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:
@@ -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_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_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_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_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_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. |
|
| `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. |
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -17,13 +17,15 @@ pub enum AdminSessionRepositoryError {
|
|||||||
Db(#[from] sqlx::Error),
|
Db(#[from] sqlx::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a session lookup. Includes the user id (for auth context)
|
/// Result of a session lookup. Includes the user id (for auth context),
|
||||||
/// and the existing `expires_at` so the middleware can decide whether
|
/// the existing `expires_at` (so the middleware can decide whether the
|
||||||
/// the sliding window bump is worth a write.
|
/// sliding-window bump is worth a write), and the `absolute_expires_at`
|
||||||
|
/// hard cap the bump must clamp at (C1).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AdminSessionLookup {
|
pub struct AdminSessionLookup {
|
||||||
pub user_id: AdminUserId,
|
pub user_id: AdminUserId,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub absolute_expires_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -33,9 +35,11 @@ pub trait AdminSessionRepository: Send + Sync {
|
|||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
expires_at: DateTime<Utc>,
|
expires_at: DateTime<Utc>,
|
||||||
|
absolute_expires_at: DateTime<Utc>,
|
||||||
) -> Result<(), AdminSessionRepositoryError>;
|
) -> Result<(), AdminSessionRepositoryError>;
|
||||||
/// Look up a session by token hash. Returns `None` for missing or
|
/// 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(
|
async fn lookup(
|
||||||
&self,
|
&self,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
@@ -78,14 +82,16 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
|||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
expires_at: DateTime<Utc>,
|
expires_at: DateTime<Utc>,
|
||||||
|
absolute_expires_at: DateTime<Utc>,
|
||||||
) -> Result<(), AdminSessionRepositoryError> {
|
) -> Result<(), AdminSessionRepositoryError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO admin_sessions (token_hash, user_id, expires_at) \
|
"INSERT INTO admin_sessions (token_hash, user_id, expires_at, absolute_expires_at) \
|
||||||
VALUES ($1, $2, $3)",
|
VALUES ($1, $2, $3, $4)",
|
||||||
)
|
)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.bind(user_id.into_inner())
|
.bind(user_id.into_inner())
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
|
.bind(absolute_expires_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -95,16 +101,17 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
|||||||
&self,
|
&self,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
) -> Result<Option<AdminSessionLookup>, AdminSessionRepositoryError> {
|
||||||
let row: Option<(uuid::Uuid, DateTime<Utc>)> = sqlx::query_as(
|
let row: Option<(uuid::Uuid, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||||
"SELECT user_id, expires_at FROM admin_sessions \
|
"SELECT user_id, expires_at, absolute_expires_at FROM admin_sessions \
|
||||||
WHERE token_hash = $1 AND expires_at > NOW()",
|
WHERE token_hash = $1 AND expires_at > NOW() AND absolute_expires_at > NOW()",
|
||||||
)
|
)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|(uid, exp)| AdminSessionLookup {
|
Ok(row.map(|(uid, exp, abs)| AdminSessionLookup {
|
||||||
user_id: uid.into(),
|
user_id: uid.into(),
|
||||||
expires_at: exp,
|
expires_at: exp,
|
||||||
|
absolute_expires_at: abs,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,9 +151,12 @@ impl AdminSessionRepository for PostgresAdminSessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
async fn prune_expired(&self) -> Result<u64, AdminSessionRepositoryError> {
|
||||||
let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()")
|
let res = sqlx::query(
|
||||||
.execute(&self.pool)
|
"DELETE FROM admin_sessions \
|
||||||
.await?;
|
WHERE expires_at <= NOW() OR absolute_expires_at <= NOW()",
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
Ok(res.rows_affected())
|
Ok(res.rows_affected())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get};
|
use axum::routing::{delete, get};
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
@@ -116,7 +116,7 @@ async fn mint_key(
|
|||||||
State(state): State<ApiKeysState>,
|
State(state): State<ApiKeysState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<MintApiKeyRequest>,
|
Json(input): Json<MintApiKeyRequest>,
|
||||||
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
|
) -> Result<(StatusCode, HeaderMap, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||||
validate_name(&input.name)?;
|
validate_name(&input.name)?;
|
||||||
validate_scopes(&input.scopes, input.app_id)?;
|
validate_scopes(&input.scopes, input.app_id)?;
|
||||||
|
|
||||||
@@ -135,6 +135,8 @@ async fn mint_key(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
|
// C2: the response carries the raw key exactly once — never cache it.
|
||||||
|
crate::auth_api::no_store_headers(),
|
||||||
Json(MintApiKeyResponse {
|
Json(MintApiKeyResponse {
|
||||||
key: row.into(),
|
key: row.into(),
|
||||||
raw_token: minted.raw,
|
raw_token: minted.raw,
|
||||||
|
|||||||
@@ -231,12 +231,15 @@ async fn apply_handler(
|
|||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
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?;
|
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||||
// §3 M3: server-authoritative per-env approval gate, evaluated against the
|
// §3 M3: server-authoritative per-env approval gate. The governing policy is
|
||||||
// persisted ∪ declared policy (hermetic — an omitted policy can't bypass a
|
// loaded from the project the SERVER resolves as owning this node (its
|
||||||
// gate a prior apply established). If gated and approved, the override
|
// nearest-claimed ancestor), unioned with the declared policy — so a request
|
||||||
// additionally requires ADMIN on the node (a step up from the editor write
|
// that omits or spoofs `[project]` can't bypass a gate the owning project
|
||||||
// caps above) + is audited.
|
// established. If gated and approved, the override additionally requires
|
||||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
// 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)? {
|
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||||
require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
|
require(svc.authz.as_ref(), &principal, Capability::AppAdmin(app_id))
|
||||||
.await
|
.await
|
||||||
@@ -320,9 +323,11 @@ async fn group_apply_handler(
|
|||||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
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)
|
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
|
||||||
.await?;
|
.await?;
|
||||||
// §3 M3: per-env approval gate (persisted ∪ declared) — an approved gated
|
// §3 M3: per-env approval gate (governing ∪ declared, server-resolved owner)
|
||||||
// apply requires GroupAdmin.
|
// — an approved gated apply requires GroupAdmin.
|
||||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
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)? {
|
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||||
require(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
@@ -406,10 +411,14 @@ async fn tree_apply_handler(
|
|||||||
Json(req): Json<TreeApplyRequest>,
|
Json(req): Json<TreeApplyRequest>,
|
||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||||
// §3 M3: server-authoritative per-env approval gate (persisted ∪ declared).
|
// §3 M3: server-authoritative per-env approval gate (governing ∪ declared).
|
||||||
// On an approved gated apply, require ADMIN on EVERY declared node (a step up
|
// The governing side is the union of every declared node's owning-project
|
||||||
// from the editor write caps authz_tree checks) + audit.
|
// policy, resolved server-side — omitting `[project]` can't dodge it. On an
|
||||||
let policy = svc.effective_env_policy(req.project.as_ref()).await?;
|
// 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)? {
|
if env_gate_check(&policy, req.env.as_deref(), &req.approved_envs)? {
|
||||||
require_admin_on_every_node(&svc, &principal, &req.bundle).await?;
|
require_admin_on_every_node(&svc, &principal, &req.bundle).await?;
|
||||||
audit_gated_apply(&principal, req.env.as_deref(), req.bundle.nodes.len());
|
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
|
/// §3 M3 (hermetic gate): the per-env approval gate, evaluated against the
|
||||||
/// EFFECTIVE policy (`persisted ∪ declared`, built by
|
/// EFFECTIVE policy (`governing ∪ declared`, built by
|
||||||
/// `ApplyService::effective_env_policy`) — the persisted side is authoritative,
|
/// `ApplyService::governing_env_policy`) — the governing side is loaded from the
|
||||||
/// so a request that omits or weakens the policy can't bypass a gate a prior
|
/// project the SERVER resolves as owning the node, so a request that omits or
|
||||||
/// apply established. Returns `Ok(true)` when the target env is confirm-required
|
/// 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
|
/// AND approved — the caller must then require admin + audit. `Ok(false)` when
|
||||||
/// not gated (no env, or `confirm = false` everywhere). `Err(ApprovalRequired)`
|
/// not gated (no env, or `confirm = false` everywhere). `Err(ApprovalRequired)`
|
||||||
/// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here).
|
/// when gated and NOT in `approved_envs` (a blanket `--yes` never lands here).
|
||||||
|
|||||||
@@ -485,6 +485,13 @@ pub struct PlanResult {
|
|||||||
/// `plan` time, before an `apply` refuses.
|
/// `plan` time, before an `apply` refuses.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub approvals_required: Vec<String>,
|
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).
|
/// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub structure: Option<StructurePreview>,
|
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
|
/// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token
|
||||||
@@ -895,47 +906,17 @@ impl ApplyService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an
|
/// The sorted names of every gated env in a computed policy (`confirm`
|
||||||
/// apply/plan — `env_name -> confirm`, computed as `persisted ∪ declared`
|
/// entries), surfaced as `plan.approvals_required`. Callers pass the
|
||||||
/// (an env is confirm-required if EITHER the stored policy OR the request's
|
/// governing policy so plan's preview matches what apply gates on.
|
||||||
/// `[project.environments]` marks it so). Loading the persisted side is what
|
fn gated_envs_from(policy: &std::collections::BTreeMap<String, bool>) -> Vec<String> {
|
||||||
/// 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?;
|
|
||||||
let mut gated: Vec<String> = policy
|
let mut gated: Vec<String> = policy
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter_map(|(env, confirm)| confirm.then_some(env))
|
.filter(|&(_, confirm)| *confirm)
|
||||||
|
.map(|(env, _)| env.clone())
|
||||||
.collect();
|
.collect();
|
||||||
gated.sort_unstable();
|
gated.sort_unstable();
|
||||||
Ok(gated)
|
gated
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
|
/// 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).
|
// `apply` can refuse if the node changed underneath it (§4.2).
|
||||||
state_token: state_token_with_names(¤t, ¤t_names),
|
state_token: state_token_with_names(¤t, ¤t_names),
|
||||||
ownership,
|
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
|
// it the reconcile would insert an inert `app_id`-owned marker row that
|
||||||
// no resolver ever reads.
|
// no resolver ever reads.
|
||||||
if !is_group && !bundle.collections.is_empty() {
|
if !is_group && !bundle.collections.is_empty() {
|
||||||
return Err(ApplyError::Invalid(
|
return Err(app_only_reject(
|
||||||
"an app manifest cannot declare shared collections — \
|
"manifest cannot declare shared collections",
|
||||||
they are owned by a group"
|
"they are owned by a group",
|
||||||
.into(),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// §11 tail: `sealed` is a GROUP-template property — it makes an
|
// §11 tail: `sealed` is a GROUP-template property — it makes an
|
||||||
@@ -1114,26 +1097,26 @@ impl ApplyService {
|
|||||||
// node inherits down).
|
// node inherits down).
|
||||||
if !is_group {
|
if !is_group {
|
||||||
if bundle.routes.iter().any(|r| r.sealed) {
|
if bundle.routes.iter().any(|r| r.sealed) {
|
||||||
return Err(ApplyError::Invalid(
|
return Err(app_only_reject(
|
||||||
"an app route cannot be `sealed` — sealing marks a group \
|
"route cannot be `sealed`",
|
||||||
template as non-suppressible; an app route is never inherited"
|
"sealing marks a group template as non-suppressible; an app \
|
||||||
.into(),
|
route is never inherited",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if bundle.triggers.iter().any(BundleTrigger::sealed) {
|
if bundle.triggers.iter().any(BundleTrigger::sealed) {
|
||||||
return Err(ApplyError::Invalid(
|
return Err(app_only_reject(
|
||||||
"an app trigger cannot be `sealed` — sealing marks a group \
|
"trigger cannot be `sealed`",
|
||||||
template as non-suppressible; an app trigger is never inherited"
|
"sealing marks a group template as non-suppressible; an app \
|
||||||
.into(),
|
trigger is never inherited",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// §11.6: `shared` watches a group's SHARED collection — meaningless
|
// §11.6: `shared` watches a group's SHARED collection — meaningless
|
||||||
// for an app (apps don't own shared collections).
|
// for an app (apps don't own shared collections).
|
||||||
if bundle.triggers.iter().any(BundleTrigger::shared) {
|
if bundle.triggers.iter().any(BundleTrigger::shared) {
|
||||||
return Err(ApplyError::Invalid(
|
return Err(app_only_reject(
|
||||||
"an app trigger cannot be `shared` — a shared-collection \
|
"trigger cannot be `shared`",
|
||||||
trigger is owned by the group that declares the collection"
|
"a shared-collection trigger is owned by the group that \
|
||||||
.into(),
|
declares the collection",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1710,24 +1693,11 @@ impl ApplyService {
|
|||||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||||
let plan = compute_diff_with_names(¤t, bundle, ¤t_names);
|
let plan = compute_diff_with_names(¤t, bundle, ¤t_names);
|
||||||
let mut report = ApplyReport::default();
|
let mut report = ApplyReport::default();
|
||||||
// §4.7 warning: an enabled binding pointing at a disabled script is
|
// The desired-state warnings — identical to what `pic plan` previews, so
|
||||||
// deployed-but-unreachable. Not an error (it's valid desired state),
|
// plan and apply agree (see `plan_warnings`).
|
||||||
// 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).
|
|
||||||
report
|
report
|
||||||
.warnings
|
.warnings
|
||||||
.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
.extend(self.plan_warnings(owner, bundle).await?);
|
||||||
|
|
||||||
self.reconcile_node_tx(
|
self.reconcile_node_tx(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
@@ -1800,16 +1770,20 @@ impl ApplyService {
|
|||||||
// §6 M2: for an existing group node, preview a structural divergence
|
// §6 M2: for an existing group node, preview a structural divergence
|
||||||
// (server parent ≠ manifest parent) before apply.
|
// (server parent ≠ manifest parent) before apply.
|
||||||
let structure = self.divergence_preview(p.node, &existing).await?;
|
let structure = self.divergence_preview(p.node, &existing).await?;
|
||||||
|
let warnings = self.plan_warnings(p.owner, &p.node.bundle).await?;
|
||||||
nodes.push(NodePlan {
|
nodes.push(NodePlan {
|
||||||
kind: p.node.kind,
|
kind: p.node.kind,
|
||||||
slug: p.node.slug.clone(),
|
slug: p.node.slug.clone(),
|
||||||
plan: p.plan,
|
plan: p.plan,
|
||||||
ownership,
|
ownership,
|
||||||
structure,
|
structure,
|
||||||
|
warnings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// §6: a to-create group is previewed with a full-create plan; if the
|
// §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 {
|
for c in to_create {
|
||||||
let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
|
let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
|
||||||
nodes.push(NodePlan {
|
nodes.push(NodePlan {
|
||||||
@@ -1818,12 +1792,15 @@ impl ApplyService {
|
|||||||
plan: c.plan,
|
plan: c.plan,
|
||||||
ownership,
|
ownership,
|
||||||
structure: None,
|
structure: None,
|
||||||
|
warnings: disabled_target_warnings(&c.node.bundle),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(TreePlanResult {
|
Ok(TreePlanResult {
|
||||||
nodes,
|
nodes,
|
||||||
state_token: token,
|
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)))
|
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
|
// §6/§7 attach point — a project's ceiling. Every applied node must live
|
||||||
// strictly within the subtree rooted at the declared `parent_group`.
|
// strictly within the subtree rooted at the declared `parent_group`.
|
||||||
@@ -3389,6 +3507,27 @@ impl ApplyService {
|
|||||||
.collect())
|
.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
|
/// §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
|
/// 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
|
/// 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
|
/// script the manifest marks *disabled* is deployed but unreachable (the
|
||||||
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
|
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
|
||||||
/// not an error — keyed on the bundle alone.
|
/// 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> {
|
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
|
||||||
let disabled: HashSet<&str> = bundle
|
let disabled: HashSet<&str> = bundle
|
||||||
.scripts
|
.scripts
|
||||||
|
|||||||
@@ -156,12 +156,19 @@ async fn login(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let token = generate_session_token();
|
let token = generate_session_token();
|
||||||
let expires_at = Utc::now()
|
let now = Utc::now();
|
||||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
// 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
|
if let Err(err) = state
|
||||||
.sessions
|
.sessions
|
||||||
.create(user_id, &token.hash, expires_at)
|
.create(user_id, &token.hash, expires_at, absolute_expires_at)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(?err, "admin_sessions insert failed");
|
tracing::error!(?err, "admin_sessions insert failed");
|
||||||
@@ -174,6 +181,7 @@ async fn login(
|
|||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
no_store_headers(),
|
||||||
Json(LoginResponse {
|
Json(LoginResponse {
|
||||||
user: AdminUserDto {
|
user: AdminUserDto {
|
||||||
id: user_row.id,
|
id: user_row.id,
|
||||||
@@ -188,6 +196,15 @@ async fn login(
|
|||||||
.into_response()
|
.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 {
|
async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response {
|
||||||
// Pull token without requiring a valid session (logout is idempotent).
|
// Pull token without requiring a valid session (logout is idempotent).
|
||||||
let token = extract_token_for_logout(&req);
|
let token = extract_token_for_logout(&req);
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ pub struct AuthState {
|
|||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
pub keys: Arc<dyn ApiKeyRepository>,
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
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
|
/// F-P-009 — shared cache of resolved Principals. Constructed once
|
||||||
/// at startup and cloned (same `Arc`) into every router state that
|
/// at startup and cloned (same `Arc`) into every router state that
|
||||||
/// resolves or revokes credentials, so a revocation-side eviction is
|
/// 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
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||||
// than silent stale sessions. Same shape as Phase 3a.
|
// than silent stale sessions. C1: clamp the bump at the session's absolute
|
||||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
// 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 {
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
tracing::error!(?err, "admin_sessions touch failed");
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
return Err(InternalError);
|
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 {
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||||
return Ok(None);
|
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 {
|
let mut candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||||
Ok(v) => v,
|
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]
|
#[test]
|
||||||
fn evict_user_drops_only_that_users_entries() {
|
fn evict_user_drops_only_that_users_entries() {
|
||||||
// Audit 2026-06-11 (PrincipalCache revocation-lag).
|
// Audit 2026-06-11 (PrincipalCache revocation-lag).
|
||||||
|
|||||||
@@ -163,9 +163,12 @@ async fn get_file(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(FilesApiError::NotFound)?;
|
.ok_or(FilesApiError::NotFound)?;
|
||||||
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
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!(
|
let disposition = format!(
|
||||||
"attachment; filename=\"{}\"",
|
"attachment; filename=\"{}\"",
|
||||||
meta.name.replace('"', "").replace(['\r', '\n'], "")
|
picloud_shared::sanitize_stored_filename(&meta.name)
|
||||||
);
|
);
|
||||||
let len = bytes.len();
|
let len = bytes.len();
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
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::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
@@ -287,14 +288,29 @@ async fn get_file(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||||
.ok_or(GroupBlobsError::NotFound)?;
|
.ok_or(GroupBlobsError::NotFound)?;
|
||||||
Ok((
|
// Same download hardening as the per-app path (`files_api::get_file`): a
|
||||||
[
|
// sanitized content-type, forced `attachment` disposition with a header-safe
|
||||||
(CONTENT_TYPE, meta.content_type),
|
// filename, plus nosniff/CSP so a stored HTML/SVG shared file can't render
|
||||||
(CONTENT_LENGTH, bytes.len().to_string()),
|
// inline (stored-XSS) and no attacker-influenced byte can panic the builder.
|
||||||
],
|
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
||||||
bytes,
|
let disposition = format!(
|
||||||
)
|
"attachment; filename=\"{}\"",
|
||||||
.into_response())
|
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
|
/// §11.6 D3: list a group's shared-queue dead-letters (newest-first). Guarded by
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ pub trait ProjectRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> Result<std::collections::BTreeMap<String, bool>, ProjectRepositoryError>;
|
) -> 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 {
|
pub struct PostgresProjectRepository {
|
||||||
@@ -111,6 +120,19 @@ impl ProjectRepository for PostgresProjectRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().collect())
|
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)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ table: admin_sessions
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
expires_at: timestamp with time zone NOT NULL
|
expires_at: timestamp with time zone NOT NULL
|
||||||
last_used_at: timestamp with time zone NOT NULL default=now()
|
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
absolute_expires_at: timestamp with time zone NOT NULL
|
||||||
|
|
||||||
table: admin_users
|
table: admin_users
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
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)
|
idx_abandoned_executions_gc: public.abandoned_executions USING btree (created_at)
|
||||||
|
|
||||||
indexes on admin_sessions:
|
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_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||||
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||||
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||||
@@ -1035,3 +1037,4 @@ constraints on vars:
|
|||||||
0067: project environments
|
0067: project environments
|
||||||
0068: group dead letters
|
0068: group dead letters
|
||||||
0069: email secret version
|
0069: email secret version
|
||||||
|
0070: admin session absolute expiry
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async fn list_with_owner_and_ancestors_surface_ownership() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_environments_by_slug_returns_persisted_policy() {
|
async fn get_environments_by_slug_returns_persisted_policy() {
|
||||||
// §3 M3 hermetic gate: the persisted per-env policy round-trips by project
|
// §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 {
|
let Some(pool) = pool_or_skip().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -147,4 +147,15 @@ async fn get_environments_by_slug_returns_persisted_policy() {
|
|||||||
.await
|
.await
|
||||||
.expect("get_environments_by_slug");
|
.expect("get_environments_by_slug");
|
||||||
assert!(empty.is_empty());
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[test]
|
#[test]
|
||||||
fn flipping_a_gate_to_false_in_the_same_request_still_gates() {
|
fn flipping_a_gate_to_false_in_the_same_request_still_gates() {
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ use tower_http::trace::TraceLayer;
|
|||||||
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
||||||
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
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
|
/// Bundles the auth-related dependencies that both `build_app` and the
|
||||||
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
||||||
pub struct AuthDeps {
|
pub struct AuthDeps {
|
||||||
@@ -65,6 +69,8 @@ pub struct AuthDeps {
|
|||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
pub keys: Arc<dyn ApiKeyRepository>,
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
|
/// C1: absolute hard cap on an admin session (see `AuthState::absolute_ttl`).
|
||||||
|
pub absolute_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthDeps {
|
impl AuthDeps {
|
||||||
@@ -76,6 +82,7 @@ impl AuthDeps {
|
|||||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
|
||||||
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
|
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
|
||||||
ttl: read_session_ttl(),
|
ttl: read_session_ttl(),
|
||||||
|
absolute_ttl: read_session_absolute_ttl(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +96,15 @@ fn read_session_ttl() -> Duration {
|
|||||||
Duration::from_secs(hours * 3600)
|
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
|
/// Compose the manager + orchestrator routes on top of a shared
|
||||||
/// Postgres pool, returning an Axum router ready to be served.
|
/// Postgres pool, returning an Axum router ready to be served.
|
||||||
///
|
///
|
||||||
@@ -627,6 +643,7 @@ pub async fn build_app(
|
|||||||
sessions: auth.sessions.clone(),
|
sessions: auth.sessions.clone(),
|
||||||
keys: auth.keys.clone(),
|
keys: auth.keys.clone(),
|
||||||
ttl: auth.ttl,
|
ttl: auth.ttl,
|
||||||
|
absolute_ttl: auth.absolute_ttl,
|
||||||
principal_cache: principal_cache.clone(),
|
principal_cache: principal_cache.clone(),
|
||||||
login_rate_limiter: Arc::new(
|
login_rate_limiter: Arc::new(
|
||||||
picloud_manager_core::login_rate_limit::LoginRateLimiter::new(),
|
picloud_manager_core::login_rate_limit::LoginRateLimiter::new(),
|
||||||
|
|||||||
@@ -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
|
/// Reject a collection name that is empty or could escape the per-app
|
||||||
/// files tree. UUID-shaped ids never produce traversal paths, but
|
/// files tree. UUID-shaped ids never produce traversal paths, but
|
||||||
/// collection names come from scripts so they're validated defensively
|
/// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}
|
|||||||
pub use exec_summary::ExecResponseSummary;
|
pub use exec_summary::ExecResponseSummary;
|
||||||
pub use execution_log::{ExecutionLog, ExecutionSource, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionSource, ExecutionStatus};
|
||||||
pub use files::{
|
pub use files::{
|
||||||
sanitize_stored_content_type, validate_collection as validate_files_collection, FileMeta,
|
sanitize_stored_content_type, sanitize_stored_filename,
|
||||||
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
|
validate_collection as validate_files_collection, FileMeta, FileUpdate, FilesError,
|
||||||
|
FilesListPage, FilesService, NewFile, NoopFilesService, SAFE_FILENAME_FALLBACK,
|
||||||
SAFE_RENDER_FALLBACK,
|
SAFE_RENDER_FALLBACK,
|
||||||
};
|
};
|
||||||
pub use group::Group;
|
pub use group::Group;
|
||||||
|
|||||||
@@ -1381,3 +1381,22 @@ to take, not yet taken:
|
|||||||
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
|
§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
|
- 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).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user