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:
MechaCat02
2026-07-11 23:47:35 +02:00
parent b8b047368e
commit 86448beb06
18 changed files with 593 additions and 125 deletions

View File

@@ -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 `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`; 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. |

View File

@@ -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);

View File

@@ -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())
}
}

View File

@@ -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,

View File

@@ -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).

View File

@@ -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(&current, &current_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, &current.secret_names)?;
let plan = compute_diff_with_names(&current, bundle, &current_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

View File

@@ -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);

View File

@@ -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).

View File

@@ -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()

View File

@@ -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

View File

@@ -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)]

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
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::<u64>().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(),

View File

@@ -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
);
}
}

View File

@@ -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;

View File

@@ -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.