fix(security): server-authoritative approval gate + auth/session/file hardening
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit. H1 — the per-env approval gate is now server-authoritative. The governing project is resolved from the target node's nearest-claimed ancestor (`governing_env_policy`/`_tree` + `governing_project_id` + `ProjectRepository::get_environments_by_id`), independent of the client-supplied `[project]`. Omitting or spoofing the project block can no longer skip a gate the owning project established; a to-create group resolves its declared parent's chain so a fresh subtree node inherits the gate. Fails closed on any read error. H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe `rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request task (unauthenticated per-request DoS). Regression test added. C1 — admin sessions gain an absolute lifetime cap (migration 0070, `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch` clamps the sliding bump to it, so a continuously-used or stolen-but-warm token self-expires. Mirrors the data-plane app-user cap. C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two that return a raw credential), so a proxy/CDN/browser cache can't retain it. B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and group download paths now set attachment + `X-Content-Type-Options: nosniff` + a restrictive CSP, closing a group-path stored-XSS gap. Also folds in the server-side plan-warning plumbing (`plan_warnings`, `PlanResult::warnings`) and the `app_only_reject` message helper that the CLI plan-preview change builds on, plus operator security notes (reads-open shared- topic SSE; the `--env` label is advisory, not a boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,13 +17,15 @@ pub enum AdminSessionRepositoryError {
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Result of a session lookup. Includes the user id (for auth context)
|
||||
/// and the existing `expires_at` so the middleware can decide whether
|
||||
/// the sliding window bump is worth a write.
|
||||
/// Result of a session lookup. Includes the user id (for auth context),
|
||||
/// the existing `expires_at` (so the middleware can decide whether the
|
||||
/// sliding-window bump is worth a write), and the `absolute_expires_at`
|
||||
/// hard cap the bump must clamp at (C1).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminSessionLookup {
|
||||
pub user_id: AdminUserId,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub absolute_expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -33,9 +35,11 @@ pub trait AdminSessionRepository: Send + Sync {
|
||||
user_id: AdminUserId,
|
||||
token_hash: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
absolute_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), AdminSessionRepositoryError>;
|
||||
/// Look up a session by token hash. Returns `None` for missing or
|
||||
/// already-expired rows (the query filters them).
|
||||
/// already-expired rows — either the sliding `expires_at` OR the
|
||||
/// absolute cap having passed (the query filters both).
|
||||
async fn lookup(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
@@ -78,14 +82,16 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
||||
user_id: AdminUserId,
|
||||
token_hash: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
absolute_expires_at: DateTime<Utc>,
|
||||
) -> Result<(), AdminSessionRepositoryError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
|
||||
VALUES ($1, $2, $3)",
|
||||
"INSERT INTO admin_sessions (token_hash, user_id, expires_at, absolute_expires_at) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(user_id.into_inner())
|
||||
.bind(expires_at)
|
||||
.bind(absolute_expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -95,16 +101,17 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
||||
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT user_id, expires_at FROM admin_sessions \
|
||||
WHERE token_hash = $1 AND expires_at > NOW()",
|
||||
let row: Option<(uuid::Uuid, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT user_id, expires_at, absolute_expires_at FROM admin_sessions \
|
||||
WHERE token_hash = $1 AND expires_at > NOW() AND absolute_expires_at > NOW()",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(uid, exp)| AdminSessionLookup {
|
||||
Ok(row.map(|(uid, exp, abs)| AdminSessionLookup {
|
||||
user_id: uid.into(),
|
||||
expires_at: exp,
|
||||
absolute_expires_at: abs,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -144,9 +151,12 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
||||
}
|
||||
|
||||
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM admin_sessions \
|
||||
WHERE expires_at <= NOW() OR absolute_expires_at <= NOW()",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get};
|
||||
use axum::{Extension, Router};
|
||||
@@ -116,7 +116,7 @@ async fn mint_key(
|
||||
State(state): State<ApiKeysState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<MintApiKeyRequest>,
|
||||
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||
) -> Result<(StatusCode, HeaderMap, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||
validate_name(&input.name)?;
|
||||
validate_scopes(&input.scopes, input.app_id)?;
|
||||
|
||||
@@ -135,6 +135,8 @@ async fn mint_key(
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
// C2: the response carries the raw key exactly once — never cache it.
|
||||
crate::auth_api::no_store_headers(),
|
||||
Json(MintApiKeyResponse {
|
||||
key: row.into(),
|
||||
raw_token: minted.raw,
|
||||
|
||||
@@ -231,12 +231,15 @@ async fn apply_handler(
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||
// §3 M3: server-authoritative per-env approval gate, evaluated against the
|
||||
// persisted ∪ declared policy (hermetic — an omitted policy can't bypass a
|
||||
// gate a prior apply established). If gated and approved, the override
|
||||
// additionally requires ADMIN on the node (a step up from the editor write
|
||||
// caps above) + is audited.
|
||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
||||
// §3 M3: server-authoritative per-env approval gate. The governing policy is
|
||||
// loaded from the project the SERVER resolves as owning this node (its
|
||||
// nearest-claimed ancestor), unioned with the declared policy — so a request
|
||||
// that omits or spoofs `[project]` can't bypass a gate the owning project
|
||||
// established. If gated and approved, the override additionally requires
|
||||
// ADMIN on the node (a step up from the editor write caps above) + is audited.
|
||||
let policy = svc
|
||||
.governing_env_policy(ApplyOwner::App(app_id), req.project.as_ref())
|
||||
.await?;
|
||||
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
|
||||
.await
|
||||
@@ -320,9 +323,11 @@ async fn group_apply_handler(
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
|
||||
.await?;
|
||||
// §3 M3: per-env approval gate (persisted ∪ declared) — an approved gated
|
||||
// apply requires GroupAdmin.
|
||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
||||
// §3 M3: per-env approval gate (governing ∪ declared, server-resolved owner)
|
||||
// — an approved gated apply requires GroupAdmin.
|
||||
let policy = svc
|
||||
.governing_env_policy(ApplyOwner::Group(group_id), req.project.as_ref())
|
||||
.await?;
|
||||
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
@@ -406,10 +411,14 @@ async fn tree_apply_handler(
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||
// §3 M3: server-authoritative per-env approval gate (persisted ∪ declared).
|
||||
// On an approved gated apply, require ADMIN on EVERY declared node (a step up
|
||||
// from the editor write caps authz_tree checks) + audit.
|
||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
||||
// §3 M3: server-authoritative per-env approval gate (governing ∪ declared).
|
||||
// The governing side is the union of every declared node's owning-project
|
||||
// policy, resolved server-side — omitting `[project]` can't dodge it. On an
|
||||
// approved gated apply, require ADMIN on EVERY declared node (a step up from
|
||||
// the editor write caps authz_tree checks) + audit.
|
||||
let policy = svc
|
||||
.governing_env_policy_tree(&req.bundle, req.project.as_ref())
|
||||
.await?;
|
||||
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||
require_admin_on_every_node(&svc, &principal, &req.bundle).await?;
|
||||
audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len());
|
||||
@@ -432,10 +441,11 @@ async fn tree_apply_handler(
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the
|
||||
/// EFFECTIVE policy (`persisted ∪ declared`, built by
|
||||
/// `ApplyService::effective_env_policy`) — the persisted side is authoritative,
|
||||
/// so a request that omits or weakens the policy can't bypass a gate a prior
|
||||
/// apply established. Returns `Ok(true)` when the target env is confirm-required
|
||||
/// EFFECTIVE policy (`governing ∪ declared`, built by
|
||||
/// `ApplyService::governing_env_policy`) — the governing side is loaded from the
|
||||
/// project the SERVER resolves as owning the node, so a request that omits or
|
||||
/// weakens `[project]` can't bypass a gate the owning project established.
|
||||
/// Returns `Ok(true)` when the target env is confirm-required
|
||||
/// AND approved — the caller must then require admin + audit. `Ok(false)` when
|
||||
/// not gated (no env, or `confirm = false` everywhere). `Err(ApprovalRequired)`
|
||||
/// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here).
|
||||
|
||||
@@ -485,6 +485,13 @@ pub struct PlanResult {
|
||||
/// `plan` time, before an `apply` refuses.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub approvals_required: Vec<String>,
|
||||
/// The desired-state warnings `apply` would emit, computed at plan so `pic
|
||||
/// plan` is a faithful preview for CI: an enabled binding on a disabled
|
||||
/// script (deployed-but-unreachable), an endpoint with no route/trigger, a
|
||||
/// `[suppress]` that matches no inherited template. Post-commit side-effect
|
||||
/// warnings (route-table refresh, materialization skips) are apply-only.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -695,6 +702,10 @@ pub struct NodePlan {
|
||||
/// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub structure: Option<StructurePreview>,
|
||||
/// The desired-state warnings this node's apply would emit (same set as
|
||||
/// [`PlanResult::warnings`]), so `pic plan --dir` previews them per node.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token
|
||||
@@ -895,47 +906,17 @@ impl ApplyService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an
|
||||
/// apply/plan — `env_name -> confirm`, computed as `persisted ∪ declared`
|
||||
/// (an env is confirm-required if EITHER the stored policy OR the request's
|
||||
/// `[project.environments]` marks it so). Loading the persisted side is what
|
||||
/// makes the gate hermetic: a request that omits (or flips-to-false) a policy
|
||||
/// a prior apply persisted still trips the gate. Empty when no project is
|
||||
/// declared. A persisted-read failure surfaces as `Backend` (fail-closed —
|
||||
/// we never silently drop a gate).
|
||||
pub async fn effective_env_policy(
|
||||
&self,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let Some(project) = project else {
|
||||
return Ok(std::collections::BTreeMap::new());
|
||||
};
|
||||
let mut effective = self
|
||||
.projects
|
||||
.get_environments_by_slug(&project.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
for (env, pol) in &project.environments {
|
||||
// Union on confirm: a declared gate strengthens, never weakens.
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
/// The sorted names of every effectively-gated env (`effective_env_policy`
|
||||
/// filtered to `confirm`), surfaced as `plan.approvals_required`.
|
||||
async fn effective_gated_envs(
|
||||
&self,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<Vec<String>, ApplyError> {
|
||||
let policy = self.effective_env_policy(project).await?;
|
||||
/// The sorted names of every gated env in a computed policy (`confirm`
|
||||
/// entries), surfaced as `plan.approvals_required`. Callers pass the
|
||||
/// governing policy so plan's preview matches what apply gates on.
|
||||
fn gated_envs_from(policy: &std::collections::BTreeMap<String, bool>) -> Vec<String> {
|
||||
let mut gated: Vec<String> = policy
|
||||
.into_iter()
|
||||
.filter_map(|(env, confirm)| confirm.then_some(env))
|
||||
.iter()
|
||||
.filter(|&(_, confirm)| *confirm)
|
||||
.map(|(env, _)| env.clone())
|
||||
.collect();
|
||||
gated.sort_unstable();
|
||||
Ok(gated)
|
||||
gated
|
||||
}
|
||||
|
||||
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
|
||||
@@ -979,7 +960,10 @@ impl ApplyService {
|
||||
// `apply` can refuse if the node changed underneath it (§4.2).
|
||||
state_token: state_token_with_names(¤t, ¤t_names),
|
||||
ownership,
|
||||
approvals_required: self.effective_gated_envs(project).await?,
|
||||
approvals_required: Self::gated_envs_from(
|
||||
&self.governing_env_policy(owner, project).await?,
|
||||
),
|
||||
warnings: self.plan_warnings(owner, bundle).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1100,10 +1084,9 @@ impl ApplyService {
|
||||
// it the reconcile would insert an inert `app_id`-owned marker row that
|
||||
// no resolver ever reads.
|
||||
if !is_group && !bundle.collections.is_empty() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app manifest cannot declare shared collections — \
|
||||
they are owned by a group"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"manifest cannot declare shared collections",
|
||||
"they are owned by a group",
|
||||
));
|
||||
}
|
||||
// §11 tail: `sealed` is a GROUP-template property — it makes an
|
||||
@@ -1114,26 +1097,26 @@ impl ApplyService {
|
||||
// node inherits down).
|
||||
if !is_group {
|
||||
if bundle.routes.iter().any(|r| r.sealed) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app route cannot be `sealed` — sealing marks a group \
|
||||
template as non-suppressible; an app route is never inherited"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"route cannot be `sealed`",
|
||||
"sealing marks a group template as non-suppressible; an app \
|
||||
route is never inherited",
|
||||
));
|
||||
}
|
||||
if bundle.triggers.iter().any(BundleTrigger::sealed) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app trigger cannot be `sealed` — sealing marks a group \
|
||||
template as non-suppressible; an app trigger is never inherited"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"trigger cannot be `sealed`",
|
||||
"sealing marks a group template as non-suppressible; an app \
|
||||
trigger is never inherited",
|
||||
));
|
||||
}
|
||||
// §11.6: `shared` watches a group's SHARED collection — meaningless
|
||||
// for an app (apps don't own shared collections).
|
||||
if bundle.triggers.iter().any(BundleTrigger::shared) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app trigger cannot be `shared` — a shared-collection \
|
||||
trigger is owned by the group that declares the collection"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"trigger cannot be `shared`",
|
||||
"a shared-collection trigger is owned by the group that \
|
||||
declares the collection",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1710,24 +1693,11 @@ impl ApplyService {
|
||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||
let plan = compute_diff_with_names(¤t, bundle, ¤t_names);
|
||||
let mut report = ApplyReport::default();
|
||||
// §4.7 warning: an enabled binding pointing at a disabled script is
|
||||
// deployed-but-unreachable. Not an error (it's valid desired state),
|
||||
// but surfaced so the operator isn't surprised by a silent 404.
|
||||
report.warnings.extend(disabled_target_warnings(bundle));
|
||||
// A group endpoint is reached by *inheritance* (a descendant app binds
|
||||
// it by name), never by its own route, so the "no route/trigger"
|
||||
// warning is noise for every group script — emit it for apps only.
|
||||
if let ApplyOwner::App(_) = owner {
|
||||
report
|
||||
.warnings
|
||||
.extend(unreachable_endpoint_warnings(bundle));
|
||||
}
|
||||
// §11 tail: a suppress reference that matches no inherited template (or
|
||||
// only sealed ones) silently does nothing — warn (typo guard), don't
|
||||
// fail. M1: runs for a group node too (it walks the group's own chain).
|
||||
// The desired-state warnings — identical to what `pic plan` previews, so
|
||||
// plan and apply agree (see `plan_warnings`).
|
||||
report
|
||||
.warnings
|
||||
.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
||||
.extend(self.plan_warnings(owner, bundle).await?);
|
||||
|
||||
self.reconcile_node_tx(
|
||||
&mut tx,
|
||||
@@ -1800,16 +1770,20 @@ impl ApplyService {
|
||||
// §6 M2: for an existing group node, preview a structural divergence
|
||||
// (server parent ≠ manifest parent) before apply.
|
||||
let structure = self.divergence_preview(p.node, &existing).await?;
|
||||
let warnings = self.plan_warnings(p.owner, &p.node.bundle).await?;
|
||||
nodes.push(NodePlan {
|
||||
kind: p.node.kind,
|
||||
slug: p.node.slug.clone(),
|
||||
plan: p.plan,
|
||||
ownership,
|
||||
structure,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
// §6: a to-create group is previewed with a full-create plan; if the
|
||||
// repo declares a `[project]`, the create will also CLAIM it.
|
||||
// repo declares a `[project]`, the create will also CLAIM it. It has no
|
||||
// node id yet (no chain to walk), so only the pure desired-state warnings
|
||||
// apply — a disabled binding; unreachable/suppress need an existing owner.
|
||||
for c in to_create {
|
||||
let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
|
||||
nodes.push(NodePlan {
|
||||
@@ -1818,12 +1792,15 @@ impl ApplyService {
|
||||
plan: c.plan,
|
||||
ownership,
|
||||
structure: None,
|
||||
warnings: disabled_target_warnings(&c.node.bundle),
|
||||
});
|
||||
}
|
||||
Ok(TreePlanResult {
|
||||
nodes,
|
||||
state_token: token,
|
||||
approvals_required: self.effective_gated_envs(project).await?,
|
||||
approvals_required: Self::gated_envs_from(
|
||||
&self.governing_env_policy_tree(bundle, project).await?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2735,6 +2712,147 @@ impl ApplyService {
|
||||
Ok(Some((pid, slug)))
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate, server-authoritative): the id of the project that
|
||||
/// GOVERNS `owner` — its nearest-claimed ancestor (inclusive: a group node
|
||||
/// uses its own claim first, then walks up; an app node walks its group
|
||||
/// chain). This is the same ownership walk `ownership_preview` uses, and it
|
||||
/// is deliberately independent of any client-supplied `[project]` — the
|
||||
/// server decides which project's approval policy applies.
|
||||
async fn governing_project_id(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Option<ProjectId>, ApplyError> {
|
||||
let anchor = match owner {
|
||||
ApplyOwner::Group(g) => g,
|
||||
ApplyOwner::App(a) => {
|
||||
let Some(app) = self
|
||||
.apps
|
||||
.get_by_id(a)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
app.group_id
|
||||
}
|
||||
};
|
||||
let chain = self
|
||||
.groups
|
||||
.ancestors(anchor)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(chain.iter().find_map(|g| g.owner_project))
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an apply
|
||||
/// to `owner` — `env -> confirm` — computed as `governing ∪ declared`. The
|
||||
/// GOVERNING side is loaded from the project the server RESOLVED as owning
|
||||
/// the target node (`governing_project_id`), NOT from the client's declared
|
||||
/// slug — so a request that omits or spoofs `[project]` still trips a gate
|
||||
/// the owning project established. The declared side only STRENGTHENS (union
|
||||
/// on confirm), never weakens. A persisted-read error surfaces as `Backend`
|
||||
/// (fail-closed — a gate is never silently dropped).
|
||||
pub async fn governing_env_policy(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
declared: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let mut effective = match self.governing_project_id(owner).await? {
|
||||
Some(pid) => self
|
||||
.projects
|
||||
.get_environments_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
None => std::collections::BTreeMap::new(),
|
||||
};
|
||||
if let Some(decl) = declared {
|
||||
for (env, pol) in &decl.environments {
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate) for a TREE apply: the union of every declared
|
||||
/// node's governing policy with the declared side. Each existing node is
|
||||
/// resolved to its owning project server-side; a to-create group node has
|
||||
/// nothing persisted to gate (its claim is established by this same apply)
|
||||
/// so it contributes only the declared policy. Un-bypassable for the real
|
||||
/// case — applying to existing nodes under a claimed, gated subtree.
|
||||
pub async fn governing_env_policy_tree(
|
||||
&self,
|
||||
bundle: &TreeBundle,
|
||||
declared: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let mut effective: std::collections::BTreeMap<String, bool> =
|
||||
std::collections::BTreeMap::new();
|
||||
for node in &bundle.nodes {
|
||||
// The project that governs this node. An existing node uses its own
|
||||
// ownership walk. A to-create GROUP has no id yet, so resolve the
|
||||
// governing project from its DECLARED parent's chain — a gated,
|
||||
// claimed pre-existing ancestor still governs a node created beneath
|
||||
// it, closing the whole-block-omission gap for a fresh subtree node.
|
||||
let governing_pid = match node.kind {
|
||||
NodeKind::App => {
|
||||
// Apps are never to-create in the tree path (resolve errors),
|
||||
// so a miss here means the app truly doesn't exist yet.
|
||||
match crate::app_repo::resolve_app(self.apps.as_ref(), &node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
Some(l) => self.governing_project_id(ApplyOwner::App(l.app.id)).await?,
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
NodeKind::Group => match self
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
Some(g) => self.governing_project_id(ApplyOwner::Group(g.id)).await?,
|
||||
None => match node.parent.as_deref() {
|
||||
Some(parent_slug) => match self
|
||||
.groups
|
||||
.get_by_slug(parent_slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
Some(parent) => {
|
||||
self.governing_project_id(ApplyOwner::Group(parent.id))
|
||||
.await?
|
||||
}
|
||||
// Parent is itself to-create (no persisted claim yet,
|
||||
// so no persisted gate from it) or absent → nothing to
|
||||
// govern this node with.
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
},
|
||||
},
|
||||
};
|
||||
if let Some(pid) = governing_pid {
|
||||
for (env, confirm) in self
|
||||
.projects
|
||||
.get_environments_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
let entry = effective.entry(env).or_insert(false);
|
||||
*entry = *entry || confirm;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(decl) = declared {
|
||||
for (env, pol) in &decl.environments {
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// §6/§7 attach point — a project's ceiling. Every applied node must live
|
||||
// strictly within the subtree rooted at the declared `parent_group`.
|
||||
@@ -3389,6 +3507,27 @@ impl ApplyService {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The desired-state warnings a plan/apply of `bundle` onto `owner` would
|
||||
/// emit — the ones knowable BEFORE committing, so `pic plan` previews exactly
|
||||
/// what `apply` reports (post-commit side-effect warnings like route-table
|
||||
/// refresh or materialization skips are apply-only). Covers: an enabled
|
||||
/// binding on a disabled script (deployed-but-unreachable), an endpoint with
|
||||
/// no route/trigger (apps only — a group endpoint is reached by inheritance),
|
||||
/// and a `[suppress]` that matches no inherited template.
|
||||
async fn plan_warnings(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
bundle: &Bundle,
|
||||
) -> Result<Vec<String>, ApplyError> {
|
||||
let mut warnings = Vec::new();
|
||||
warnings.extend(disabled_target_warnings(bundle));
|
||||
if let ApplyOwner::App(_) = owner {
|
||||
warnings.extend(unreachable_endpoint_warnings(bundle));
|
||||
}
|
||||
warnings.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
/// §11 tail typo guard: a suppress reference that matches **no** inherited
|
||||
/// template on the app's chain does nothing — return a warning for each such
|
||||
/// dangling reference (soft; never fails the apply). Trigger refs match an
|
||||
@@ -4843,6 +4982,14 @@ fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
/// script the manifest marks *disabled* is deployed but unreachable (the
|
||||
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
|
||||
/// not an error — keyed on the bundle alone.
|
||||
/// Consistent phrasing for a GROUP-only feature declared on an APP node — the
|
||||
/// several such guards in `validate_bundle_for` (§11.6 shared collections, §11
|
||||
/// tail `sealed`, shared triggers). Renders `an app <what> — <why>` so the
|
||||
/// app/group boundary reads the same everywhere.
|
||||
fn app_only_reject(what: &str, why: &str) -> ApplyError {
|
||||
ApplyError::Invalid(format!("an app {what} — {why}"))
|
||||
}
|
||||
|
||||
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
let disabled: HashSet<&str> = bundle
|
||||
.scripts
|
||||
|
||||
@@ -156,12 +156,19 @@ async fn login(
|
||||
};
|
||||
|
||||
let token = generate_session_token();
|
||||
let expires_at = Utc::now()
|
||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||
let now = Utc::now();
|
||||
// C1: an admin session dies at the EARLIER of its sliding window and its
|
||||
// absolute cap. At create the sliding window is shorter, so `expires_at`
|
||||
// starts as `now + ttl`; the absolute cap `now + absolute_ttl` is stored so
|
||||
// the sliding `touch` can clamp to it as the session ages.
|
||||
let expires_at =
|
||||
now + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||
let absolute_expires_at = now
|
||||
+ ChronoDuration::from_std(state.absolute_ttl).unwrap_or_else(|_| ChronoDuration::days(30));
|
||||
|
||||
if let Err(err) = state
|
||||
.sessions
|
||||
.create(user_id, &token.hash, expires_at)
|
||||
.create(user_id, &token.hash, expires_at, absolute_expires_at)
|
||||
.await
|
||||
{
|
||||
tracing::error!(?err, "admin_sessions insert failed");
|
||||
@@ -174,6 +181,7 @@ async fn login(
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
no_store_headers(),
|
||||
Json(LoginResponse {
|
||||
user: AdminUserDto {
|
||||
id: user_row.id,
|
||||
@@ -188,6 +196,15 @@ async fn login(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `Cache-Control: no-store` for a response carrying a raw credential (a session
|
||||
/// token, an API key). Prevents a browser / proxy / CDN cache from retaining the
|
||||
/// secret where a later client could read it back. (C2)
|
||||
pub(crate) fn no_store_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers
|
||||
}
|
||||
|
||||
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||
// Pull token without requiring a valid session (logout is idempotent).
|
||||
let token = extract_token_for_logout(&req);
|
||||
|
||||
@@ -137,6 +137,11 @@ pub struct AuthState {
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
pub ttl: Duration,
|
||||
/// C1: absolute hard cap on an admin session's lifetime. The sliding
|
||||
/// `touch` bump is clamped at `session_start + absolute_ttl`, so even a
|
||||
/// continuously-used token self-expires. Set at login via the session's
|
||||
/// stored `absolute_expires_at`; this value seeds that at create time.
|
||||
pub absolute_ttl: Duration,
|
||||
/// F-P-009 — shared cache of resolved Principals. Constructed once
|
||||
/// at startup and cloned (same `Arc`) into every router state that
|
||||
/// resolves or revokes credentials, so a revocation-side eviction is
|
||||
@@ -279,8 +284,10 @@ async fn verify_session(
|
||||
};
|
||||
|
||||
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||
// than silent stale sessions. Same shape as Phase 3a.
|
||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||
// than silent stale sessions. C1: clamp the bump at the session's absolute
|
||||
// cap so a continuously-used token can't slide forever.
|
||||
let sliding = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||
let new_expires_at = sliding.min(lookup.absolute_expires_at);
|
||||
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||
tracing::error!(?err, "admin_sessions touch failed");
|
||||
return Err(InternalError);
|
||||
@@ -304,7 +311,15 @@ async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principa
|
||||
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||
return Ok(None);
|
||||
}
|
||||
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
||||
// `rest` is the raw, attacker-controlled bearer value after `pic_` — it is
|
||||
// NOT yet validated as the base32 body a real key has, so a byte-index slice
|
||||
// (`&rest[..8]`) could split a multibyte UTF-8 codepoint straddling byte 8
|
||||
// and panic, aborting the request task (an unauthenticated DoS). `get(..)`
|
||||
// returns `None` at a non-char-boundary, so a malformed bearer falls through
|
||||
// to "no match" instead of panicking.
|
||||
let Some(prefix) = rest.get(..API_KEY_PREFIX_LEN) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||
Ok(v) => v,
|
||||
@@ -508,6 +523,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_prefix_slice_is_char_boundary_safe() {
|
||||
// H2 regression (audit 2026-07-11): `rest` is the raw bearer body after
|
||||
// `pic_`, sliced BEFORE any base32 validation. A byte-index slice
|
||||
// `&rest[..API_KEY_PREFIX_LEN]` panics when a multibyte codepoint
|
||||
// straddles byte 8. `€` is 3 bytes, so `aaaaaa€…` puts byte 8 mid-
|
||||
// codepoint — the boundary-safe `get(..)` must return None, not panic.
|
||||
let malformed = "aaaaaa\u{20ac}bbbb";
|
||||
assert!(malformed.len() > API_KEY_PREFIX_LEN);
|
||||
assert!(malformed.get(..API_KEY_PREFIX_LEN).is_none());
|
||||
// A clean ASCII body slices to exactly the indexed prefix.
|
||||
assert_eq!("abcdefghXYZ".get(..API_KEY_PREFIX_LEN), Some("abcdefgh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evict_user_drops_only_that_users_entries() {
|
||||
// Audit 2026-06-11 (PrincipalCache revocation-lag).
|
||||
|
||||
@@ -163,9 +163,12 @@ async fn get_file(
|
||||
.await?
|
||||
.ok_or(FilesApiError::NotFound)?;
|
||||
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
||||
// The stored name is upload-supplied; sanitize to header-safe ASCII so the
|
||||
// response builder can never error on a control byte (which `.expect()`
|
||||
// below would turn into a per-request panic).
|
||||
let disposition = format!(
|
||||
"attachment; filename=\"{}\"",
|
||||
meta.name.replace('"', "").replace(['\r', '\n'], "")
|
||||
picloud_shared::sanitize_stored_filename(&meta.name)
|
||||
);
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
@@ -287,14 +288,29 @@ async fn get_file(
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
.ok_or(GroupBlobsError::NotFound)?;
|
||||
Ok((
|
||||
[
|
||||
(CONTENT_TYPE, meta.content_type),
|
||||
(CONTENT_LENGTH, bytes.len().to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
// Same download hardening as the per-app path (`files_api::get_file`): a
|
||||
// sanitized content-type, forced `attachment` disposition with a header-safe
|
||||
// filename, plus nosniff/CSP so a stored HTML/SVG shared file can't render
|
||||
// inline (stored-XSS) and no attacker-influenced byte can panic the builder.
|
||||
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
||||
let disposition = format!(
|
||||
"attachment; filename=\"{}\"",
|
||||
picloud_shared::sanitize_stored_filename(&meta.name)
|
||||
);
|
||||
let len = bytes.len();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, safe_ct)
|
||||
.header(CONTENT_DISPOSITION, disposition)
|
||||
.header(CONTENT_LENGTH, len)
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||
)
|
||||
.header("Referrer-Policy", "no-referrer")
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
/// §11.6 D3: list a group's shared-queue dead-letters (newest-first). Guarded by
|
||||
|
||||
@@ -35,6 +35,15 @@ pub trait ProjectRepository: Send + Sync {
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError>;
|
||||
/// §3 M3 (hermetic gate, server-authoritative): the persisted per-env
|
||||
/// approval policy for the project with this id. Same shape as
|
||||
/// `get_environments_by_slug`, but keyed by the id the server RESOLVED from
|
||||
/// the target node's nearest-claimed ancestor — so a client that omits or
|
||||
/// spoofs `[project]` can't dodge a gate the owning project established.
|
||||
async fn get_environments_by_id(
|
||||
&self,
|
||||
id: ProjectId,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresProjectRepository {
|
||||
@@ -111,6 +120,19 @@ impl ProjectRepository for PostgresProjectRepository {
|
||||
.await?;
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn get_environments_by_id(
|
||||
&self,
|
||||
id: ProjectId,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, (String, bool)>(
|
||||
"SELECT env_name, confirm FROM project_environments WHERE project_id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
Reference in New Issue
Block a user