diff --git a/CLAUDE.md b/CLAUDE.md index bca9144..37104c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,10 @@ Environment variables consumed by the `picloud` binary: | `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. Also: when no SMTP relay is configured, `email::send` switches from disabled (`NotConfigured`) to an **in-memory dev sink** — sends succeed and the last 100 messages are readable at `GET /api/v1/admin/dev/emails` (instance Owner/Admin only; route exists only in this mode). Never in production. | | `PICLOUD_DEV_INSECURE_KEY` | — | Set to the literal `i-understand-this-is-insecure` to let dev mode boot without `PICLOUD_SECRET_KEY`, using a deterministic, world-known dev master key. Never set in production — it would encrypt everything with a public value. | | `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size. Matched to `PICLOUD_MAX_CONCURRENT_EXECUTIONS` so the data plane can't starve background workers. | -| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. | +| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window admin session lifetime (idle timeout). | +| `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS` | `720` (30 days) | Absolute hard cap on an admin session's lifetime (audit 2026-07-11 C1). The sliding `touch` bump is clamped at `login_time + this`, so even a continuously-used or stolen-but-warm admin token self-expires. Mirrors the data-plane app-user session cap. | +| `PICLOUD_REALTIME_BROADCAST_CAPACITY` | `64` | Per-channel SSE broadcast buffer depth (a slow consumer sees oldest events dropped). | +| `PICLOUD_REALTIME_MAX_CHANNELS` | `100000` | Max live SSE channels per map (per-app and per-group). A subscribe that would open a NEW channel past this is refused with 503 (audit 2026-07-11 B6), so a client naming unbounded distinct topics can't grow the broadcaster maps to OOM. | | `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. | | `PICLOUD_FILES_ROOT` | `./data` | Filesystem root for `files::*` blob storage (v1.1.5). Bytes live at `/files////`; metadata in Postgres. | | `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` | `104857600` (100 MB) | Per-file hard size cap for `files::*` (v1.1.5). Per-app quotas deferred to v1.2. | diff --git a/crates/manager-core/migrations/0070_admin_session_absolute_expiry.sql b/crates/manager-core/migrations/0070_admin_session_absolute_expiry.sql new file mode 100644 index 0000000..e85ce29 --- /dev/null +++ b/crates/manager-core/migrations/0070_admin_session_absolute_expiry.sql @@ -0,0 +1,19 @@ +-- Audit 2026-07-11 (LOW C1): admin sessions had only a SLIDING expiry, so a +-- continuously-used (or stolen-but-warm) admin token never self-expired. The +-- data-plane app-user sessions (0027) already carry an absolute hard cap; give +-- the higher-privilege admin session the same. `touch` clamps the sliding bump +-- at this value, and `lookup` filters it, so an admin session dies at the later +-- of its idle window or this absolute ceiling — whichever comes first. +ALTER TABLE admin_sessions ADD COLUMN absolute_expires_at TIMESTAMPTZ; + +-- Backfill live rows: cap them 30 days from creation (the new default absolute +-- TTL). A row already older than that will be swept on its next lookup. +UPDATE admin_sessions + SET absolute_expires_at = created_at + INTERVAL '30 days' + WHERE absolute_expires_at IS NULL; + +ALTER TABLE admin_sessions ALTER COLUMN absolute_expires_at SET NOT NULL; + +-- Backs the absolute-expiry filter on lookup / prune. +CREATE INDEX admin_sessions_absolute_expiry_idx + ON admin_sessions (absolute_expires_at); diff --git a/crates/manager-core/src/admin_session_repo.rs b/crates/manager-core/src/admin_session_repo.rs index 71bd96f..fc5813a 100644 --- a/crates/manager-core/src/admin_session_repo.rs +++ b/crates/manager-core/src/admin_session_repo.rs @@ -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, + pub absolute_expires_at: DateTime, } #[async_trait] @@ -33,9 +35,11 @@ pub trait AdminSessionRepository: Send + Sync { user_id: AdminUserId, token_hash: &str, expires_at: DateTime, + absolute_expires_at: DateTime, ) -> 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, + absolute_expires_at: DateTime, ) -> 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, AdminSessionRepositoryError> { - let row: Option<(uuid::Uuid, DateTime)> = 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, DateTime)> = 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 { - 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()) } } diff --git a/crates/manager-core/src/api_keys_api.rs b/crates/manager-core/src/api_keys_api.rs index 570fa1e..3b5db66 100644 --- a/crates/manager-core/src/api_keys_api.rs +++ b/crates/manager-core/src/api_keys_api.rs @@ -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, Extension(principal): Extension, Json(input): Json, -) -> Result<(StatusCode, Json), ApiKeysError> { +) -> Result<(StatusCode, HeaderMap, Json), 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, diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index e4a99ba..dc677fc 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -231,12 +231,15 @@ async fn apply_handler( ) -> Result, 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, ) -> Result, 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). diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 12f0d3e..e9690bc 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -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, + /// 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, } // ---------------------------------------------------------------------------- @@ -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, + /// 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, } /// 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, 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, 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) -> Vec { let mut gated: Vec = 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, 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, 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, ApplyError> { + let mut effective: std::collections::BTreeMap = + 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, 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 { /// 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 ` 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 { let disabled: HashSet<&str> = bundle .scripts diff --git a/crates/manager-core/src/auth_api.rs b/crates/manager-core/src/auth_api.rs index b6a4bc8..7ac11c6 100644 --- a/crates/manager-core/src/auth_api.rs +++ b/crates/manager-core/src/auth_api.rs @@ -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, req: Request) -> Response { // Pull token without requiring a valid session (logout is idempotent). let token = extract_token_for_logout(&req); diff --git a/crates/manager-core/src/auth_middleware.rs b/crates/manager-core/src/auth_middleware.rs index bd0cf9a..f069720 100644 --- a/crates/manager-core/src/auth_middleware.rs +++ b/crates/manager-core/src/auth_middleware.rs @@ -137,6 +137,11 @@ pub struct AuthState { pub sessions: Arc, pub keys: Arc, 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 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). diff --git a/crates/manager-core/src/files_api.rs b/crates/manager-core/src/files_api.rs index 87d0bf8..10d9deb 100644 --- a/crates/manager-core/src/files_api.rs +++ b/crates/manager-core/src/files_api.rs @@ -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() diff --git a/crates/manager-core/src/group_blobs_api.rs b/crates/manager-core/src/group_blobs_api.rs index a1a7d70..5d142cb 100644 --- a/crates/manager-core/src/group_blobs_api.rs +++ b/crates/manager-core/src/group_blobs_api.rs @@ -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 diff --git a/crates/manager-core/src/project_repo.rs b/crates/manager-core/src/project_repo.rs index b27b8c6..25599e9 100644 --- a/crates/manager-core/src/project_repo.rs +++ b/crates/manager-core/src/project_repo.rs @@ -35,6 +35,15 @@ pub trait ProjectRepository: Send + Sync { &self, slug: &str, ) -> Result, 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, 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, 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)] diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 18fac45..162e69c 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -19,6 +19,7 @@ table: admin_sessions created_at: timestamp with time zone NOT NULL default=now() expires_at: timestamp with time zone NOT NULL last_used_at: timestamp with time zone NOT NULL default=now() + absolute_expires_at: timestamp with time zone NOT NULL table: admin_users id: uuid NOT NULL default=gen_random_uuid() @@ -474,6 +475,7 @@ indexes on abandoned_executions: idx_abandoned_executions_gc: public.abandoned_executions USING btree (created_at) indexes on admin_sessions: + admin_sessions_absolute_expiry_idx: public.admin_sessions USING btree (absolute_expires_at) admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at) admin_sessions_pkey: public.admin_sessions USING btree (token_hash) admin_sessions_user_idx: public.admin_sessions USING btree (user_id) @@ -1035,3 +1037,4 @@ constraints on vars: 0067: project environments 0068: group dead letters 0069: email secret version + 0070: admin session absolute expiry diff --git a/crates/manager-core/tests/projects_repo.rs b/crates/manager-core/tests/projects_repo.rs index c09414b..f2b0c36 100644 --- a/crates/manager-core/tests/projects_repo.rs +++ b/crates/manager-core/tests/projects_repo.rs @@ -108,7 +108,7 @@ async fn list_with_owner_and_ancestors_surface_ownership() { #[tokio::test] async fn get_environments_by_slug_returns_persisted_policy() { // §3 M3 hermetic gate: the persisted per-env policy round-trips by project - // slug. Backs `ApplyService::effective_env_policy` (the `persisted` half). + // slug. Backs `ApplyService::governing_env_policy` (the `governing` half). let Some(pool) = pool_or_skip().await else { return; }; @@ -147,4 +147,15 @@ async fn get_environments_by_slug_returns_persisted_policy() { .await .expect("get_environments_by_slug"); assert!(empty.is_empty()); + + // Audit 2026-07-11 H1: the gate now loads the policy by the SERVER-resolved + // project id (from the node's ownership walk), not the client slug. The + // by-id read must return the same persisted policy as the by-slug read. + let by_id = projects + .get_environments_by_id(proj_id.into()) + .await + .expect("get_environments_by_id"); + assert_eq!(by_id.get("production"), Some(&true)); + assert_eq!(by_id.get("staging"), Some(&false)); + assert_eq!(by_id.len(), 2); } diff --git a/crates/picloud-cli/tests/env_approval.rs b/crates/picloud-cli/tests/env_approval.rs index f779748..675c4cf 100644 --- a/crates/picloud-cli/tests/env_approval.rs +++ b/crates/picloud-cli/tests/env_approval.rs @@ -409,6 +409,77 @@ fn persisted_policy_gates_a_request_that_omits_it() { ); } +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn omitting_the_project_block_entirely_does_not_bypass_a_persisted_gate() { + // Audit 2026-07-11 H1: the earlier gate keyed the persisted policy on the + // client-supplied `project.slug`, so a request that OMITTED `[project]` + // ENTIRELY (`project: null`, not just empty `environments`) yielded an empty + // policy and skipped the gate. The fix resolves the GOVERNING project + // server-side from the target node's nearest-claimed ancestor, so a request + // to a claimed, gated node can no longer apply just by dropping `[project]`. + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("eao-grp"); + let project = common::unique_slug("eao-proj"); + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + let http = reqwest::blocking::Client::new(); + + // Seed: declare + approve the gate → claims the group AND persists + // `production=true` on the owning project. + let seed = http + .post(format!("{}/api/v1/admin/tree/apply", env.url)) + .bearer_auth(&env.token) + .json(&serde_json::json!({ + "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] }, + "project": { + "slug": project, + "environments": { "production": { "confirm": true } } + }, + "env": "production", + "approved_envs": ["production"], + "prune": false, + })) + .send() + .expect("seed apply"); + assert!( + seed.status().is_success(), + "the seeding apply (declared + approved) must succeed (got {}: {})", + seed.status(), + seed.text().unwrap_or_default() + ); + + // Attack: a raw apply that OMITS the `project` block entirely (no `project` + // key on the wire) to the same gated node, WITHOUT approving `production`. + // The server resolves the governing project from the node itself, so this is + // refused (409) — it does NOT quietly apply. + let omit = http + .post(format!("{}/api/v1/admin/tree/apply", env.url)) + .bearer_auth(&env.token) + .json(&serde_json::json!({ + "bundle": { "nodes": [{ "kind": "group", "slug": group, "bundle": {} }] }, + "env": "production", + "approved_envs": [], + "prune": false, + })) + .send() + .expect("omit apply"); + assert_eq!( + omit.status().as_u16(), + 409, + "omitting [project] must not bypass the persisted gate (got {}: {})", + omit.status(), + omit.text().unwrap_or_default() + ); +} + #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn flipping_a_gate_to_false_in_the_same_request_still_gates() { diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 14aa53f..b8b21e8 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -58,6 +58,10 @@ use tower_http::trace::TraceLayer; /// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set. const DEFAULT_SESSION_TTL_HOURS: u64 = 24; +/// Default absolute session lifetime cap (C1) when +/// `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS` isn't set — 30 days. +const DEFAULT_SESSION_ABSOLUTE_TTL_HOURS: u64 = 30 * 24; + /// Bundles the auth-related dependencies that both `build_app` and the /// startup bootstrap need. Built once in `main.rs` from the shared pool. pub struct AuthDeps { @@ -65,6 +69,8 @@ pub struct AuthDeps { pub sessions: Arc, pub keys: Arc, pub ttl: Duration, + /// C1: absolute hard cap on an admin session (see `AuthState::absolute_ttl`). + pub absolute_ttl: Duration, } impl AuthDeps { @@ -76,6 +82,7 @@ impl AuthDeps { sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())), keys: Arc::new(PostgresApiKeyRepository::new(pool)), ttl: read_session_ttl(), + absolute_ttl: read_session_absolute_ttl(), } } } @@ -89,6 +96,15 @@ fn read_session_ttl() -> Duration { Duration::from_secs(hours * 3600) } +fn read_session_absolute_ttl() -> Duration { + let hours = std::env::var("PICLOUD_SESSION_ABSOLUTE_TTL_HOURS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|h| *h > 0) + .unwrap_or(DEFAULT_SESSION_ABSOLUTE_TTL_HOURS); + Duration::from_secs(hours * 3600) +} + /// Compose the manager + orchestrator routes on top of a shared /// Postgres pool, returning an Axum router ready to be served. /// @@ -627,6 +643,7 @@ pub async fn build_app( sessions: auth.sessions.clone(), keys: auth.keys.clone(), ttl: auth.ttl, + absolute_ttl: auth.absolute_ttl, principal_cache: principal_cache.clone(), login_rate_limiter: Arc::new( picloud_manager_core::login_rate_limit::LoginRateLimiter::new(), diff --git a/crates/shared/src/files.rs b/crates/shared/src/files.rs index 9ed4c30..9f51c39 100644 --- a/crates/shared/src/files.rs +++ b/crates/shared/src/files.rs @@ -303,6 +303,40 @@ pub fn sanitize_stored_content_type(ct: &str) -> String { } } +/// Fallback filename when a stored name sanitizes to nothing. +pub const SAFE_FILENAME_FALLBACK: &str = "download"; + +/// Coerce a stored filename into a value safe to embed in a +/// `Content-Disposition: attachment; filename="…"` header. +/// +/// The stored `name` is script/upload-supplied and flows straight into a +/// response header. `HeaderValue` rejects control bytes (`< 0x20`, `0x7f`), so +/// a name containing e.g. a NUL or a raw byte would make the response builder +/// error — and the download handler `.expect()`s that build, turning an +/// attacker-influenced stored name into a per-request panic. We keep only +/// printable ASCII (`0x20..=0x7e`) minus the quoting metacharacters `"` and +/// `\`, replacing anything else with `_`. The result is always a valid +/// `HeaderValue`, so the handler can never panic on it. (Non-ASCII names lose +/// fidelity in this legacy `filename=` param; a future `filename*` UTF-8 +/// parameter can restore it — this fix is about never panicking.) +#[must_use] +pub fn sanitize_stored_filename(name: &str) -> String { + // Keep printable ASCII (space included) minus the quoting metacharacters; + // DROP everything else (control bytes, non-ASCII) so the result is always a + // valid HeaderValue. A name that sanitizes to nothing falls back to a + // non-empty default. + let cleaned: String = name + .chars() + .filter(|&c| (c == ' ' || c.is_ascii_graphic()) && c != '"' && c != '\\') + .collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + SAFE_FILENAME_FALLBACK.to_string() + } else { + trimmed.to_string() + } +} + /// Reject a collection name that is empty or could escape the per-app /// files tree. UUID-shaped ids never produce traversal paths, but /// collection names come from scripts so they're validated defensively @@ -476,4 +510,38 @@ mod content_type_sanitizer_tests { ); } } + + #[test] + fn stored_filename_is_header_safe() { + // Audit 2026-07-11 — a stored name with a control byte (NUL, 0x7f, + // CR/LF) or quote/backslash must sanitize to a value safe to embed in a + // `Content-Disposition` header, so the download handler can never panic + // building the response. The invariant HeaderValue requires: every byte + // is visible ASCII (0x20..=0x7e) and never `"` or `\` (quoting metachars). + for name in [ + "ok.pdf", + "with space.txt", + "quote\".txt", + "back\\slash.txt", + "nul\u{0}byte.bin", + "del\u{7f}.bin", + "crlf\r\n.txt", + "café.pdf", + "\u{0}\u{7f}", + ] { + let clean = sanitize_stored_filename(name); + assert!(!clean.is_empty(), "{name:?} produced an empty filename"); + assert!( + clean + .bytes() + .all(|b| (0x20..=0x7e).contains(&b) && b != b'"' && b != b'\\'), + "{name:?} -> {clean:?} has a header-unsafe byte" + ); + } + // An all-unsafe name falls back to a non-empty default. + assert_eq!( + sanitize_stored_filename("\u{0}\u{7f}\r\n"), + SAFE_FILENAME_FALLBACK + ); + } } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 7b15b73..e199b33 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -66,8 +66,9 @@ pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter} pub use exec_summary::ExecResponseSummary; pub use execution_log::{ExecutionLog, ExecutionSource, ExecutionStatus}; pub use files::{ - sanitize_stored_content_type, validate_collection as validate_files_collection, FileMeta, - FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService, + sanitize_stored_content_type, sanitize_stored_filename, + validate_collection as validate_files_collection, FileMeta, FileUpdate, FilesError, + FilesListPage, FilesService, NewFile, NoopFilesService, SAFE_FILENAME_FALLBACK, SAFE_RENDER_FALLBACK, }; pub use group::Group; diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 3c3334d..82c091d 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -1381,3 +1381,22 @@ to take, not yet taken: §11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)* - The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy). + +--- + +## 13. Operator security notes (audit 2026-07-11) + +Two behaviors that are correct-by-design but easy to misread — call them out for operators: + +- **Shared-topic SSE is world-readable by anyone who can name a subtree domain.** A group + `kind='topic'` shared collection is *reads-open by design* (the declaration is the grant, matching + shared KV/docs/files reads). An anonymous request to any descendant app's host can subscribe to the + group's shared topic. This is intentional — but if a topic carries sensitive data, do NOT rely on + obscurity: gate it at the app (an authed route that proxies the stream) or don't share it. Per-topic + opt-in read gating is a possible future addition. +- **The `--env` label is a client assertion, not an identity.** The env-approval gate governs *whether + a confirm-required environment needs `--approve`*, but the label an apply carries (`--env production`) + is chosen by the client. It does not by itself grant or restrict reach — the governing project's + persisted policy (resolved server-side from the node's nearest-claimed ancestor, audit 2026-07-11 H1) + is what's authoritative. Mislabeling `production` content as `staging` cannot dodge a gate the owning + project set on the *node*, but operators should treat the label as advisory metadata, not a boundary.