feat(secrets): group-secrets admin API, masked read, config/effective
Adds the Phase-3 admin surface on top of the group-secrets storage:
* `secrets_api` gains group routes under `/groups/{id}/secrets`
(set/list/delete, env-scoped) gated `GroupSecretsWrite` (editor+), plus
the ONE plaintext endpoint `GET /groups/{id}/secrets/{name}/value` gated
`GroupSecretsRead` (group_admin only). That is the masked-secret
boundary: a descendant app's dev sees a group secret EXISTS and consumes
it at runtime via `secrets::get`, but only a reader at the OWNING group
gets the value. App secrets stay env-agnostic (a stray `env` is rejected).
The owner is resolved first, then the capability binds to the resolved
id — never a path param.
* `config_api`: `GET /apps/{id}/config/effective` (gated `AppVarsRead`)
returns the resolved view a dev would get — every inherited var with its
value + provenance, and every inherited secret MASKED (name/owner/scope,
never the value). Backed by a new `fetch_effective_secret_meta`
(DISTINCT-ON nearest-wins, same ordering as the per-name resolver).
* authz: `GroupSecretsWrite` moves from `app:admin` to `script:write`
scope so its API-key scope matches its editor role tier (closing the
latent scope/role mismatch the checkpoint review flagged); the value
read `GroupSecretsRead` stays at `app:admin`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -263,6 +263,11 @@ impl Capability {
|
|||||||
| Self::AppUsersWrite(_)
|
| Self::AppUsersWrite(_)
|
||||||
| Self::AppUsersAdmin(_)
|
| Self::AppUsersAdmin(_)
|
||||||
| Self::AppVarsWrite(_)
|
| Self::AppVarsWrite(_)
|
||||||
|
// Group-secret WRITE is editor-role-gated (same tier as
|
||||||
|
// GroupVarsWrite), so its API-key scope matches: script:write,
|
||||||
|
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
|
||||||
|
// the admin tier below.
|
||||||
|
| Self::GroupSecretsWrite(_)
|
||||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
@@ -273,8 +278,7 @@ impl Capability {
|
|||||||
| Self::GroupWrite(_)
|
| Self::GroupWrite(_)
|
||||||
| Self::GroupAdmin(_)
|
| Self::GroupAdmin(_)
|
||||||
| Self::GroupVarsWrite(_)
|
| Self::GroupVarsWrite(_)
|
||||||
| Self::GroupSecretsRead(_)
|
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
|
||||||
| Self::GroupSecretsWrite(_) => Scope::AppAdmin,
|
|
||||||
Self::AppLogRead(_) => Scope::LogRead,
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
crates/manager-core/src/config_api.rs
Normal file
158
crates/manager-core/src/config_api.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
|
||||||
|
//! app actually sees: every inherited var (with its value + provenance) and
|
||||||
|
//! every inherited secret (MASKED — name/owner/scope only, never the value).
|
||||||
|
//!
|
||||||
|
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
|
||||||
|
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
|
||||||
|
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
|
||||||
|
//! where each value comes from (`--explain` on the CLI surfaces the
|
||||||
|
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
|
||||||
|
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
|
||||||
|
//! at the owning group via the dedicated value endpoint.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_shared::{AppId, Principal};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::config_resolver::{
|
||||||
|
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConfigApiState {
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_router(state: ConfigApiState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
|
||||||
|
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn effective_config(
|
||||||
|
State(s): State<ConfigApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ConfigApiError> {
|
||||||
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppVarsRead(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Vars: resolve to values + provenance (vars are app-readable config).
|
||||||
|
let candidates = fetch_var_candidates(&s.pool, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||||
|
let (values, provenance) = resolve(candidates);
|
||||||
|
let mut vars = serde_json::Map::new();
|
||||||
|
for (key, value) in values {
|
||||||
|
let p = &provenance[&key];
|
||||||
|
vars.insert(
|
||||||
|
key,
|
||||||
|
json!({
|
||||||
|
"value": value,
|
||||||
|
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
|
||||||
|
"scope": p.scope,
|
||||||
|
"merged_from": p.merged_from
|
||||||
|
.iter()
|
||||||
|
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets: masked — name + owner/level/scope + status, never the value.
|
||||||
|
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||||
|
let mut secrets = serde_json::Map::new();
|
||||||
|
for m in secret_meta {
|
||||||
|
secrets.insert(
|
||||||
|
m.name,
|
||||||
|
json!({
|
||||||
|
"status": "set",
|
||||||
|
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
|
||||||
|
"scope": m.scope,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
|
||||||
|
.map(|l| l.app.id)
|
||||||
|
.ok_or(ConfigApiError::AppNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigApiError {
|
||||||
|
#[error("app not found")]
|
||||||
|
AppNotFound,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
#[error("config backend: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for ConfigApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for ConfigApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ConfigApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "config effective authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Backend(e) => {
|
||||||
|
tracing::error!(error = %e, "config effective backend error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,6 +228,62 @@ pub async fn fetch_var_candidates(
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||||
|
/// which owner/level/scope supplies it — **never** the value.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EffectiveSecretMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub owner_kind: OwnerKind,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub scope: String,
|
||||||
|
pub depth: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the *names* of every secret an app effectively sees — its own
|
||||||
|
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
|
||||||
|
/// masked metadata only (owner/level/scope), so it's safe for an app-level
|
||||||
|
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
|
||||||
|
/// per-name secret resolver guarantees the same winner.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates sqlx errors.
|
||||||
|
pub async fn fetch_effective_secret_meta(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
|
||||||
|
let sql = format!(
|
||||||
|
"{CHAIN_LEVELS_CTE} \
|
||||||
|
SELECT DISTINCT ON (s.name) s.name, \
|
||||||
|
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||||
|
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||||
|
s.environment_scope, c.depth \
|
||||||
|
FROM chain c \
|
||||||
|
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||||
|
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
|
||||||
|
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
|
||||||
|
);
|
||||||
|
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
|
||||||
|
name,
|
||||||
|
owner_kind: if owner_kind == "app" {
|
||||||
|
OwnerKind::App
|
||||||
|
} else {
|
||||||
|
OwnerKind::Group
|
||||||
|
},
|
||||||
|
owner_id,
|
||||||
|
scope,
|
||||||
|
depth,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct VarCandidateRow {
|
struct VarCandidateRow {
|
||||||
depth: i32,
|
depth: i32,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub mod auth_api;
|
|||||||
pub mod auth_bootstrap;
|
pub mod auth_bootstrap;
|
||||||
pub mod auth_middleware;
|
pub mod auth_middleware;
|
||||||
pub mod authz;
|
pub mod authz;
|
||||||
|
pub mod config_api;
|
||||||
pub mod config_resolver;
|
pub mod config_resolver;
|
||||||
pub mod cron_scheduler;
|
pub mod cron_scheduler;
|
||||||
pub mod dead_letter_repo;
|
pub mod dead_letter_repo;
|
||||||
@@ -150,6 +151,7 @@ pub use auth_middleware::{
|
|||||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
||||||
};
|
};
|
||||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
|
pub use config_api::{config_router, ConfigApiError, ConfigApiState};
|
||||||
pub use cron_scheduler::spawn_cron_scheduler;
|
pub use cron_scheduler::spawn_cron_scheduler;
|
||||||
pub use dead_letter_repo::{
|
pub use dead_letter_repo::{
|
||||||
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
|
||||||
//! (v1.1.7).
|
|
||||||
//!
|
//!
|
||||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
|
||||||
//! (NEVER values).
|
//! (NEVER values).
|
||||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
|
||||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
|
//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret.
|
||||||
|
//! * `GET /groups/{id}/secrets` — list group secret names + scope
|
||||||
|
//! + updated_at (NEVER values).
|
||||||
|
//! * `POST /groups/{id}/secrets` — set/overwrite a group secret
|
||||||
|
//! (env-scoped).
|
||||||
|
//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret.
|
||||||
|
//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** —
|
||||||
|
//! the ONE endpoint that returns plaintext, gated at the owning group.
|
||||||
//!
|
//!
|
||||||
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
|
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
|
||||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
|
||||||
//! returns only names + timestamps — the dashboard never receives
|
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
|
||||||
//! plaintext. Values are encrypted with the process master key before
|
//! the masked-secret boundary: a descendant app's dev can see that a group
|
||||||
//! they touch the database (same envelope as the script `secrets::set`).
|
//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a
|
||||||
|
//! principal with read rights AT THE OWNING GROUP can read its value. The
|
||||||
|
//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the
|
||||||
|
//! capability to the resolved owner id — never to a caller-controlled path
|
||||||
|
//! param. Values are encrypted with the process master key before they
|
||||||
|
//! touch the database (owner-bound AAD; see `secrets_service`).
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -19,14 +30,15 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
|
use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
|
use crate::group_repo::GroupRepository;
|
||||||
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
|
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
|
||||||
use crate::secrets_service::seal;
|
use crate::secrets_service::{open, seal};
|
||||||
|
|
||||||
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
|
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
|
||||||
const APP_SECRET_SCOPE: &str = "*";
|
const APP_SECRET_SCOPE: &str = "*";
|
||||||
@@ -35,6 +47,7 @@ const APP_SECRET_SCOPE: &str = "*";
|
|||||||
pub struct SecretsState {
|
pub struct SecretsState {
|
||||||
pub repo: Arc<dyn SecretsRepo>,
|
pub repo: Arc<dyn SecretsRepo>,
|
||||||
pub apps: Arc<dyn AppRepository>,
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub groups: Arc<dyn GroupRepository>,
|
||||||
pub authz: Arc<dyn AuthzRepo>,
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub master_key: MasterKey,
|
pub master_key: MasterKey,
|
||||||
pub max_value_bytes: usize,
|
pub max_value_bytes: usize,
|
||||||
@@ -42,10 +55,25 @@ pub struct SecretsState {
|
|||||||
|
|
||||||
pub fn secrets_router(state: SecretsState) -> Router {
|
pub fn secrets_router(state: SecretsState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
|
.route(
|
||||||
|
"/apps/{app_id}/secrets",
|
||||||
|
get(list_app_secrets).post(set_app_secret),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/apps/{app_id}/secrets/{name}",
|
"/apps/{app_id}/secrets/{name}",
|
||||||
axum::routing::delete(delete_secret),
|
axum::routing::delete(delete_app_secret),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/groups/{group_id}/secrets",
|
||||||
|
get(list_group_secrets).post(set_group_secret),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/groups/{group_id}/secrets/{name}",
|
||||||
|
axum::routing::delete(delete_group_secret),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/groups/{group_id}/secrets/{name}/value",
|
||||||
|
get(read_group_secret_value),
|
||||||
)
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@@ -58,9 +86,18 @@ pub struct ListQuery {
|
|||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EnvQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
struct SecretItem {
|
struct SecretItem {
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Environment scope — `*` for app secrets, possibly a concrete env for
|
||||||
|
/// group secrets.
|
||||||
|
env: String,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +107,23 @@ struct ListSecretsResponse {
|
|||||||
next_cursor: Option<String>,
|
next_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_secrets(
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetSecretRequest {
|
||||||
|
pub name: String,
|
||||||
|
/// Any JSON value — the dashboard sends a single-line string, but
|
||||||
|
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
/// Environment scope (group secrets only). `*` (env-agnostic, default)
|
||||||
|
/// or a concrete env matched against `apps.environment` at resolution.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App handlers (env-agnostic, scope `*`)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_app_secrets(
|
||||||
State(s): State<SecretsState>,
|
State(s): State<SecretsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
@@ -83,36 +136,10 @@ async fn list_secrets(
|
|||||||
Capability::AppSecretsRead(app_id),
|
Capability::AppSecretsRead(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let page = s
|
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
|
||||||
.repo
|
|
||||||
.list_meta(
|
|
||||||
SecretOwner::App(app_id),
|
|
||||||
q.cursor.as_deref(),
|
|
||||||
q.limit.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(ListSecretsResponse {
|
|
||||||
secrets: page
|
|
||||||
.items
|
|
||||||
.into_iter()
|
|
||||||
.map(|m| SecretItem {
|
|
||||||
name: m.name,
|
|
||||||
updated_at: m.updated_at,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
next_cursor: page.next_cursor,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
async fn set_app_secret(
|
||||||
pub struct SetSecretRequest {
|
|
||||||
pub name: String,
|
|
||||||
/// Any JSON value — the dashboard sends a single-line string, but
|
|
||||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
|
||||||
pub value: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_secret(
|
|
||||||
State(s): State<SecretsState>,
|
State(s): State<SecretsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
@@ -125,31 +152,17 @@ async fn set_secret(
|
|||||||
Capability::AppSecretsWrite(app_id),
|
Capability::AppSecretsWrite(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
validate_secret_name(&input.name)?;
|
// App secrets are always env-agnostic; reject a stray `env`.
|
||||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
|
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
|
||||||
// (app_id, name). Same path as the SDK secrets::set.
|
return Err(SecretsApiError::Invalid(
|
||||||
let owner = SecretOwner::App(app_id);
|
"app secrets are env-agnostic; set an environment scope on a group secret instead"
|
||||||
let (ciphertext, nonce, version) = seal(
|
.into(),
|
||||||
&s.master_key,
|
));
|
||||||
owner,
|
}
|
||||||
&input.name,
|
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
|
||||||
&input.value,
|
|
||||||
s.max_value_bytes,
|
|
||||||
)?;
|
|
||||||
s.repo
|
|
||||||
.set(
|
|
||||||
owner,
|
|
||||||
APP_SECRET_SCOPE,
|
|
||||||
&input.name,
|
|
||||||
&ciphertext,
|
|
||||||
&nonce,
|
|
||||||
version,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_secret(
|
async fn delete_app_secret(
|
||||||
State(s): State<SecretsState>,
|
State(s): State<SecretsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Path((id_or_slug, name)): Path<(String, String)>,
|
Path((id_or_slug, name)): Path<(String, String)>,
|
||||||
@@ -161,16 +174,161 @@ async fn delete_secret(
|
|||||||
Capability::AppSecretsWrite(app_id),
|
Capability::AppSecretsWrite(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !s
|
delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Group handlers (env-scoped)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_group_secrets(
|
||||||
|
State(s): State<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<ListQuery>,
|
||||||
|
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||||
|
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupSecretsWrite(group_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_group_secret(
|
||||||
|
State(s): State<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SetSecretRequest>,
|
||||||
|
) -> Result<StatusCode, SecretsApiError> {
|
||||||
|
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupSecretsWrite(group_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||||
|
validate_env_scope(&env)?;
|
||||||
|
seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_group_secret(
|
||||||
|
State(s): State<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, name)): Path<(String, String)>,
|
||||||
|
Query(q): Query<EnvQuery>,
|
||||||
|
) -> Result<StatusCode, SecretsApiError> {
|
||||||
|
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupSecretsWrite(group_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||||
|
validate_env_scope(&env)?;
|
||||||
|
delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead`
|
||||||
|
/// (group_admin) — the masked-secret boundary. A descendant app's dev can
|
||||||
|
/// see the secret exists and consume it at runtime, but only a reader at the
|
||||||
|
/// owning group gets the value here.
|
||||||
|
async fn read_group_secret_value(
|
||||||
|
State(s): State<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, name)): Path<(String, String)>,
|
||||||
|
Query(q): Query<EnvQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, SecretsApiError> {
|
||||||
|
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupSecretsRead(group_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
validate_secret_name(&name)?;
|
||||||
|
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||||
|
validate_env_scope(&env)?;
|
||||||
|
let owner = SecretOwner::Group(group_id);
|
||||||
|
let stored = s
|
||||||
.repo
|
.repo
|
||||||
.delete(SecretOwner::App(app_id), APP_SECRET_SCOPE, &name)
|
.get(owner, &env, &name)
|
||||||
.await?
|
.await?
|
||||||
{
|
.ok_or(SecretsApiError::NotFound)?;
|
||||||
|
let value = open(&s.master_key, owner, &name, &stored).map_err(|e| {
|
||||||
|
tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted");
|
||||||
|
SecretsApiError::from(e)
|
||||||
|
})?;
|
||||||
|
Ok(Json(json!({ "name": name, "env": env, "value": value })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Shared owner-generic bodies
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_meta(
|
||||||
|
repo: &dyn SecretsRepo,
|
||||||
|
owner: SecretOwner,
|
||||||
|
q: &ListQuery,
|
||||||
|
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||||
|
let page = repo
|
||||||
|
.list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||||
|
.await?;
|
||||||
|
Ok(Json(ListSecretsResponse {
|
||||||
|
secrets: page
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| SecretItem {
|
||||||
|
name: m.name,
|
||||||
|
env: m.environment_scope,
|
||||||
|
updated_at: m.updated_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
next_cursor: page.next_cursor,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seal_and_store(
|
||||||
|
s: &SecretsState,
|
||||||
|
owner: SecretOwner,
|
||||||
|
env: &str,
|
||||||
|
input: SetSecretRequest,
|
||||||
|
) -> Result<StatusCode, SecretsApiError> {
|
||||||
|
validate_secret_name(&input.name)?;
|
||||||
|
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name.
|
||||||
|
let (ciphertext, nonce, version) = seal(
|
||||||
|
&s.master_key,
|
||||||
|
owner,
|
||||||
|
&input.name,
|
||||||
|
&input.value,
|
||||||
|
s.max_value_bytes,
|
||||||
|
)?;
|
||||||
|
s.repo
|
||||||
|
.set(owner, env, &input.name, &ciphertext, &nonce, version)
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
repo: &dyn SecretsRepo,
|
||||||
|
owner: SecretOwner,
|
||||||
|
env: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<StatusCode, SecretsApiError> {
|
||||||
|
if !repo.delete(owner, env, name).await? {
|
||||||
return Err(SecretsApiError::NotFound);
|
return Err(SecretsApiError::NotFound);
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Resolution + validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
||||||
crate::app_repo::resolve_app(apps, ident)
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
.await
|
.await
|
||||||
@@ -179,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
|
|||||||
.ok_or(SecretsApiError::AppNotFound)
|
.ok_or(SecretsApiError::AppNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_group(
|
||||||
|
groups: &dyn GroupRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<GroupId, SecretsApiError> {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
groups
|
||||||
|
.get_by_id(uuid.into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
groups
|
||||||
|
.get_by_slug(ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||||
|
};
|
||||||
|
found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars
|
||||||
|
/// admin validator so a secret and a var share the same env vocabulary.
|
||||||
|
fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> {
|
||||||
|
if env == "*" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if env.is_empty() || env.len() > 63 {
|
||||||
|
return Err(SecretsApiError::Invalid(
|
||||||
|
"env must be '*' or 1–63 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let first = env.chars().next().unwrap();
|
||||||
|
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||||
|
return Err(SecretsApiError::Invalid(
|
||||||
|
"env must start with a lowercase letter or digit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !env
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||||
|
{
|
||||||
|
return Err(SecretsApiError::Invalid(
|
||||||
|
"env may contain only lowercase letters, digits, and hyphens".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum SecretsApiError {
|
pub enum SecretsApiError {
|
||||||
#[error("app not found")]
|
#[error("app not found")]
|
||||||
AppNotFound,
|
AppNotFound,
|
||||||
|
#[error("group not found")]
|
||||||
|
GroupNotFound,
|
||||||
#[error("secret not found")]
|
#[error("secret not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
#[error("invalid request: {0}")]
|
#[error("invalid request: {0}")]
|
||||||
@@ -233,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
|
|||||||
impl IntoResponse for SecretsApiError {
|
impl IntoResponse for SecretsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
Self::AppNotFound | Self::NotFound => {
|
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
}
|
}
|
||||||
Self::Invalid(_) => (
|
Self::Invalid(_) => (
|
||||||
|
|||||||
@@ -513,6 +513,7 @@ pub async fn build_app(
|
|||||||
let secrets_state = SecretsState {
|
let secrets_state = SecretsState {
|
||||||
repo: secrets_repo,
|
repo: secrets_repo,
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
|
groups: groups_repo.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
master_key,
|
master_key,
|
||||||
max_value_bytes: secrets_max_value_bytes,
|
max_value_bytes: secrets_max_value_bytes,
|
||||||
@@ -576,6 +577,11 @@ pub async fn build_app(
|
|||||||
groups: groups_repo.clone(),
|
groups: groups_repo.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
|
let config_state = picloud_manager_core::ConfigApiState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
apps: apps_repo.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||||
apps: apps_state.apps.clone(),
|
apps: apps_state.apps.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
@@ -600,6 +606,7 @@ pub async fn build_app(
|
|||||||
.merge(app_members_router(app_members_state))
|
.merge(app_members_router(app_members_state))
|
||||||
.merge(groups_router(groups_state))
|
.merge(groups_router(groups_state))
|
||||||
.merge(vars_router(vars_state))
|
.merge(vars_router(vars_state))
|
||||||
|
.merge(picloud_manager_core::config_router(config_state))
|
||||||
.merge(picloud_manager_core::app_users_router(
|
.merge(picloud_manager_core::app_users_router(
|
||||||
app_users_admin_state,
|
app_users_admin_state,
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user