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:
@@ -228,6 +228,62 @@ pub async fn fetch_var_candidates(
|
||||
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)]
|
||||
struct VarCandidateRow {
|
||||
depth: i32,
|
||||
|
||||
Reference in New Issue
Block a user