diff --git a/CLAUDE.md b/CLAUDE.md index 804beba..c33d0b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint. -**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Stdlib reference at [docs/stdlib-reference.md](docs/stdlib-reference.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context. +**v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`). + +**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache). Next: group-owned scripts/modules (§11 Phase 4) and the project tool mapping onto groups (§11 Phase 5). + +**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable _config_ tables (`vars`, `secrets`) instead carry a **polymorphic owner** — nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes. Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg (the cross-app isolation boundary). ## Three-Service Architecture diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 378efea..649fb56 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -26,6 +26,7 @@ pub mod retry; pub mod secrets; pub mod stdlib; pub mod users; +pub mod vars; pub use bridge::{dynamic_to_json, json_to_dynamic}; pub use cx::SdkCallCx; @@ -62,6 +63,7 @@ pub fn register_all( queue::register(engine, services, cx.clone()); retry::register(engine, services, cx.clone()); secrets::register(engine, services, cx.clone()); + vars::register(engine, services, cx.clone()); email::register(engine, services, cx.clone()); users::register(engine, services, cx.clone()); invoke::register(engine, services, cx, limits, self_engine); diff --git a/crates/executor-core/src/sdk/vars.rs b/crates/executor-core/src/sdk/vars.rs new file mode 100644 index 0000000..ca43421 --- /dev/null +++ b/crates/executor-core/src/sdk/vars.rs @@ -0,0 +1,57 @@ +//! `vars::` Rhai bridge — read-only access to the app's resolved, +//! group-inherited config (Phase 3). +//! +//! ```rhai +//! let region = vars::get("region"); // value or () +//! let all = vars::all(); // #{ key: value, ... } +//! ``` +//! +//! Values are inherited down the group tree and env-filtered (§3); the +//! resolution happens server-side in manager-core. Writes go through the +//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the +//! service — never a script argument — preserving cross-app isolation. + +use std::sync::Arc; + +use picloud_shared::{SdkCallCx, Services}; +use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; + +use super::bridge::{block_on, json_to_dynamic}; + +pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { + let svc = services.vars.clone(); + let mut module = Module::new(); + + // vars::get(key) — resolved value, or () if no level defines it. + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn( + "get", + move |key: &str| -> Result> { + let svc = svc.clone(); + let cx = cx.clone(); + let opt = block_on("vars", async move { svc.get(&cx, key).await })?; + Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic)) + }, + ); + } + + // vars::all() — the fully-resolved config map. + { + let svc = svc.clone(); + let cx = cx.clone(); + module.set_native_fn("all", move || -> Result> { + let svc = svc.clone(); + let cx = cx.clone(); + let resolved = block_on("vars", async move { svc.all(&cx).await })?; + let mut m = Map::new(); + for (k, v) in resolved { + m.insert(k.into(), json_to_dynamic(v)); + } + Ok(m) + }); + } + + engine.register_static_module("vars", module.into()); +} diff --git a/crates/executor-core/tests/module_redaction_logging.rs b/crates/executor-core/tests/module_redaction_logging.rs index 09f5362..9850387 100644 --- a/crates/executor-core/tests/module_redaction_logging.rs +++ b/crates/executor-core/tests/module_redaction_logging.rs @@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); let engine = Engine::new(Limits::default(), services); diff --git a/crates/executor-core/tests/modules.rs b/crates/executor-core/tests/modules.rs index 7897918..7a1607f 100644 --- a/crates/executor-core/tests/modules.rs +++ b/crates/executor-core/tests/modules.rs @@ -104,6 +104,7 @@ fn services_with(modules: Arc) -> Services { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ) } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs index 01db6e5..9feb03a 100644 --- a/crates/executor-core/tests/sdk_docs.rs +++ b/crates/executor-core/tests/sdk_docs.rs @@ -235,6 +235,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_email.rs b/crates/executor-core/tests/sdk_email.rs index c562a7f..160441d 100644 --- a/crates/executor-core/tests/sdk_email.rs +++ b/crates/executor-core/tests/sdk_email.rs @@ -44,6 +44,7 @@ fn engine_with(rec: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_files.rs b/crates/executor-core/tests/sdk_files.rs index 639bc37..68a29c6 100644 --- a/crates/executor-core/tests/sdk_files.rs +++ b/crates/executor-core/tests/sdk_files.rs @@ -172,6 +172,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_http.rs b/crates/executor-core/tests/sdk_http.rs index 672bcf4..7c175b8 100644 --- a/crates/executor-core/tests/sdk_http.rs +++ b/crates/executor-core/tests/sdk_http.rs @@ -95,6 +95,7 @@ fn engine_with(http: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_invoke.rs b/crates/executor-core/tests/sdk_invoke.rs index b9cc325..67468ce 100644 --- a/crates/executor-core/tests/sdk_invoke.rs +++ b/crates/executor-core/tests/sdk_invoke.rs @@ -90,6 +90,7 @@ fn build_engine(svc: Arc) -> Arc { Arc::new(NoopUsersService), Arc::new(NoopQueueService), svc, + Arc::new(picloud_shared::NoopVarsService), ); let engine = Arc::new(Engine::new(Limits::default(), services)); engine.set_self_weak(Arc::downgrade(&engine)); diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index c37a2a5..6b3b059 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -114,6 +114,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_pubsub.rs b/crates/executor-core/tests/sdk_pubsub.rs index cdecc3d..56daabe 100644 --- a/crates/executor-core/tests/sdk_pubsub.rs +++ b/crates/executor-core/tests/sdk_pubsub.rs @@ -52,6 +52,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_queue.rs b/crates/executor-core/tests/sdk_queue.rs index c776b68..aaea046 100644 --- a/crates/executor-core/tests/sdk_queue.rs +++ b/crates/executor-core/tests/sdk_queue.rs @@ -66,6 +66,7 @@ fn make_engine(svc: Arc) -> Arc { Arc::new(picloud_shared::NoopUsersService), svc, Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_retry.rs b/crates/executor-core/tests/sdk_retry.rs index 11ac00f..116698b 100644 --- a/crates/executor-core/tests/sdk_retry.rs +++ b/crates/executor-core/tests/sdk_retry.rs @@ -30,6 +30,7 @@ fn build_engine() -> Arc { Arc::new(NoopUsersService), Arc::new(NoopQueueService), Arc::new(NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_secrets.rs b/crates/executor-core/tests/sdk_secrets.rs index 2836134..4a5815b 100644 --- a/crates/executor-core/tests/sdk_secrets.rs +++ b/crates/executor-core/tests/sdk_secrets.rs @@ -105,6 +105,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/executor-core/tests/sdk_subscriber_token.rs b/crates/executor-core/tests/sdk_subscriber_token.rs index 702313a..50309d1 100644 --- a/crates/executor-core/tests/sdk_subscriber_token.rs +++ b/crates/executor-core/tests/sdk_subscriber_token.rs @@ -99,6 +99,7 @@ fn make_engine() -> Arc { Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), + Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } diff --git a/crates/manager-core/migrations/0048_vars.sql b/crates/manager-core/migrations/0048_vars.sql new file mode 100644 index 0000000..3cd8cc0 --- /dev/null +++ b/crates/manager-core/migrations/0048_vars.sql @@ -0,0 +1,47 @@ +-- Phase 3: group-inherited config — the `vars` table + an app environment +-- marker. See docs/design/groups-and-project-tool.md §3, §5.1. +-- +-- `vars` is the net-new, env-scoped configuration layer (greenfield — there +-- is no env-agnostic config today, only `secrets`). A var is owned by +-- exactly one group OR one app, optionally scoped to an environment, and +-- resolved down the tree (§3): env-filter first, then nearest level wins. +-- +-- "An environment is an app": each env is already a distinct app row. To +-- env-filter group-level `@E` values, the resolver must know which env an +-- app represents — recorded here on `apps.environment` (NULL = env-agnostic, +-- set at app-create from the CLI's known env). + +ALTER TABLE apps ADD COLUMN environment TEXT; + +CREATE TABLE vars ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a + -- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling + -- owner is impossible. + group_id UUID REFERENCES groups(id) ON DELETE CASCADE, + app_id UUID REFERENCES apps(id) ON DELETE CASCADE, + CONSTRAINT vars_owner_exactly_one + CHECK ((group_id IS NULL) <> (app_id IS NULL)), + -- '*' = env-agnostic; otherwise an env name matched against + -- apps.environment. NOT NULL with a '*' sentinel so the uniqueness + -- indexes are total (a NULL scope would break UNIQUE). + environment_scope TEXT NOT NULL DEFAULT '*', + key TEXT NOT NULL, + -- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an + -- inherited key, §3) is the explicit boolean below, NOT value = 'null' + -- — JSON null is a legitimate value and must stay distinguishable. + value JSONB NOT NULL, + is_tombstone BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One row per (owner, scope, key). Partial unique indexes because the owner +-- is split across two nullable columns. +CREATE UNIQUE INDEX vars_group_uidx + ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL; +CREATE UNIQUE INDEX vars_app_uidx + ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL; + +CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL; +CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL; diff --git a/crates/manager-core/migrations/0049_group_secrets.sql b/crates/manager-core/migrations/0049_group_secrets.sql new file mode 100644 index 0000000..b1174c5 --- /dev/null +++ b/crates/manager-core/migrations/0049_group_secrets.sql @@ -0,0 +1,51 @@ +-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets. +-- +-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one +-- envelope per app. Phase 3 makes secrets inheritable down the group tree +-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048): +-- a secret may be owned by an app OR an ancestor group, and a descendant +-- app resolves the nearest one, environment-filtered. +-- +-- Reshape (mirrors `vars`): +-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its +-- secrets, same as its vars). +-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now +-- enforces "owned by an app XOR a group". +-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name; +-- the resolver filters on it. Existing rows backfill to `'*'`, so every +-- current app secret stays env-agnostic and resolves unchanged. +-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both +-- keyed `(owner, environment_scope, name)`. +-- +-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is +-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}` +-- for group secrets — it does NOT include `environment_scope`. Adding the +-- column therefore leaves every existing ciphertext decryptable byte-for-byte; +-- the app-owner AAD is unchanged and the group namespace is disjoint. + +ALTER TABLE secrets + ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE, + ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*'; + +-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it +-- is a primary-key column. +ALTER TABLE secrets + DROP CONSTRAINT secrets_pkey; + +ALTER TABLE secrets + ALTER COLUMN app_id DROP NOT NULL; + +ALTER TABLE secrets + ADD CONSTRAINT secrets_owner_exactly_one + CHECK ((group_id IS NULL) <> (app_id IS NULL)); + +-- One secret per (owner, env, name). Partial so each owner column only +-- constrains its own rows; the resolver and every upsert restate the +-- predicate as the ON CONFLICT arbiter. +CREATE UNIQUE INDEX secrets_app_uidx + ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL; +CREATE UNIQUE INDEX secrets_group_uidx + ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL; + +-- Owner lookup index for the group side (the app side keeps idx_secrets_app). +CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL; diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 38adcf0..e899d0e 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -818,7 +818,7 @@ impl ApplyService { ) -> Result<(Vec, Vec), ApplyError> { let stored = self .secrets - .get(app_id, name) + .get(crate::secrets_service::SecretOwner::App(app_id), "*", name) .await .map_err(|e| ApplyError::Backend(e.to_string()))? .ok_or_else(|| { @@ -827,8 +827,13 @@ impl ApplyService { (push it with `pic secret set {name}`)" )) })?; - let plaintext = crate::secrets_service::open(&self.master_key, app_id, name, &stored) - .map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?; + let plaintext = crate::secrets_service::open( + &self.master_key, + crate::secrets_service::SecretOwner::App(app_id), + name, + &stored, + ) + .map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?; // The inbound HMAC must be a non-empty string — an empty/whitespace // key is forgeable (preserves the spirit of audit 2026-06-11 H-B2). match plaintext.as_str() { @@ -1013,7 +1018,11 @@ impl ApplyService { loop { let page = self .secrets - .list_names(app_id, cursor.as_deref(), 200) + .list_names( + crate::secrets_service::SecretOwner::App(app_id), + cursor.as_deref(), + 200, + ) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; names.extend(page.names); diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 9f1cf56..df554d4 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -116,6 +116,25 @@ pub enum Capability { /// Write (set/delete) a secret in this app's secrets store (v1.1.7). /// Granted to `editor`+, maps to `script:write` on API keys. AppSecretsWrite(AppId), + /// Read this app's resolved config vars (Phase 3). Same trust shape as + /// secrets-read — granted to `viewer`+, maps to `script:read`. + AppVarsRead(AppId), + /// Write (set/delete) an app-owned config var (Phase 3). Granted to + /// `editor`+, maps to `script:write`. + AppVarsWrite(AppId), + /// Read a group's config vars (Phase 3). Resolved via the group + /// ancestor walk; viewer+ on the group. + GroupVarsRead(GroupId), + /// Write (set/delete) a group-owned config var (Phase 3). editor+ on + /// the group. + GroupVarsWrite(GroupId), + /// Read a group-owned secret's VALUE (Phase 3, the human-read gate). + /// group_admin on the owning group — distinct from runtime injection, + /// which an inheriting app does without this check. + GroupSecretsRead(GroupId), + /// Write (set/delete) a group-owned secret (Phase 3). editor+ on the + /// group. + GroupSecretsWrite(GroupId), /// Send an outbound email from a script in this app (v1.1.7). Maps /// to `script:write` on API keys (sending mail is an outbound /// side-effect like an HTTP request). Granted to `editor`+. @@ -176,7 +195,11 @@ impl Capability { // app) is denied group management at the binding layer. | Self::GroupRead(_) | Self::GroupWrite(_) - | Self::GroupAdmin(_) => None, + | Self::GroupAdmin(_) + | Self::GroupVarsRead(_) + | Self::GroupVarsWrite(_) + | Self::GroupSecretsRead(_) + | Self::GroupSecretsWrite(_) => None, Self::AppRead(id) | Self::AppWriteScript(id) | Self::AppWriteRoute(id) @@ -194,6 +217,8 @@ impl Capability { | Self::AppQueueEnqueue(id) | Self::AppSecretsRead(id) | Self::AppSecretsWrite(id) + | Self::AppVarsRead(id) + | Self::AppVarsWrite(id) | Self::AppEmailSend(id) | Self::AppManageTriggers(id) | Self::AppDeadLetterManage(id) @@ -223,7 +248,9 @@ impl Capability { | Self::AppFilesRead(_) | Self::AppSecretsRead(_) | Self::AppUsersRead(_) - | Self::GroupRead(_) => Scope::ScriptRead, + | Self::AppVarsRead(_) + | Self::GroupRead(_) + | Self::GroupVarsRead(_) => Scope::ScriptRead, Self::AppWriteScript(_) | Self::AppKvWrite(_) | Self::AppDocsWrite(_) @@ -235,6 +262,12 @@ impl Capability { | Self::AppEmailSend(_) | Self::AppUsersWrite(_) | Self::AppUsersAdmin(_) + | Self::AppVarsWrite(_) + // Group-secret WRITE is editor-role-gated (same tier as + // GroupVarsWrite), so its API-key scope matches: script:write, + // not app:admin. Only the VALUE READ (GroupSecretsRead) sits at + // the admin tier below. + | Self::GroupSecretsWrite(_) | Self::AppInvoke(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, @@ -243,7 +276,9 @@ impl Capability { | Self::AppDeadLetterManage(_) | Self::AppTopicManage(_) | Self::GroupWrite(_) - | Self::GroupAdmin(_) => Scope::AppAdmin, + | Self::GroupAdmin(_) + | Self::GroupVarsWrite(_) + | Self::GroupSecretsRead(_) => Scope::AppAdmin, Self::AppLogRead(_) => Scope::LogRead, } } @@ -412,7 +447,13 @@ async fn role_grants( // walk (a group_admin on an ancestor is implicitly admin of // the descendant group). Routed before member_grants because // group caps carry no app_id. - Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => { + Capability::GroupRead(g) + | Capability::GroupWrite(g) + | Capability::GroupAdmin(g) + | Capability::GroupVarsRead(g) + | Capability::GroupVarsWrite(g) + | Capability::GroupSecretsRead(g) + | Capability::GroupSecretsWrite(g) => { group_member_grants(repo, principal.user_id, cap, g).await } // Creating a root-level group is an instance act — members @@ -472,9 +513,19 @@ async fn group_member_grants( /// viewer→read, editor→write, group_admin(=AppAdmin)→admin. const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool { match cap { - Capability::GroupRead(_) => true, // any role can read - Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin), - Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin), + // viewer+ reads group metadata and config vars. + Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true, + // editor+ writes config vars/secrets. + Capability::GroupWrite(_) + | Capability::GroupVarsWrite(_) + | Capability::GroupSecretsWrite(_) => { + matches!(role, AppRole::Editor | AppRole::AppAdmin) + } + // group_admin manages the group + reads secret VALUES (the + // human-read gate, distinct from an app's runtime injection). + Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => { + matches!(role, AppRole::AppAdmin) + } _ => false, } } @@ -493,6 +544,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppFilesRead(_) | Capability::AppSecretsRead(_) | Capability::AppUsersRead(_) + | Capability::AppVarsRead(_) ); let in_editor = in_viewer || matches!( @@ -508,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppSecretsWrite(_) | Capability::AppEmailSend(_) | Capability::AppUsersWrite(_) + | Capability::AppVarsWrite(_) | Capability::AppInvoke(_) ); let in_app_admin = in_editor diff --git a/crates/manager-core/src/config_api.rs b/crates/manager-core/src/config_api.rs new file mode 100644 index 0000000..01861ef --- /dev/null +++ b/crates/manager-core/src/config_api.rs @@ -0,0 +1,158 @@ +//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an +//! app actually sees: every inherited var (with its value + provenance) and +//! every inherited secret (MASKED — name/owner/scope only, never the value). +//! +//! This is the read-only companion to the `vars`/`secrets` admin surfaces. +//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so +//! a dev can see exactly what `vars::get`/`secrets::get` would return and +//! where each value comes from (`--explain` on the CLI surfaces the +//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret +//! VALUES are deliberately absent — reading those needs `GroupSecretsRead` +//! at the owning group via the dedicated value endpoint. + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::get; +use axum::{Extension, Router}; +use picloud_shared::{AppId, Principal}; +use serde_json::json; +use sqlx::PgPool; + +use crate::app_repo::AppRepository; +use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; +use crate::config_resolver::{ + fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind, +}; + +#[derive(Clone)] +pub struct ConfigApiState { + pub pool: PgPool, + pub apps: Arc, + pub authz: Arc, +} + +pub fn config_router(state: ConfigApiState) -> Router { + Router::new() + .route("/apps/{id_or_slug}/config/effective", get(effective_config)) + .with_state(state) +} + +fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value { + json!({ "kind": kind.as_str(), "id": id, "depth": depth }) +} + +async fn effective_config( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, ConfigApiError> { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppVarsRead(app_id), + ) + .await?; + + // Vars: resolve to values + provenance (vars are app-readable config). + let candidates = fetch_var_candidates(&s.pool, app_id) + .await + .map_err(|e| ConfigApiError::Backend(e.to_string()))?; + let (values, provenance) = resolve(candidates); + let mut vars = serde_json::Map::new(); + for (key, value) in values { + let p = &provenance[&key]; + vars.insert( + key, + json!({ + "value": value, + "owner": owner_json(p.owner_kind, p.owner_id, p.depth), + "scope": p.scope, + "merged_from": p.merged_from + .iter() + .map(|(d, sc)| json!({ "depth": d, "scope": sc })) + .collect::>(), + }), + ); + } + + // Secrets: masked — name + owner/level/scope + status, never the value. + let secret_meta = fetch_effective_secret_meta(&s.pool, app_id) + .await + .map_err(|e| ConfigApiError::Backend(e.to_string()))?; + let mut secrets = serde_json::Map::new(); + for m in secret_meta { + secrets.insert( + m.name, + json!({ + "status": "set", + "owner": owner_json(m.owner_kind, m.owner_id, m.depth), + "scope": m.scope, + }), + ); + } + + Ok(Json(json!({ "vars": vars, "secrets": secrets }))) +} + +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) + .await + .map_err(|e| ConfigApiError::Backend(e.to_string()))? + .map(|l| l.app.id) + .ok_or(ConfigApiError::AppNotFound) +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigApiError { + #[error("app not found")] + AppNotFound, + #[error("forbidden")] + Forbidden, + #[error("authorization repo error: {0}")] + AuthzRepo(String), + #[error("config backend: {0}")] + Backend(String), +} + +impl From for ConfigApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From for ConfigApiError { + fn from(e: AuthzError) -> Self { + Self::AuthzRepo(e.to_string()) + } +} + +impl IntoResponse for ConfigApiError { + fn into_response(self) -> Response { + let (status, body) = match &self { + Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })), + Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), + Self::AuthzRepo(e) => { + tracing::error!(error = %e, "config effective authz repo error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + Self::Backend(e) => { + tracing::error!(error = %e, "config effective backend error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + }; + (status, Json(body)).into_response() + } +} diff --git a/crates/manager-core/src/config_resolver.rs b/crates/manager-core/src/config_resolver.rs new file mode 100644 index 0000000..edbd1d5 --- /dev/null +++ b/crates/manager-core/src/config_resolver.rs @@ -0,0 +1,456 @@ +//! The §3 configuration-resolution engine: env-filtered, proximity-first +//! inheritance down the group tree. +//! +//! To resolve a key for app A in environment E (docs/design §3): +//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible; +//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`. +//! Env is *eligibility*, not a precedence tier. +//! 2. **Nearest level wins** — walk A → parent group → … → root; the +//! closest level that defines the (filtered) key wins. Proximity beats +//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`). +//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an +//! explicit tombstone** that suppresses the inherited key. +//! +//! The recursive CTE that walks `apps.group_id → groups.parent_id → root` +//! mirrors `app_members_repo::effective_app_role`. The env-eligibility +//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which +//! a window-function pick can't express — happen in the pure `resolve` +//! function below, so they're unit-tested without Postgres. + +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; +use sqlx::PgPool; +use uuid::Uuid; + +use picloud_shared::AppId; + +/// Shared chain-walk CTE: emits one row per owner-level for an app — +/// depth 0 = the app itself (`app_owner` set), then ancestor groups +/// nearest-first (`group_owner` set), each carrying the app's environment. +/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this +/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret +/// resolvers, which each append their own owner-keyed JOIN. +pub(crate) const CHAIN_LEVELS_CTE: &str = "\ + WITH RECURSIVE chain AS ( \ + SELECT a.id AS app_owner, NULL::uuid AS group_owner, \ + a.group_id AS next_group, 0 AS depth, a.environment AS app_env \ + FROM apps a WHERE a.id = $1 \ + UNION ALL \ + SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \ + FROM groups g JOIN chain c ON g.id = c.next_group \ + WHERE c.depth < 64 \ + )"; + +/// Owner kind of a resolved value, for `--explain` provenance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnerKind { + App, + Group, +} + +impl OwnerKind { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::App => "app", + Self::Group => "group", + } + } +} + +/// One eligible (env-filtered) candidate row pulled by the resolver, before +/// §3 proximity/merge resolution. +#[derive(Debug, Clone)] +pub struct Candidate { + /// 0 = the app itself; 1 = its parent group; … (nearest-first). + pub depth: i32, + pub owner_kind: OwnerKind, + pub owner_id: Uuid, + /// `*` (env-agnostic) or a concrete environment name. + pub scope: String, + pub key: String, + pub value: Value, + pub is_tombstone: bool, +} + +/// Where a resolved key came from (for `config --effective --explain`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Provenance { + pub owner_kind: OwnerKind, + pub owner_id: Uuid, + pub depth: i32, + pub scope: String, + /// For a deep-merged map, the `(depth, scope)` of every layer that + /// contributed, nearest-first. Empty for a plain scalar/array winner. + pub merged_from: Vec<(i32, String)>, +} + +/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1). +fn env_priority(scope: &str) -> u8 { + u8::from(scope != "*") +} + +/// Deep-merge `src` into `dst` per key: nested objects merge recursively, +/// everything else is replaced by `src`. Caller merges farthest→nearest so +/// nearer layers overwrite. +fn deep_merge(dst: &mut Map, src: &Map) { + for (k, sv) in src { + match (dst.get_mut(k), sv) { + (Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm), + _ => { + dst.insert(k.clone(), sv.clone()); + } + } + } +} + +/// Resolve one key's candidate rows (any order in) to its effective value, +/// plus the provenance. Returns `None` when a tombstone suppresses the key. +fn resolve_one(mut rows: Vec) -> Option<(Value, Provenance)> { + // Nearest-first; within a level, `@E` before `*` (§3 steps 1-2). + rows.sort_by(|a, b| { + a.depth + .cmp(&b.depth) + .then(env_priority(&b.scope).cmp(&env_priority(&a.scope))) + }); + + // §3 step 1: env is eligibility, not a merge tier — within a single level + // `@E` *suppresses* `*` (it does not layer on top of it). Each level is one + // owner (single-parent tree) with at most one `@E` and one `*` row per key + // after env-filtering; keep only the level's winner (the `@E` row sorts + // first). Without this, a level holding both an `@E` map and a `*` map would + // deep-merge them instead of the `@E` shadowing the `*`. + rows.dedup_by_key(|r| r.depth); + + // Collect the contiguous run of map-valued rows from the nearest. A + // tombstone or scalar/array boundary stops the run (and, if it's the + // nearest row, decides the result outright). + let mut maps: Vec<&Candidate> = Vec::new(); + let mut boundary: Option<&Candidate> = None; + for r in &rows { + if r.is_tombstone { + boundary = Some(r); + break; + } + if r.value.is_object() { + maps.push(r); + } else { + boundary = Some(r); + break; + } + } + + if let Some(first) = maps.first() { + // Map run wins; deep-merge farthest→nearest so nearest keys win. + let mut acc = Map::new(); + for m in maps.iter().rev() { + if let Value::Object(obj) = &m.value { + deep_merge(&mut acc, obj); + } + } + let prov = Provenance { + owner_kind: first.owner_kind, + owner_id: first.owner_id, + depth: first.depth, + scope: first.scope.clone(), + merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(), + }; + return Some((Value::Object(acc), prov)); + } + + // No leading map run: the nearest row is the boundary. + match boundary { + // Nearest is a tombstone → key deleted. + Some(b) if b.is_tombstone => None, + // Nearest is a scalar/array → take it verbatim. + Some(b) => Some(( + b.value.clone(), + Provenance { + owner_kind: b.owner_kind, + owner_id: b.owner_id, + depth: b.depth, + scope: b.scope.clone(), + merged_from: Vec::new(), + }, + )), + // No rows at all (caller only passes non-empty groups). + None => None, + } +} + +/// Resolve a full candidate set (mixed keys, any order) into the effective +/// config map + per-key provenance. Pure — unit-tested without Postgres. +#[must_use] +pub fn resolve( + candidates: Vec, +) -> (BTreeMap, BTreeMap) { + let mut by_key: BTreeMap> = BTreeMap::new(); + for c in candidates { + by_key.entry(c.key.clone()).or_default().push(c); + } + let mut values = BTreeMap::new(); + let mut provenance = BTreeMap::new(); + for (key, rows) in by_key { + if let Some((v, p)) = resolve_one(rows) { + values.insert(key.clone(), v); + provenance.insert(key, p); + } + } + (values, provenance) +} + +/// Pull every env-eligible `vars` candidate for `app_id`: the app's own +/// rows + every ancestor group's rows, filtered to `*` or the app's +/// environment, ordered nearest-first. +/// +/// # Errors +/// Propagates sqlx errors. +pub async fn fetch_var_candidates( + pool: &PgPool, + app_id: AppId, +) -> Result, sqlx::Error> { + let sql = format!( + "{CHAIN_LEVELS_CTE} \ + SELECT c.depth, \ + CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \ + COALESCE(v.app_id, v.group_id) AS owner_id, \ + v.environment_scope, v.key, v.value, v.is_tombstone \ + FROM chain c \ + JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \ + WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \ + ORDER BY c.depth ASC" + ); + let rows = sqlx::query_as::<_, VarCandidateRow>(&sql) + .bind(app_id.into_inner()) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) +} + +/// The masked, resolved view of one inherited secret for `config/effective`: +/// which owner/level/scope supplies it — **never** the value. +#[derive(Debug, Clone)] +pub struct EffectiveSecretMeta { + pub name: String, + pub owner_kind: OwnerKind, + pub owner_id: Uuid, + pub scope: String, + pub depth: i32, +} + +/// Resolve the *names* of every secret an app effectively sees — its own +/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns +/// masked metadata only (owner/level/scope), so it's safe for an app-level +/// principal to read. `DISTINCT ON (name)` with the same ordering as the +/// per-name secret resolver guarantees the same winner. +/// +/// # Errors +/// Propagates sqlx errors. +pub async fn fetch_effective_secret_meta( + pool: &PgPool, + app_id: AppId, +) -> Result, sqlx::Error> { + let sql = format!( + "{CHAIN_LEVELS_CTE} \ + SELECT DISTINCT ON (s.name) s.name, \ + CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \ + COALESCE(s.app_id, s.group_id) AS owner_id, \ + s.environment_scope, c.depth \ + FROM chain c \ + JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \ + WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \ + ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC" + ); + let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql) + .bind(app_id.into_inner()) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map( + |(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta { + name, + owner_kind: if owner_kind == "app" { + OwnerKind::App + } else { + OwnerKind::Group + }, + owner_id, + scope, + depth, + }, + ) + .collect()) +} + +#[derive(sqlx::FromRow)] +struct VarCandidateRow { + depth: i32, + owner_kind: String, + owner_id: Uuid, + environment_scope: String, + key: String, + value: Value, + is_tombstone: bool, +} + +impl From for Candidate { + fn from(r: VarCandidateRow) -> Self { + Self { + depth: r.depth, + owner_kind: if r.owner_kind == "app" { + OwnerKind::App + } else { + OwnerKind::Group + }, + owner_id: r.owner_id, + scope: r.environment_scope, + key: r.key, + value: r.value, + is_tombstone: r.is_tombstone, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate { + Candidate { + depth, + owner_kind: if depth == 0 { + OwnerKind::App + } else { + OwnerKind::Group + }, + owner_id: Uuid::nil(), + scope: scope.into(), + key: key.into(), + value, + is_tombstone: false, + } + } + + fn tomb(depth: i32, scope: &str, key: &str) -> Candidate { + Candidate { + is_tombstone: true, + value: Value::Null, + ..cand(depth, scope, key, Value::Null) + } + } + + #[test] + fn nearest_level_wins() { + // app (depth 0) overrides group (depth 2). + let (v, p) = resolve(vec![ + cand(2, "*", "region", json!("eu")), + cand(0, "*", "region", json!("us")), + ]); + assert_eq!(v["region"], json!("us")); + assert_eq!(p["region"].depth, 0); + } + + #[test] + fn env_scoped_beats_agnostic_within_a_level() { + let (v, p) = resolve(vec![ + cand(1, "*", "db_url", json!("default")), + cand(1, "staging", "db_url", json!("staging-db")), + ]); + assert_eq!(v["db_url"], json!("staging-db")); + assert_eq!(p["db_url"].scope, "staging"); + } + + #[test] + fn proximity_beats_env_specificity_across_levels() { + // §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`. + let (v, _) = resolve(vec![ + cand(2, "production", "db_url", json!("anc-prod")), + cand(0, "*", "db_url", json!("leaf-default")), + ]); + assert_eq!(v["db_url"], json!("leaf-default")); + } + + #[test] + fn maps_deep_merge_nearest_wins() { + // ancestor sets {title, region}; leaf overrides title, adds locale. + let (v, p) = resolve(vec![ + cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})), + cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})), + ]); + assert_eq!( + v["ui"], + json!({"title": "Leaf", "region": "eu", "locale": "en"}) + ); + assert_eq!(p["ui"].merged_from.len(), 2); + } + + #[test] + fn same_level_env_map_suppresses_agnostic_map_no_merge() { + // §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it + // shadows it. A level holding both an `@staging` map and a `*` map must + // resolve to the `@staging` map alone, never a deep-merge of the two. + let (v, p) = resolve(vec![ + cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})), + cand(1, "staging", "cfg", json!({"shared": "staging"})), + ]); + assert_eq!(v["cfg"], json!({"shared": "staging"})); + assert_eq!(p["cfg"].scope, "staging"); + // Provenance must not list the suppressed `*` layer. + assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]); + } + + #[test] + fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() { + // The suppressed same-level `*` must also be invisible to cross-level + // merge: leaf `@staging` map merges onto the ancestor map, but the + // leaf's own `*` map (shadowed at its level) never contributes. + let (v, _) = resolve(vec![ + cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})), + cand(0, "*", "cfg", json!({"leak": "should-not-appear"})), + cand(0, "staging", "cfg", json!({"tier": "leaf"})), + ]); + assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"})); + assert!(!v["cfg"].as_object().unwrap().contains_key("leak")); + } + + #[test] + fn nearer_scalar_replaces_whole_inherited_map() { + let (v, _) = resolve(vec![ + cand(2, "*", "x", json!({"a": 1})), + cand(0, "*", "x", json!("scalar")), + ]); + assert_eq!(v["x"], json!("scalar")); + } + + #[test] + fn tombstone_suppresses_inherited_key() { + let (v, _) = resolve(vec![ + cand(2, "*", "secret_flag", json!(true)), + tomb(0, "*", "secret_flag"), + ]); + assert!(!v.contains_key("secret_flag")); + } + + #[test] + fn json_null_is_a_value_not_a_deletion() { + let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]); + assert!(v.contains_key("k")); + assert_eq!(v["k"], Value::Null); + } + + #[test] + fn map_merge_stops_at_a_nearer_scalar_boundary() { + // nearest map, then a scalar, then a farther map: only the + // contiguous top map run merges; the scalar bounds it. + let (v, _) = resolve(vec![ + cand(3, "*", "m", json!({"deep": 1})), + cand(2, "*", "m", json!("scalar-boundary")), + cand(0, "*", "m", json!({"near": 2})), + ]); + // depth 0 map is the only one above the depth-2 scalar boundary. + assert_eq!(v["m"], json!({"near": 2})); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index ea7f5bc..b6980a3 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -31,6 +31,8 @@ pub mod auth_api; pub mod auth_bootstrap; pub mod auth_middleware; pub mod authz; +pub mod config_api; +pub mod config_resolver; pub mod cron_scheduler; pub mod dead_letter_repo; pub mod dead_letter_service; @@ -84,6 +86,9 @@ pub mod trigger_repo; pub mod triggers_api; pub mod users_admin_api; pub mod users_service; +pub mod vars_api; +pub mod vars_repo; +pub mod vars_service; pub use abandoned_repo::{ AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo, @@ -146,6 +151,7 @@ pub use auth_middleware::{ API_KEY_PREFIX, API_KEY_PREFIX_LEN, }; pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; +pub use config_api::{config_router, ConfigApiError, ConfigApiState}; pub use cron_scheduler::spawn_cron_scheduler; pub use dead_letter_repo::{ DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo, @@ -200,11 +206,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository}; pub use sandbox::{CeilingError, SandboxCeiling}; pub use secrets_api::{secrets_router, SecretsApiError, SecretsState}; pub use secrets_repo::{ - PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo, + PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo, SecretsRepoError, StoredSecret, }; pub use secrets_service::{ - open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl, + open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl, DEFAULT_SECRET_MAX_VALUE_BYTES, }; pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError}; @@ -219,3 +225,6 @@ pub use trigger_repo::{ pub use triggers_api::{triggers_router, TriggersApiError, TriggersState}; pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState}; pub use users_service::{UsersServiceConfig, UsersServiceImpl}; +pub use vars_api::{vars_router, VarsApiError, VarsApiState}; +pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError}; +pub use vars_service::VarsServiceImpl; diff --git a/crates/manager-core/src/secrets_api.rs b/crates/manager-core/src/secrets_api.rs index 7289a48..a27dd98 100644 --- a/crates/manager-core/src/secrets_api.rs +++ b/crates/manager-core/src/secrets_api.rs @@ -1,16 +1,27 @@ -//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints -//! (v1.1.7). +//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints. //! -//! * `GET /apps/{id}/secrets` — list names + updated_at +//! * `GET /apps/{id}/secrets` — list app secret names + updated_at //! (NEVER values). -//! * `POST /apps/{id}/secrets` — set/overwrite a secret. -//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret. +//! * `POST /apps/{id}/secrets` — set/overwrite an app secret. +//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret. +//! * `GET /groups/{id}/secrets` — list group secret names + scope +//! + updated_at (NEVER values). +//! * `POST /groups/{id}/secrets` — set/overwrite a group secret +//! (env-scoped). +//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret. +//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** — +//! the ONE endpoint that returns plaintext, gated at the owning group. //! -//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by -//! `AppSecretsRead` (→ `script:read`). The list surface deliberately -//! returns only names + timestamps — the dashboard never receives -//! plaintext. Values are encrypted with the process master key before -//! they touch the database (same envelope as the script `secrets::set`). +//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`. +//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the +//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is +//! the masked-secret boundary: a descendant app's dev can see that a group +//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a +//! principal with read rights AT THE OWNING GROUP can read its value. The +//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the +//! capability to the resolved owner id — never to a caller-controlled path +//! param. Values are encrypted with the process master key before they +//! touch the database (owner-bound AAD; see `secrets_service`). use std::sync::Arc; @@ -19,19 +30,24 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; -use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError}; +use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError}; use serde::Deserialize; use serde_json::json; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; -use crate::secrets_repo::{SecretsRepo, SecretsRepoError}; -use crate::secrets_service::seal; +use crate::group_repo::GroupRepository; +use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError}; +use crate::secrets_service::{open, seal}; + +/// App secrets are env-agnostic; only group secrets carry a concrete scope. +const APP_SECRET_SCOPE: &str = "*"; #[derive(Clone)] pub struct SecretsState { pub repo: Arc, pub apps: Arc, + pub groups: Arc, pub authz: Arc, pub master_key: MasterKey, pub max_value_bytes: usize, @@ -39,10 +55,25 @@ pub struct SecretsState { pub fn secrets_router(state: SecretsState) -> Router { Router::new() - .route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret)) + .route( + "/apps/{app_id}/secrets", + get(list_app_secrets).post(set_app_secret), + ) .route( "/apps/{app_id}/secrets/{name}", - axum::routing::delete(delete_secret), + axum::routing::delete(delete_app_secret), + ) + .route( + "/groups/{group_id}/secrets", + get(list_group_secrets).post(set_group_secret), + ) + .route( + "/groups/{group_id}/secrets/{name}", + axum::routing::delete(delete_group_secret), + ) + .route( + "/groups/{group_id}/secrets/{name}/value", + get(read_group_secret_value), ) .with_state(state) } @@ -55,9 +86,18 @@ pub struct ListQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +pub struct EnvQuery { + #[serde(default)] + pub env: Option, +} + #[derive(Debug, serde::Serialize)] struct SecretItem { name: String, + /// Environment scope — `*` for app secrets, possibly a concrete env for + /// group secrets. + env: String, updated_at: chrono::DateTime, } @@ -67,7 +107,23 @@ struct ListSecretsResponse { next_cursor: Option, } -async fn list_secrets( +#[derive(Debug, Deserialize)] +pub struct SetSecretRequest { + pub name: String, + /// Any JSON value — the dashboard sends a single-line string, but + /// maps/arrays/numbers round-trip too (matching `secrets::set`). + pub value: serde_json::Value, + /// Environment scope (group secrets only). `*` (env-agnostic, default) + /// or a concrete env matched against `apps.environment` at resolution. + #[serde(default)] + pub env: Option, +} + +// ---------------------------------------------------------------------------- +// App handlers (env-agnostic, scope `*`) +// ---------------------------------------------------------------------------- + +async fn list_app_secrets( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, @@ -80,32 +136,10 @@ async fn list_secrets( Capability::AppSecretsRead(app_id), ) .await?; - let page = s - .repo - .list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0)) - .await?; - Ok(Json(ListSecretsResponse { - secrets: page - .items - .into_iter() - .map(|m| SecretItem { - name: m.name, - updated_at: m.updated_at, - }) - .collect(), - next_cursor: page.next_cursor, - })) + list_meta(&*s.repo, SecretOwner::App(app_id), &q).await } -#[derive(Debug, Deserialize)] -pub struct SetSecretRequest { - pub name: String, - /// Any JSON value — the dashboard sends a single-line string, but - /// maps/arrays/numbers round-trip too (matching `secrets::set`). - pub value: serde_json::Value, -} - -async fn set_secret( +async fn set_app_secret( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, @@ -118,23 +152,17 @@ async fn set_secret( Capability::AppSecretsWrite(app_id), ) .await?; - validate_secret_name(&input.name)?; - // Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to - // (app_id, name). Same path as the SDK secrets::set. - let (ciphertext, nonce, version) = seal( - &s.master_key, - app_id, - &input.name, - &input.value, - s.max_value_bytes, - )?; - s.repo - .set(app_id, &input.name, &ciphertext, &nonce, version) - .await?; - Ok(StatusCode::NO_CONTENT) + // App secrets are always env-agnostic; reject a stray `env`. + if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) { + return Err(SecretsApiError::Invalid( + "app secrets are env-agnostic; set an environment scope on a group secret instead" + .into(), + )); + } + seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await } -async fn delete_secret( +async fn delete_app_secret( State(s): State, Extension(principal): Extension, Path((id_or_slug, name)): Path<(String, String)>, @@ -146,12 +174,161 @@ async fn delete_secret( Capability::AppSecretsWrite(app_id), ) .await?; - if !s.repo.delete(app_id, &name).await? { + delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await +} + +// ---------------------------------------------------------------------------- +// Group handlers (env-scoped) +// ---------------------------------------------------------------------------- + +async fn list_group_secrets( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Query(q): Query, +) -> Result, SecretsApiError> { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsWrite(group_id), + ) + .await?; + list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await +} + +async fn set_group_secret( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsWrite(group_id), + ) + .await?; + let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into()); + validate_env_scope(&env)?; + seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await +} + +async fn delete_group_secret( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, name)): Path<(String, String)>, + Query(q): Query, +) -> Result { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsWrite(group_id), + ) + .await?; + let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into()); + validate_env_scope(&env)?; + delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await +} + +/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead` +/// (group_admin) — the masked-secret boundary. A descendant app's dev can +/// see the secret exists and consume it at runtime, but only a reader at the +/// owning group gets the value here. +async fn read_group_secret_value( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, name)): Path<(String, String)>, + Query(q): Query, +) -> Result, SecretsApiError> { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupSecretsRead(group_id), + ) + .await?; + validate_secret_name(&name)?; + let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into()); + validate_env_scope(&env)?; + let owner = SecretOwner::Group(group_id); + let stored = s + .repo + .get(owner, &env, &name) + .await? + .ok_or(SecretsApiError::NotFound)?; + let value = open(&s.master_key, owner, &name, &stored).map_err(|e| { + tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted"); + SecretsApiError::from(e) + })?; + Ok(Json(json!({ "name": name, "env": env, "value": value }))) +} + +// ---------------------------------------------------------------------------- +// Shared owner-generic bodies +// ---------------------------------------------------------------------------- + +async fn list_meta( + repo: &dyn SecretsRepo, + owner: SecretOwner, + q: &ListQuery, +) -> Result, SecretsApiError> { + let page = repo + .list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0)) + .await?; + Ok(Json(ListSecretsResponse { + secrets: page + .items + .into_iter() + .map(|m| SecretItem { + name: m.name, + env: m.environment_scope, + updated_at: m.updated_at, + }) + .collect(), + next_cursor: page.next_cursor, + })) +} + +async fn seal_and_store( + s: &SecretsState, + owner: SecretOwner, + env: &str, + input: SetSecretRequest, +) -> Result { + validate_secret_name(&input.name)?; + // Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name. + let (ciphertext, nonce, version) = seal( + &s.master_key, + owner, + &input.name, + &input.value, + s.max_value_bytes, + )?; + s.repo + .set(owner, env, &input.name, &ciphertext, &nonce, version) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn delete( + repo: &dyn SecretsRepo, + owner: SecretOwner, + env: &str, + name: &str, +) -> Result { + if !repo.delete(owner, env, name).await? { return Err(SecretsApiError::NotFound); } Ok(StatusCode::NO_CONTENT) } +// ---------------------------------------------------------------------------- +// Resolution + validation +// ---------------------------------------------------------------------------- + async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await @@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result Result { + let found = if let Ok(uuid) = ident.parse::() { + groups + .get_by_id(uuid.into()) + .await + .map_err(|e| SecretsApiError::Backend(e.to_string()))? + } else { + groups + .get_by_slug(ident) + .await + .map_err(|e| SecretsApiError::Backend(e.to_string()))? + }; + found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound) +} + +/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars +/// admin validator so a secret and a var share the same env vocabulary. +fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> { + if env == "*" { + return Ok(()); + } + if env.is_empty() || env.len() > 63 { + return Err(SecretsApiError::Invalid( + "env must be '*' or 1–63 characters".into(), + )); + } + let first = env.chars().next().unwrap(); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(SecretsApiError::Invalid( + "env must start with a lowercase letter or digit".into(), + )); + } + if !env + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(SecretsApiError::Invalid( + "env may contain only lowercase letters, digits, and hyphens".into(), + )); + } + Ok(()) +} + #[derive(Debug, thiserror::Error)] pub enum SecretsApiError { #[error("app not found")] AppNotFound, + #[error("group not found")] + GroupNotFound, #[error("secret not found")] NotFound, #[error("invalid request: {0}")] @@ -214,7 +439,7 @@ impl From for SecretsApiError { impl IntoResponse for SecretsApiError { fn into_response(self) -> Response { let (status, body) = match &self { - Self::AppNotFound | Self::NotFound => { + Self::AppNotFound | Self::GroupNotFound | Self::NotFound => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Invalid(_) => ( diff --git a/crates/manager-core/src/secrets_repo.rs b/crates/manager-core/src/secrets_repo.rs index 9ebc3c8..f955e64 100644 --- a/crates/manager-core/src/secrets_repo.rs +++ b/crates/manager-core/src/secrets_repo.rs @@ -2,13 +2,23 @@ //! opaque ciphertext + nonce blobs in and out. Encryption, JSON //! encoding, authorization, name validation, and the value-size cap all //! live one layer up in `SecretsServiceImpl` / `secrets_api`. +//! +//! Phase 3 made secrets polymorphic-owner + env-scoped (migration +//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one +//! ancestor group, and a descendant app resolves the nearest one, +//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are +//! owner-keyed via [`SecretOwner`]; the SDK read path goes through +//! [`SecretsRepo::resolve`], which walks the app→group→root chain. use async_trait::async_trait; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use chrono::{DateTime, Utc}; -use picloud_shared::AppId; +use picloud_shared::{AppId, GroupId}; use sqlx::PgPool; +use uuid::Uuid; + +use crate::config_resolver::CHAIN_LEVELS_CTE; #[derive(Debug, thiserror::Error)] pub enum SecretsRepoError { @@ -19,14 +29,22 @@ pub enum SecretsRepoError { InvalidCursor, } +/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR +/// one group; the owner is bound into the AES-GCM AAD (see +/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails +/// decryption, and it selects the partial-unique conflict target on write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretOwner { + App(AppId), + Group(GroupId), +} + /// An encrypted secret as it lives on disk: ciphertext (auth tag /// appended) plus the nonce it was sealed with. /// /// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout. /// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` = -/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration -/// `0042_secrets_envelope_version.sql` adds the column with a default -/// of `0`, so existing rows keep working. +/// AES-GCM with AAD bound to the owner+name (see `secrets_service`). #[derive(Debug, Clone)] pub struct StoredSecret { pub encrypted_value: Vec, @@ -34,11 +52,21 @@ pub struct StoredSecret { pub version: i16, } +/// The winner of an inherited-secret resolution: the stored ciphertext +/// plus the owner it actually came from (app-own or an ancestor group), +/// which the caller needs to pick the right AAD when decrypting. +#[derive(Debug, Clone)] +pub struct ResolvedSecret { + pub owner: SecretOwner, + pub stored: StoredSecret, +} + /// Admin-surface metadata for one secret. Values are never returned — -/// only the name and the last-modified timestamp. +/// only the name, its environment scope, and the last-modified timestamp. #[derive(Debug, Clone)] pub struct SecretMeta { pub name: String, + pub environment_scope: String, pub updated_at: DateTime, } @@ -60,38 +88,62 @@ pub struct SecretsMetaPage { /// substitute an in-memory backing without Postgres. #[async_trait] pub trait SecretsRepo: Send + Sync { - async fn get( + /// Resolve the effective secret for `app_id` by name: walk the + /// app→ancestor-group chain (depth 0 = the app), env-filter to the + /// app's environment or `*`, and return the nearest winner (with + /// `@E` beating `*` within a level). `None` if no level defines it. + /// This is the runtime injection path — isolation is anchored to + /// `app_id`, so an app only ever sees its own + its ancestors' secrets. + async fn resolve( &self, app_id: AppId, name: &str, + ) -> Result, SecretsRepoError>; + + /// Read one owner's OWN secret at a specific env scope (no inheritance). + /// Backs the group-gated human value-read and the apply email path. + async fn get( + &self, + owner: SecretOwner, + env_scope: &str, + name: &str, ) -> Result, SecretsRepoError>; - /// Upsert (overwrite if present). `version` is the AES-GCM envelope - /// discriminator from [`StoredSecret::version`]. + /// Upsert (overwrite if present) one `(owner, env_scope, name)` row. + /// `version` is the AES-GCM envelope discriminator. async fn set( &self, - app_id: AppId, + owner: SecretOwner, + env_scope: &str, name: &str, encrypted_value: &[u8], nonce: &[u8], version: i16, ) -> Result<(), SecretsRepoError>; - /// Delete; returns whether a row was present. - async fn delete(&self, app_id: AppId, name: &str) -> Result; + /// Delete one `(owner, env_scope, name)` row; returns whether a row + /// was present. + async fn delete( + &self, + owner: SecretOwner, + env_scope: &str, + name: &str, + ) -> Result; - /// Names only — the SDK `list` surface. + /// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK + /// `list` surface. Names are de-duplicated across env scopes. async fn list_names( &self, - app_id: AppId, + owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result; - /// Name + updated_at — the admin `GET` surface. + /// Name + scope + updated_at of an owner's OWN secrets — the admin + /// `GET` surface. async fn list_meta( &self, - app_id: AppId, + owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result; @@ -131,21 +183,98 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result { String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor) } +/// `list_meta` orders by `(name, environment_scope)` — a name can now have +/// several env-scoped rows (group secrets) — so its keyset cursor must carry +/// BOTH columns, else a name whose scopes straddle a page boundary loses its +/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or +/// secret-name char, so it's an unambiguous delimiter). +const CURSOR_SEP: char = '\u{1f}'; + +fn encode_meta_cursor(name: &str, scope: &str) -> String { + URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes()) +} + +fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> { + let bytes = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| SecretsRepoError::InvalidCursor)?; + let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?; + s.split_once(CURSOR_SEP) + .map(|(n, sc)| (n.to_string(), sc.to_string())) + .ok_or(SecretsRepoError::InvalidCursor) +} + +/// `(owner_column, owner_uuid)` for binding an owner into a query. +fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) { + match owner { + SecretOwner::App(a) => ("app_id", a.into_inner()), + SecretOwner::Group(g) => ("group_id", g.into_inner()), + } +} + #[async_trait] impl SecretsRepo for PostgresSecretsRepo { - async fn get( + async fn resolve( &self, app_id: AppId, name: &str, - ) -> Result, SecretsRepoError> { - let row: Option<(Vec, Vec, i16)> = sqlx::query_as( - "SELECT encrypted_value, nonce, version FROM secrets \ - WHERE app_id = $1 AND name = $2", + ) -> Result, SecretsRepoError> { + // Reuse the shared chain-walk ($1 = app_id), join secrets by name, + // env-filter, and take the nearest level — `@E` beating `*` within a + // level via the secondary sort key. One row out, or none. + let sql = format!( + "{CHAIN_LEVELS_CTE} \ + SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \ + COALESCE(s.app_id, s.group_id) AS owner_id, \ + s.encrypted_value, s.nonce, s.version \ + FROM chain c \ + JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \ + WHERE s.name = $2 \ + AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \ + ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \ + LIMIT 1" + ); + let row: Option<(String, Uuid, Vec, Vec, i16)> = sqlx::query_as(&sql) + .bind(app_id.into_inner()) + .bind(name) + .fetch_optional(&self.pool) + .await?; + Ok( + row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| { + let owner = if owner_kind == "app" { + SecretOwner::App(AppId::from(owner_id)) + } else { + SecretOwner::Group(GroupId::from(owner_id)) + }; + ResolvedSecret { + owner, + stored: StoredSecret { + encrypted_value, + nonce, + version, + }, + } + }), ) - .bind(app_id.into_inner()) - .bind(name) - .fetch_optional(&self.pool) - .await?; + } + + async fn get( + &self, + owner: SecretOwner, + env_scope: &str, + name: &str, + ) -> Result, SecretsRepoError> { + let (col, id) = owner_bind(owner); + let sql = format!( + "SELECT encrypted_value, nonce, version FROM secrets \ + WHERE {col} = $1 AND environment_scope = $2 AND name = $3" + ); + let row: Option<(Vec, Vec, i16)> = sqlx::query_as(&sql) + .bind(id) + .bind(env_scope) + .bind(name) + .fetch_optional(&self.pool) + .await?; Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret { encrypted_value, nonce, @@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo { async fn set( &self, - app_id: AppId, + owner: SecretOwner, + env_scope: &str, name: &str, encrypted_value: &[u8], nonce: &[u8], version: i16, ) -> Result<(), SecretsRepoError> { - sqlx::query( - "INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \ - VALUES ($1, $2, $3, $4, $5) \ - ON CONFLICT (app_id, name) DO UPDATE \ + // Owner-kind-specific SQL: write only the owner's nullable column and + // restate the partial-unique predicate as the ON CONFLICT arbiter. + let (col, id) = owner_bind(owner); + let predicate = match owner { + SecretOwner::App(_) => "app_id IS NOT NULL", + SecretOwner::Group(_) => "group_id IS NOT NULL", + }; + let sql = format!( + "INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \ SET encrypted_value = EXCLUDED.encrypted_value, \ nonce = EXCLUDED.nonce, \ version = EXCLUDED.version, \ - updated_at = NOW()", - ) - .bind(app_id.into_inner()) - .bind(name) - .bind(encrypted_value) - .bind(nonce) - .bind(version) - .execute(&self.pool) - .await?; + updated_at = NOW()" + ); + sqlx::query(&sql) + .bind(id) + .bind(env_scope) + .bind(name) + .bind(encrypted_value) + .bind(nonce) + .bind(version) + .execute(&self.pool) + .await?; Ok(()) } - async fn delete(&self, app_id: AppId, name: &str) -> Result { - let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2") - .bind(app_id.into_inner()) + async fn delete( + &self, + owner: SecretOwner, + env_scope: &str, + name: &str, + ) -> Result { + let (col, id) = owner_bind(owner); + let sql = format!( + "DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3" + ); + let res = sqlx::query(&sql) + .bind(id) + .bind(env_scope) .bind(name) .execute(&self.pool) .await?; @@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo { async fn list_names( &self, - app_id: AppId, + owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result { @@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo { None => None, }; let take = i64::from(limit) + 1; - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT name FROM secrets \ - WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \ - ORDER BY name ASC LIMIT $3", - ) - .bind(app_id.into_inner()) - .bind(last_name.as_deref()) - .bind(take) - .fetch_all(&self.pool) - .await?; + let (col, id) = owner_bind(owner); + // DISTINCT collapses a name that exists at several env scopes into + // one entry — the SDK `list` is a name catalogue, not per-scope. + let sql = format!( + "SELECT DISTINCT name FROM secrets \ + WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \ + ORDER BY name ASC LIMIT $3" + ); + let rows: Vec<(String,)> = sqlx::query_as(&sql) + .bind(id) + .bind(last_name.as_deref()) + .bind(take) + .fetch_all(&self.pool) + .await?; let mut names: Vec = rows.into_iter().map(|(n,)| n).collect(); let next_cursor = if names.len() > limit as usize { @@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo { async fn list_meta( &self, - app_id: AppId, + owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result { let limit = clamp_limit(limit); - let last_name = match cursor { - Some(c) => Some(decode_cursor(c)?), + // Composite keyset on (name, environment_scope) — see encode_meta_cursor. + let last = match cursor { + Some(c) => Some(decode_meta_cursor(c)?), None => None, }; + let (last_name, last_scope) = match &last { + Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())), + None => (None, None), + }; let take = i64::from(limit) + 1; - let rows: Vec<(String, DateTime)> = sqlx::query_as( - "SELECT name, updated_at FROM secrets \ - WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \ - ORDER BY name ASC LIMIT $3", - ) - .bind(app_id.into_inner()) - .bind(last_name.as_deref()) - .bind(take) - .fetch_all(&self.pool) - .await?; + let (col, id) = owner_bind(owner); + let sql = format!( + "SELECT name, environment_scope, updated_at FROM secrets \ + WHERE {col} = $1 \ + AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \ + ORDER BY name ASC, environment_scope ASC LIMIT $4" + ); + let rows: Vec<(String, String, DateTime)> = sqlx::query_as(&sql) + .bind(id) + .bind(last_name) + .bind(last_scope) + .bind(take) + .fetch_all(&self.pool) + .await?; let mut items: Vec = rows .into_iter() - .map(|(name, updated_at)| SecretMeta { name, updated_at }) + .map(|(name, environment_scope, updated_at)| SecretMeta { + name, + environment_scope, + updated_at, + }) .collect(); let next_cursor = if items.len() > limit as usize { items.truncate(limit as usize); - items.last().map(|m| encode_cursor(&m.name)) + items + .last() + .map(|m| encode_meta_cursor(&m.name, &m.environment_scope)) } else { None }; diff --git a/crates/manager-core/src/secrets_service.rs b/crates/manager-core/src/secrets_service.rs index 6dd981a..670f13a 100644 --- a/crates/manager-core/src/secrets_service.rs +++ b/crates/manager-core/src/secrets_service.rs @@ -22,22 +22,39 @@ use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{ - crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage, + crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage, SecretsService, }; use crate::authz::{self, AuthzRepo, Capability}; use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret}; +// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both +// the storage CRUD and the AAD). Re-exported here so the historical +// `secrets_service::SecretOwner` path stays stable. +pub use crate::secrets_repo::SecretOwner; + /// Current AES-GCM envelope version for the per-app secret store. /// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1. pub const SECRET_ENVELOPE_V1: i16 = 1; -/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a -/// cross-row swap (e.g. moving one app's ciphertext under another -/// app's `(app_id, name)` slot) fails decryption. -fn secret_aad(app_id: AppId, name: &str) -> Vec { - format!("secret:{app_id}:{name}").into_bytes() +/// The env scope under which an app's OWN secrets are stored. App secrets +/// are env-agnostic — only group secrets carry a concrete environment. +const APP_SECRET_SCOPE: &str = "*"; + +/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row +/// swap (moving one owner's ciphertext under another's slot) fails +/// decryption. The **app** form is byte-identical to the pre-Phase-3 +/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting +/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}` +/// namespace (the `group:` infix keeps app and group AAD disjoint even if a +/// group UUID happened to equal an app UUID). +fn secret_aad(owner: SecretOwner, name: &str) -> Vec { + match owner { + SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"), + SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"), + } + .into_bytes() } /// Default per-secret plaintext cap (64 KB). Override with @@ -96,7 +113,7 @@ impl Default for SecretsConfig { /// failure (should not happen for a `serde_json::Value`). pub fn seal( master_key: &MasterKey, - app_id: AppId, + owner: SecretOwner, name: &str, value: &serde_json::Value, max_value_bytes: usize, @@ -109,7 +126,7 @@ pub fn seal( actual: plaintext.len(), }); } - let aad = secret_aad(app_id, name); + let aad = secret_aad(owner, name); let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes()); Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1)) } @@ -125,7 +142,7 @@ pub fn seal( /// [`SecretsError::Corrupted`] when decryption or JSON decoding fails. pub fn open( master_key: &MasterKey, - app_id: AppId, + owner: SecretOwner, name: &str, stored: &StoredSecret, ) -> Result { @@ -136,7 +153,7 @@ pub fn open( master_key.as_bytes(), ), SECRET_ENVELOPE_V1 => { - let aad = secret_aad(app_id, name); + let aad = secret_aad(owner, name); crypto::decrypt_with_aad( &stored.encrypted_value, &stored.nonce, @@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl { ) -> Result, SecretsError> { validate_secret_name(name)?; self.check_read(cx).await?; - let Some(stored) = self.repo.get(cx.app_id, name).await? else { + // Inherited resolution: the app's own secret, else the nearest + // ancestor group's, env-filtered. `resolve` anchors the walk to + // `cx.app_id`, so an app can only ever read its own + its ancestors' + // secrets — the cross-app isolation boundary. The winning owner comes + // back with the row so we decrypt under the AAD it was sealed with. + let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else { return Ok(None); }; - match open(&self.master_key, cx.app_id, name, &stored) { + match open(&self.master_key, resolved.owner, name, &resolved.stored) { Ok(value) => Ok(Some(value)), Err(e) => { // A decrypt failure is operationally significant — surface @@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl { ) -> Result<(), SecretsError> { validate_secret_name(name)?; self.check_write(cx).await?; - let (ciphertext, nonce, version) = seal( - &self.master_key, - cx.app_id, - name, - &value, - self.max_value_bytes, - )?; + let owner = SecretOwner::App(cx.app_id); + let (ciphertext, nonce, version) = + seal(&self.master_key, owner, name, &value, self.max_value_bytes)?; self.repo - .set(cx.app_id, name, &ciphertext, &nonce, version) + .set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version) .await?; Ok(()) } @@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl { async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result { validate_secret_name(name)?; self.check_write(cx).await?; - Ok(self.repo.delete(cx.app_id, name).await?) + Ok(self + .repo + .delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name) + .await?) } async fn list( @@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl { limit: u32, ) -> Result { self.check_read(cx).await?; - let page = self.repo.list_names(cx.app_id, cursor, limit).await?; + let page = self + .repo + .list_names(SecretOwner::App(cx.app_id), cursor, limit) + .await?; Ok(SecretsListPage { names: page.names, next_cursor: page.next_cursor, @@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl { mod tests { use super::*; use crate::authz::{AuthzError, AuthzRepo}; - use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage}; + use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage}; use async_trait::async_trait; use picloud_shared::{ - AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, - UserId, + AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId, + ScriptId, UserId, }; use std::collections::BTreeMap; use tokio::sync::Mutex; + /// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner + /// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never + /// collide. These unit tests exercise the app surface (scope `*`); the + /// chain-walk `resolve` is journey-tested against real Postgres, so here + /// it degrades to a plain app-own `*` lookup. #[derive(Default)] struct InMemorySecretsRepo { - data: Mutex>, + data: Mutex>, + } + + fn owner_key(owner: SecretOwner) -> String { + match owner { + SecretOwner::App(a) => format!("app:{a}"), + SecretOwner::Group(g) => format!("group:{g}"), + } } #[async_trait] impl SecretsRepo for InMemorySecretsRepo { - async fn get( + async fn resolve( &self, app_id: AppId, name: &str, + ) -> Result, SecretsRepoError> { + let owner = SecretOwner::App(app_id); + Ok(self + .data + .lock() + .await + .get(&( + owner_key(owner), + APP_SECRET_SCOPE.to_string(), + name.to_string(), + )) + .cloned() + .map(|stored| ResolvedSecret { owner, stored })) + } + async fn get( + &self, + owner: SecretOwner, + env_scope: &str, + name: &str, ) -> Result, SecretsRepoError> { Ok(self .data .lock() .await - .get(&(app_id, name.to_string())) + .get(&(owner_key(owner), env_scope.to_string(), name.to_string())) .cloned()) } async fn set( &self, - app_id: AppId, + owner: SecretOwner, + env_scope: &str, name: &str, encrypted_value: &[u8], nonce: &[u8], version: i16, ) -> Result<(), SecretsRepoError> { self.data.lock().await.insert( - (app_id, name.to_string()), + (owner_key(owner), env_scope.to_string(), name.to_string()), StoredSecret { encrypted_value: encrypted_value.to_vec(), nonce: nonce.to_vec(), @@ -374,29 +430,36 @@ mod tests { ); Ok(()) } - async fn delete(&self, app_id: AppId, name: &str) -> Result { + async fn delete( + &self, + owner: SecretOwner, + env_scope: &str, + name: &str, + ) -> Result { Ok(self .data .lock() .await - .remove(&(app_id, name.to_string())) + .remove(&(owner_key(owner), env_scope.to_string(), name.to_string())) .is_some()) } async fn list_names( &self, - app_id: AppId, + owner: SecretOwner, cursor: Option<&str>, limit: u32, ) -> Result { let data = self.data.lock().await; + let ok = owner_key(owner); let last = cursor.map(std::string::ToString::to_string); let mut names: Vec = data .iter() - .filter(|((a, _), _)| *a == app_id) - .map(|((_, n), _)| n.clone()) + .filter(|((o, _, _), _)| *o == ok) + .map(|((_, _, n), _)| n.clone()) .filter(|n| last.as_ref().is_none_or(|l| n > l)) .collect(); names.sort(); + names.dedup(); let take = (limit as usize).max(1); let next_cursor = if names.len() > take { names.truncate(take); @@ -408,7 +471,7 @@ mod tests { } async fn list_meta( &self, - _app_id: AppId, + _owner: SecretOwner, _cursor: Option<&str>, _limit: u32, ) -> Result { @@ -635,7 +698,11 @@ mod tests { repo.data .lock() .await - .get_mut(&(app, "k".to_string())) + .get_mut(&( + owner_key(SecretOwner::App(app)), + "*".to_string(), + "k".to_string(), + )) .unwrap() .encrypted_value[0] ^= 0xff; let err = s.get(&anon_cx(app), "k").await.unwrap_err(); @@ -666,10 +733,21 @@ mod tests { .data .lock() .await - .get(&(a, "k".to_string())) + .get(&( + owner_key(SecretOwner::App(a)), + "*".to_string(), + "k".to_string(), + )) .cloned() .unwrap(); - repo.data.lock().await.insert((b, "k".to_string()), stolen); + repo.data.lock().await.insert( + ( + owner_key(SecretOwner::App(b)), + "*".to_string(), + "k".to_string(), + ), + stolen, + ); let err = s.get(&anon_cx(b), "k").await.unwrap_err(); assert!( matches!(err, SecretsError::Corrupted), @@ -677,6 +755,55 @@ mod tests { ); } + #[tokio::test] + async fn aad_distinguishes_app_and_group_owner() { + // Phase 3: app and group secrets live in disjoint AAD namespaces + // (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed + // for a group must not open as an app secret (and vice-versa), + // even if the raw UUIDs were equal — the `group:` infix separates + // them. Exercise the free seal/open functions directly. + let k = key(); + let app = AppId::new(); + let group = GroupId::new(); + let value = serde_json::json!("shared-config"); + + // Seal under the GROUP owner. + let (ct, nonce, version) = + seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap(); + let stored = StoredSecret { + encrypted_value: ct, + nonce: nonce.to_vec(), + version, + }; + // Opens fine as the same group owner. + assert_eq!( + open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(), + value + ); + // Fails as an app owner (AAD mismatch) — even reusing the UUID. + let app_from_group = AppId::from(group.into_inner()); + assert!(matches!( + open(&k, SecretOwner::App(app_from_group), "db_url", &stored), + Err(SecretsError::Corrupted) + )); + // And the symmetric direction: an app-sealed row won't open as a group. + let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap(); + let stored2 = StoredSecret { + encrypted_value: ct2, + nonce: nonce2.to_vec(), + version: v2, + }; + assert!(matches!( + open( + &k, + SecretOwner::Group(GroupId::from(app.into_inner())), + "db_url", + &stored2 + ), + Err(SecretsError::Corrupted) + )); + } + #[tokio::test] async fn aad_blocks_cross_name_ciphertext_swap() { // Same app, different name → AAD mismatch. @@ -695,13 +822,21 @@ mod tests { .data .lock() .await - .get(&(a, "real".to_string())) + .get(&( + owner_key(SecretOwner::App(a)), + "*".to_string(), + "real".to_string(), + )) .cloned() .unwrap(); - repo.data - .lock() - .await - .insert((a, "renamed".to_string()), stolen); + repo.data.lock().await.insert( + ( + owner_key(SecretOwner::App(a)), + "*".to_string(), + "renamed".to_string(), + ), + stolen, + ); let err = s.get(&anon_cx(a), "renamed").await.unwrap_err(); assert!(matches!(err, SecretsError::Corrupted)); } @@ -726,7 +861,11 @@ mod tests { ) .unwrap(); repo.data.lock().await.insert( - (app, "old".to_string()), + ( + owner_key(SecretOwner::App(app)), + "*".to_string(), + "old".to_string(), + ), StoredSecret { encrypted_value: ct, nonce: nonce.to_vec(), diff --git a/crates/manager-core/src/vars_api.rs b/crates/manager-core/src/vars_api.rs new file mode 100644 index 0000000..0551dfa --- /dev/null +++ b/crates/manager-core/src/vars_api.rs @@ -0,0 +1,410 @@ +//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config +//! `vars` admin surface (write/list side; resolution lives in +//! `config_resolver` + the `vars::` SDK). +//! +//! * `GET /apps/{id}/vars` — list the app's OWN vars. +//! * `PUT /apps/{id}/vars` — set/overwrite one app var. +//! * `DELETE /apps/{id}/vars/{key}` — delete one app var. +//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned. +//! +//! App routes gate on `App{Vars}Read/Write`; group routes on +//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid), +//! THEN `authz::require` binds the capability to the resolved owner id — +//! never to a caller-controlled path param. Listing returns the owner's +//! OWN rows only (not the resolved/inherited view). + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::{get, put}; +use axum::{Extension, Router}; +use picloud_shared::{AppId, GroupId, Principal}; +use serde::Deserialize; +use serde_json::json; + +use crate::app_repo::AppRepository; +use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; +use crate::group_repo::GroupRepository; +use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError}; + +#[derive(Clone)] +pub struct VarsApiState { + pub vars: Arc, + pub apps: Arc, + pub groups: Arc, + pub authz: Arc, +} + +pub fn vars_router(state: VarsApiState) -> Router { + Router::new() + .route( + "/apps/{id_or_slug}/vars", + get(list_app_vars).put(set_app_var), + ) + .route( + "/apps/{id_or_slug}/vars/{key}", + axum::routing::delete(delete_app_var), + ) + .route( + "/groups/{id_or_slug}/vars", + put(set_group_var).get(list_group_vars), + ) + .route( + "/groups/{id_or_slug}/vars/{key}", + axum::routing::delete(delete_group_var), + ) + .with_state(state) +} + +// ---------------------------------------------------------------------------- +// DTOs +// ---------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct SetVarRequest { + pub key: String, + pub value: serde_json::Value, + /// Environment scope — `*` (env-agnostic, default) or a concrete env + /// name matched against `apps.environment` at resolution time. + #[serde(default)] + pub env: Option, + /// Write a tombstone (suppresses an inherited key) instead of a real + /// value. The body's `value` is ignored for a tombstone. + #[serde(default)] + pub tombstone: bool, +} + +#[derive(Debug, Deserialize)] +pub struct EnvQuery { + #[serde(default)] + pub env: Option, +} + +#[derive(Debug, serde::Serialize)] +struct VarItem { + key: String, + env: String, + value: serde_json::Value, + is_tombstone: bool, + updated_at: chrono::DateTime, +} + +#[derive(Debug, serde::Serialize)] +struct ListVarsResponse { + vars: Vec, +} + +// ---------------------------------------------------------------------------- +// App handlers +// ---------------------------------------------------------------------------- + +async fn list_app_vars( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, VarsApiError> { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppVarsRead(app_id), + ) + .await?; + list(&*s.vars, VarOwner::App(app_id)).await +} + +async fn set_app_var( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppVarsWrite(app_id), + ) + .await?; + set(&*s.vars, VarOwner::App(app_id), input).await +} + +async fn delete_app_var( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, key)): Path<(String, String)>, + Query(q): Query, +) -> Result { + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::AppVarsWrite(app_id), + ) + .await?; + delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await +} + +// ---------------------------------------------------------------------------- +// Group handlers +// ---------------------------------------------------------------------------- + +async fn list_group_vars( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, VarsApiError> { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupVarsRead(group_id), + ) + .await?; + list(&*s.vars, VarOwner::Group(group_id)).await +} + +async fn set_group_var( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupVarsWrite(group_id), + ) + .await?; + set(&*s.vars, VarOwner::Group(group_id), input).await +} + +async fn delete_group_var( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, key)): Path<(String, String)>, + Query(q): Query, +) -> Result { + let group_id = resolve_group(&*s.groups, &id_or_slug).await?; + require( + s.authz.as_ref(), + &principal, + Capability::GroupVarsWrite(group_id), + ) + .await?; + delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await +} + +// ---------------------------------------------------------------------------- +// Shared owner-generic bodies +// ---------------------------------------------------------------------------- + +async fn list( + vars: &dyn VarsRepo, + owner: VarOwner, +) -> Result, VarsApiError> { + let rows = vars.list_for_owner(owner).await?; + Ok(Json(ListVarsResponse { + vars: rows + .into_iter() + .map(|r| VarItem { + key: r.key, + env: r.environment_scope, + value: r.value, + is_tombstone: r.is_tombstone, + updated_at: r.updated_at, + }) + .collect(), + })) +} + +async fn set( + vars: &dyn VarsRepo, + owner: VarOwner, + input: SetVarRequest, +) -> Result { + validate_key(&input.key)?; + let env = input.env.as_deref().unwrap_or("*"); + validate_env_scope(env)?; + // A tombstone carries no meaningful value (the resolver suppresses the + // key regardless); store JSON null so the NOT NULL column is satisfied. + let value = if input.tombstone { + serde_json::Value::Null + } else { + input.value + }; + vars.set(owner, env, &input.key, &value, input.tombstone) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn delete( + vars: &dyn VarsRepo, + owner: VarOwner, + key: &str, + env: Option<&str>, +) -> Result { + let env = env.unwrap_or("*"); + validate_env_scope(env)?; + if !vars.delete(owner, env, key).await? { + return Err(VarsApiError::NotFound); + } + Ok(StatusCode::NO_CONTENT) +} + +// ---------------------------------------------------------------------------- +// Resolution + validation +// ---------------------------------------------------------------------------- + +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) + .await + .map_err(|e| VarsApiError::Backend(e.to_string()))? + .map(|l| l.app.id) + .ok_or(VarsApiError::AppNotFound) +} + +async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result { + let found = if let Ok(uuid) = ident.parse::() { + groups + .get_by_id(uuid.into()) + .await + .map_err(|e| VarsApiError::Backend(e.to_string()))? + } else { + groups + .get_by_slug(ident) + .await + .map_err(|e| VarsApiError::Backend(e.to_string()))? + }; + found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound) +} + +/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the +/// manifest's var names (docs/design §4.3). +fn validate_key(key: &str) -> Result<(), VarsApiError> { + if key.is_empty() || key.len() > 128 { + return Err(VarsApiError::Invalid("key must be 1–128 characters".into())); + } + let mut chars = key.chars(); + let first = chars.next().unwrap(); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(VarsApiError::Invalid( + "key must start with a lowercase letter or digit".into(), + )); + } + if !key + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(VarsApiError::Invalid( + "key may contain only lowercase letters, digits, and hyphens".into(), + )); + } + Ok(()) +} + +/// Env scope is `*` (env-agnostic) or a kebab env name. +fn validate_env_scope(env: &str) -> Result<(), VarsApiError> { + if env == "*" { + return Ok(()); + } + if env.is_empty() || env.len() > 63 { + return Err(VarsApiError::Invalid( + "env must be '*' or 1–63 characters".into(), + )); + } + let mut chars = env.chars(); + let first = chars.next().unwrap(); + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { + return Err(VarsApiError::Invalid( + "env must start with a lowercase letter or digit".into(), + )); + } + if !env + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(VarsApiError::Invalid( + "env may contain only lowercase letters, digits, and hyphens".into(), + )); + } + Ok(()) +} + +// ---------------------------------------------------------------------------- +// Errors +// ---------------------------------------------------------------------------- + +#[derive(Debug, thiserror::Error)] +pub enum VarsApiError { + #[error("app not found")] + AppNotFound, + #[error("group not found")] + GroupNotFound, + #[error("var not found")] + NotFound, + #[error("invalid request: {0}")] + Invalid(String), + #[error("forbidden")] + Forbidden, + #[error("authorization repo error: {0}")] + AuthzRepo(String), + #[error("vars backend: {0}")] + Backend(String), +} + +impl From for VarsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From for VarsApiError { + fn from(e: AuthzError) -> Self { + Self::AuthzRepo(e.to_string()) + } +} + +impl From for VarsApiError { + fn from(e: VarsRepoError) -> Self { + match e { + VarsRepoError::Db(e) => Self::Backend(e.to_string()), + } + } +} + +impl IntoResponse for VarsApiError { + fn into_response(self) -> Response { + let (status, body) = match &self { + Self::AppNotFound | Self::GroupNotFound | Self::NotFound => { + (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) + } + Self::Invalid(_) => ( + StatusCode::UNPROCESSABLE_ENTITY, + json!({ "error": self.to_string() }), + ), + Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), + Self::AuthzRepo(e) => { + tracing::error!(error = %e, "vars admin authz repo error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + Self::Backend(e) => { + tracing::error!(error = %e, "vars admin backend error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "internal error" }), + ) + } + }; + (status, Json(body)).into_response() + } +} diff --git a/crates/manager-core/src/vars_repo.rs b/crates/manager-core/src/vars_repo.rs new file mode 100644 index 0000000..db058e0 --- /dev/null +++ b/crates/manager-core/src/vars_repo.rs @@ -0,0 +1,210 @@ +//! Low-level Postgres CRUD over `vars` — the write/admin side of the +//! Phase-3 config layer (the read/resolution side lives in +//! `config_resolver`). Storage-only: it upserts and lists an owner's OWN +//! rows. Authorization, env-scope validation, and value encoding live one +//! layer up in `vars_api`. +//! +//! A var is owned by exactly one group OR one app (the migration's +//! `vars_owner_exactly_one` CHECK). Because the owner is split across two +//! nullable columns (`group_id`, `app_id`), the upsert writes +//! owner-kind-specific SQL with the matching partial-unique conflict +//! target. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::{AppId, GroupId}; +use serde_json::Value as JsonValue; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum VarsRepoError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +/// Which side of the polymorphic owner a var hangs off. The repo chooses +/// the conflict target (group vs app partial-unique index) from this. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VarOwner { + Group(GroupId), + App(AppId), +} + +/// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs +/// the admin list surface. +#[derive(Debug, Clone)] +pub struct VarRow { + pub environment_scope: String, + pub key: String, + pub value: JsonValue, + pub is_tombstone: bool, + pub updated_at: DateTime, +} + +/// Repo surface. A trait so service/handler tests can substitute an +/// in-memory backing without Postgres. +#[async_trait] +pub trait VarsRepo: Send + Sync { + /// Upsert one (owner, env_scope, key) row. + async fn set( + &self, + owner: VarOwner, + env_scope: &str, + key: &str, + value: &JsonValue, + is_tombstone: bool, + ) -> Result<(), VarsRepoError>; + + /// Delete one (owner, env_scope, key) row; returns whether a row was + /// present. + async fn delete( + &self, + owner: VarOwner, + env_scope: &str, + key: &str, + ) -> Result; + + /// The owner's OWN rows only (NOT resolved/inherited), ordered by + /// (key, environment_scope). + async fn list_for_owner(&self, owner: VarOwner) -> Result, VarsRepoError>; +} + +pub struct PostgresVarsRepo { + pool: PgPool, +} + +impl PostgresVarsRepo { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl VarsRepo for PostgresVarsRepo { + async fn set( + &self, + owner: VarOwner, + env_scope: &str, + key: &str, + value: &JsonValue, + is_tombstone: bool, + ) -> Result<(), VarsRepoError> { + // Owner-kind-specific SQL: only one of the two nullable owner + // columns is written, and the conflict target is the matching + // partial-unique index. + match owner { + VarOwner::Group(g) => { + sqlx::query( + // The conflict target is a PARTIAL unique index, so the + // index predicate (`WHERE group_id IS NOT NULL`) must be + // restated for Postgres to infer the arbiter. + "INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (group_id, environment_scope, key) \ + WHERE group_id IS NOT NULL DO UPDATE \ + SET value = EXCLUDED.value, \ + is_tombstone = EXCLUDED.is_tombstone, \ + updated_at = NOW()", + ) + .bind(g.into_inner()) + .bind(env_scope) + .bind(key) + .bind(value) + .bind(is_tombstone) + .execute(&self.pool) + .await?; + } + VarOwner::App(a) => { + sqlx::query( + // Partial-index conflict target — restate the predicate. + "INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \ + VALUES ($1, $2, $3, $4, $5) \ + ON CONFLICT (app_id, environment_scope, key) \ + WHERE app_id IS NOT NULL DO UPDATE \ + SET value = EXCLUDED.value, \ + is_tombstone = EXCLUDED.is_tombstone, \ + updated_at = NOW()", + ) + .bind(a.into_inner()) + .bind(env_scope) + .bind(key) + .bind(value) + .bind(is_tombstone) + .execute(&self.pool) + .await?; + } + } + Ok(()) + } + + async fn delete( + &self, + owner: VarOwner, + env_scope: &str, + key: &str, + ) -> Result { + let res = match owner { + VarOwner::Group(g) => { + sqlx::query( + "DELETE FROM vars \ + WHERE group_id = $1 AND environment_scope = $2 AND key = $3", + ) + .bind(g.into_inner()) + .bind(env_scope) + .bind(key) + .execute(&self.pool) + .await? + } + VarOwner::App(a) => { + sqlx::query( + "DELETE FROM vars \ + WHERE app_id = $1 AND environment_scope = $2 AND key = $3", + ) + .bind(a.into_inner()) + .bind(env_scope) + .bind(key) + .execute(&self.pool) + .await? + } + }; + Ok(res.rows_affected() > 0) + } + + async fn list_for_owner(&self, owner: VarOwner) -> Result, VarsRepoError> { + let rows: Vec<(String, String, JsonValue, bool, DateTime)> = match owner { + VarOwner::Group(g) => { + sqlx::query_as( + "SELECT environment_scope, key, value, is_tombstone, updated_at \ + FROM vars WHERE group_id = $1 \ + ORDER BY key ASC, environment_scope ASC", + ) + .bind(g.into_inner()) + .fetch_all(&self.pool) + .await? + } + VarOwner::App(a) => { + sqlx::query_as( + "SELECT environment_scope, key, value, is_tombstone, updated_at \ + FROM vars WHERE app_id = $1 \ + ORDER BY key ASC, environment_scope ASC", + ) + .bind(a.into_inner()) + .fetch_all(&self.pool) + .await? + } + }; + Ok(rows + .into_iter() + .map( + |(environment_scope, key, value, is_tombstone, updated_at)| VarRow { + environment_scope, + key, + value, + is_tombstone, + updated_at, + }, + ) + .collect()) + } +} diff --git a/crates/manager-core/src/vars_service.rs b/crates/manager-core/src/vars_service.rs new file mode 100644 index 0000000..3a2e524 --- /dev/null +++ b/crates/manager-core/src/vars_service.rs @@ -0,0 +1,63 @@ +//! `VarsService` — the runtime read path for group-inherited config. +//! +//! Resolves the calling app's config (own rows + inherited group rows, +//! env-filtered, proximity-first; see `config_resolver`) and exposes it to +//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts; +//! writes go through the admin API. Like every SDK service, it derives the +//! app from `cx.app_id` — never a script argument — so cross-app isolation +//! holds. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use picloud_shared::{SdkCallCx, VarsError, VarsService}; +use serde_json::Value; +use sqlx::PgPool; + +use crate::authz::{self, AuthzRepo, Capability}; +use crate::config_resolver::{fetch_var_candidates, resolve}; + +pub struct VarsServiceImpl { + pool: PgPool, + authz: Arc, +} + +impl VarsServiceImpl { + #[must_use] + pub fn new(pool: PgPool, authz: Arc) -> Self { + Self { pool, authz } + } + + /// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts + /// (`principal: None`) read freely under script-as-gate semantics. + async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> { + if let Some(ref principal) = cx.principal { + authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id)) + .await + .map_err(|_| VarsError::Forbidden)?; + } + Ok(()) + } + + async fn resolved(&self, cx: &SdkCallCx) -> Result, VarsError> { + let candidates = fetch_var_candidates(&self.pool, cx.app_id) + .await + .map_err(|e| VarsError::Backend(e.to_string()))?; + let (values, _provenance) = resolve(candidates); + Ok(values) + } +} + +#[async_trait] +impl VarsService for VarsServiceImpl { + async fn get(&self, cx: &SdkCallCx, key: &str) -> Result, VarsError> { + self.check_read(cx).await?; + Ok(self.resolved(cx).await?.remove(key)) + } + + async fn all(&self, cx: &SdkCallCx) -> Result, VarsError> { + self.check_read(cx).await?; + self.resolved(cx).await + } +} diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index e53f9a4..3652765 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -657,39 +657,138 @@ impl Client { decode_status(resp).await } - /// `GET /api/v1/admin/apps/{id}/secrets` - pub async fn secrets_list(&self, app: &str) -> Result { - let app = seg(app); + /// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names + + /// last-modified for the owner. Values never travel on this path. `env` + /// is only meaningful for group owners (app secrets are env-agnostic). + pub async fn secrets_list( + &self, + owner: VarOwnerArg<'_>, + env: Option<&str>, + ) -> Result { + let mut path = format!("{}/secrets", owner.base_path()); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::GET, &path).send().await?; + decode(resp).await + } + + /// `POST /api/v1/admin/{apps,groups}/{id}/secrets`. `env` rides in the + /// body and is only honored by group owners. + pub async fn secrets_set( + &self, + owner: VarOwnerArg<'_>, + name: &str, + value: serde_json::Value, + env: Option<&str>, + ) -> Result<()> { + // `name` travels in the JSON body, not the path. + let mut body = serde_json::json!({ "name": name, "value": value }); + if let Some(env) = env { + body["env"] = serde_json::Value::String(env.to_string()); + } let resp = self - .request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets")) + .request(Method::POST, &format!("{}/secrets", owner.base_path())) + .json(&body) + .send() + .await?; + decode_status(resp).await + } + + /// `DELETE /api/v1/admin/{apps,groups}/{id}/secrets/{name}` + pub async fn secrets_delete( + &self, + owner: VarOwnerArg<'_>, + name: &str, + env: Option<&str>, + ) -> Result<()> { + let mut path = format!("{}/secrets/{}", owner.base_path(), seg(name)); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::DELETE, &path).send().await?; + decode_status(resp).await + } + + /// `GET /api/v1/admin/groups/{id}/secrets/{name}/value` — the ONLY path + /// that returns a decrypted secret value. Gated server-side at the owning + /// group; there is no app-secret equivalent by design. + pub async fn group_secret_read_value( + &self, + group: &str, + name: &str, + env: Option<&str>, + ) -> Result { + let (group, name) = (seg(group), seg(name)); + let mut path = format!("/api/v1/admin/groups/{group}/secrets/{name}/value"); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::GET, &path).send().await?; + decode(resp).await + } + + // ---------- vars (Phase 3 config) ---------- + + /// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars + /// (not the resolved/inherited view). + pub async fn vars_list(&self, owner: VarOwnerArg<'_>) -> Result { + let resp = self + .request(Method::GET, &format!("{}/vars", owner.base_path())) .send() .await?; decode(resp).await } - /// `POST /api/v1/admin/apps/{id}/secrets` - pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> { - // `name` travels in the JSON body, not the path — only `app` needs encoding. - let app = seg(app); + /// `PUT /api/v1/admin/{apps,groups}/{id}/vars` + pub async fn vars_set( + &self, + owner: VarOwnerArg<'_>, + key: &str, + value: serde_json::Value, + env: Option<&str>, + tombstone: bool, + ) -> Result<()> { + let mut body = serde_json::json!({ "key": key, "value": value, "tombstone": tombstone }); + if let Some(env) = env { + body["env"] = serde_json::Value::String(env.to_string()); + } let resp = self - .request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets")) - .json(&serde_json::json!({ "name": name, "value": value })) + .request(Method::PUT, &format!("{}/vars", owner.base_path())) + .json(&body) .send() .await?; decode_status(resp).await } - /// `DELETE /api/v1/admin/apps/{id}/secrets/{name}` - pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> { - let (app, name) = (seg(app), seg(name)); + /// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}` + pub async fn vars_delete( + &self, + owner: VarOwnerArg<'_>, + key: &str, + env: Option<&str>, + ) -> Result<()> { + let mut path = format!("{}/vars/{}", owner.base_path(), seg(key)); + if let Some(env) = env { + path.push_str(&format!("?env={}", seg(env))); + } + let resp = self.request(Method::DELETE, &path).send().await?; + decode_status(resp).await + } + + /// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved + /// (group-inherited) vars plus masked secret statuses, each annotated with + /// the owner that won and the layers it merged from (§4.6). + pub async fn config_effective(&self, app: &str) -> Result { + let app = seg(app); let resp = self .request( - Method::DELETE, - &format!("/api/v1/admin/apps/{app}/secrets/{name}"), + Method::GET, + &format!("/api/v1/admin/apps/{app}/config/effective"), ) .send() .await?; - decode_status(resp).await + decode(resp).await } // ---------- domains ---------- @@ -1394,6 +1493,37 @@ pub struct DeadLetterDto { pub resolution: Option, } +/// Which owner a `pic vars` call targets. Selects the `apps` vs `groups` +/// admin path prefix; the identifier travels in the path (encoded). +#[derive(Debug, Clone, Copy)] +pub enum VarOwnerArg<'a> { + App(&'a str), + Group(&'a str), +} + +impl VarOwnerArg<'_> { + fn base_path(&self) -> String { + match self { + Self::App(ident) => format!("/api/v1/admin/apps/{}", seg(ident)), + Self::Group(ident) => format!("/api/v1/admin/groups/{}", seg(ident)), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct VarListDto { + pub vars: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct VarItemDto { + pub key: String, + pub env: String, + pub value: serde_json::Value, + pub is_tombstone: bool, + pub updated_at: DateTime, +} + #[derive(Debug, Deserialize)] pub struct SecretListDto { pub secrets: Vec, @@ -1405,9 +1535,72 @@ pub struct SecretListDto { #[derive(Debug, Deserialize)] pub struct SecretItemDto { pub name: String, + /// Env scope. Only meaningful (and populated) for group owners; the server + /// omits it for app secrets, so default to `*` when absent. + #[serde(default = "default_env")] + pub env: String, pub updated_at: DateTime, } +fn default_env() -> String { + "*".to_string() +} + +/// Plaintext value of a single group secret — the response of the gated +/// `.../secrets/{name}/value` read. The decrypted value is arbitrary JSON. +#[derive(Debug, Deserialize)] +pub struct SecretValueDto { + #[allow(dead_code)] + pub name: String, + #[allow(dead_code)] + pub env: String, + pub value: serde_json::Value, +} + +// --- effective config (`/config/effective`) --- + +/// The owner (app or group) a resolved layer belongs to, with its distance +/// from the app in the inheritance chain (`depth` 0 = the app itself). +#[derive(Debug, Deserialize)] +pub struct EffectiveOwnerDto { + pub kind: String, + #[allow(dead_code)] + pub id: String, + pub depth: u32, +} + +/// One layer a resolved var merged from, deepest-first as the server returns it. +#[derive(Debug, Deserialize)] +pub struct MergedFromDto { + pub depth: u32, + pub scope: String, +} + +#[derive(Debug, Deserialize)] +pub struct EffectiveVarDto { + pub value: serde_json::Value, + pub owner: EffectiveOwnerDto, + pub scope: String, + #[serde(default)] + pub merged_from: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct EffectiveSecretDto { + #[allow(dead_code)] + pub status: String, + pub owner: EffectiveOwnerDto, + pub scope: String, +} + +#[derive(Debug, Deserialize)] +pub struct EffectiveConfigDto { + #[serde(default)] + pub vars: std::collections::BTreeMap, + #[serde(default)] + pub secrets: std::collections::BTreeMap, +} + /// Per-script runtime config the CLI can now set (G3). All optional — an /// unset field is omitted so the server applies its own default (and the /// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides). diff --git a/crates/picloud-cli/src/cmds/config.rs b/crates/picloud-cli/src/cmds/config.rs index b8da11f..d92cc36 100644 --- a/crates/picloud-cli/src/cmds/config.rs +++ b/crates/picloud-cli/src/cmds/config.rs @@ -1,26 +1,38 @@ //! `pic config --effective` — read-only view of an app's resolved //! configuration, with secret values masked (§4.6). //! -//! Single-app today: "config" means the app's secrets, cross-referenced -//! against the manifest so an operator can see at a glance which declared -//! secrets are still unset and which live secrets aren't declared. Values are -//! never fetched or shown — only `` / ``. The command is shaped to -//! grow an `--explain` mode and inherited `vars` once groups/vars land (the -//! Phase-3 multi-level resolution). +//! Two sections: +//! * `vars` — the resolved (group-inherited) config vars, each `key = value` +//! annotated with the owner that won (kind + depth) and its scope. Pass +//! `--explain` to also dump each var's `merged_from` provenance — the +//! ordered (depth, scope) layers that fed the resolution. +//! * `secrets` — masked statuses, cross-referenced against the manifest so an +//! operator can see which declared secrets are still unset and which live +//! secrets aren't declared. Values are never fetched or shown here; the +//! server reports only `` / `` plus the owning layer. +//! +//! The vars + masked-secret owner info comes from the `/config/effective` +//! endpoint; the manifest is only used to flag `declared`/`unset` drift. use std::collections::BTreeSet; use std::path::Path; use anyhow::{bail, Result}; -use crate::client::Client; +use crate::client::{Client, EffectiveOwnerDto}; use crate::config; use crate::manifest::Manifest; use crate::output::{OutputMode, Table}; +/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`). +fn owner_label(owner: &EffectiveOwnerDto) -> String { + format!("{}@{}", owner.kind, owner.depth) +} + pub async fn run( manifest_path: &Path, effective: bool, + explain: bool, env: Option<&str>, mode: OutputMode, ) -> Result<()> { @@ -34,24 +46,54 @@ pub async fn run( // base slug — when an overlay re-points slug/secrets. let manifest = Manifest::load_with_env(manifest_path, env)?; - let declared: BTreeSet = manifest.secrets.names.iter().cloned().collect(); - let on_server: BTreeSet = client - .secrets_list(&manifest.app.slug) - .await? - .secrets - .into_iter() - .map(|s| s.name) - .collect(); + let eff = client.config_effective(&manifest.app.slug).await?; - let mut table = Table::new(["secret", "value", "status"]); + // --- vars: the resolved view, with winning owner + provenance. --- + let mut vars_table = Table::new(["key", "value", "owner", "scope"]); + for (key, var) in &eff.vars { + vars_table.row([ + key.clone(), + var.value.to_string(), + owner_label(&var.owner), + var.scope.clone(), + ]); + } + vars_table.print(mode); + + if explain { + // Provenance: the (depth, scope) layers each resolved key merged from. + let mut prov = Table::new(["key", "depth", "scope"]); + for (key, var) in &eff.vars { + for layer in &var.merged_from { + prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]); + } + } + prov.print(mode); + } + + // --- secrets: masked status, folding manifest drift + owning layer. --- + let declared: BTreeSet = manifest.secrets.names.iter().cloned().collect(); + let on_server: BTreeSet = eff.secrets.keys().cloned().collect(); + + let mut table = Table::new(["secret", "value", "status", "owner", "scope"]); for name in declared.union(&on_server) { let (value, status) = match (declared.contains(name), on_server.contains(name)) { (true, true) => ("", "managed"), - (true, false) => ("", "declared, not pushed — `pic secret set`"), + (true, false) => ("", "declared, not pushed — `pic secrets set`"), (false, true) => ("", "on server, not in manifest"), (false, false) => unreachable!("name came from one of the two sets"), }; - table.row([name.clone(), value.to_string(), status.to_string()]); + let (owner, scope) = match eff.secrets.get(name) { + Some(s) => (owner_label(&s.owner), s.scope.clone()), + None => ("-".to_string(), "-".to_string()), + }; + table.row([ + name.clone(), + value.to_string(), + status.to_string(), + owner, + scope, + ]); } table.print(mode); Ok(()) diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 8725727..b4d4629 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -22,4 +22,5 @@ pub mod secrets; pub mod topics; pub mod triggers; pub mod users; +pub mod vars; pub mod whoami; diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 7dd1b11..ec67535 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId}; use serde::Deserialize; -use crate::client::Client; +use crate::client::{Client, VarOwnerArg}; use crate::config; use crate::manifest::{ CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp, @@ -45,7 +45,10 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> let app = client.apps_get(app_ident).await?; let scripts = client.scripts_list_by_app(app_ident).await?; let triggers = client.triggers_list(app_ident).await?.triggers; - let secrets = client.secrets_list(app_ident).await?.secrets; + let secrets = client + .secrets_list(VarOwnerArg::App(app_ident), None) + .await? + .secrets; let name_by_id: HashMap = scripts.iter().map(|s| (s.id, s.name.clone())).collect(); diff --git a/crates/picloud-cli/src/cmds/secrets.rs b/crates/picloud-cli/src/cmds/secrets.rs index 57b099d..b6129b2 100644 --- a/crates/picloud-cli/src/cmds/secrets.rs +++ b/crates/picloud-cli/src/cmds/secrets.rs @@ -1,31 +1,56 @@ -//! `pic secrets` subcommands: `ls`, `set`, `rm`. +//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets. //! -//! Set reads the secret value from stdin (the only safe channel — -//! inline values would leak into shell history). The value is sent -//! as a JSON string; pass `--json` to interpret stdin as raw JSON -//! (numbers, maps, …) instead. +//! Exactly one of `--group` / `--app` selects the owner (mirroring +//! `pic vars`). Set reads the secret value from stdin (the only safe +//! channel — inline values would leak into shell history). The value is +//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON +//! (numbers, maps, …) instead. `--env` is only meaningful for group +//! owners (app secrets are env-agnostic). use std::io::Read; use anyhow::{anyhow, Context, Result}; -use crate::client::Client; +use crate::client::{Client, VarOwnerArg}; use crate::config; use crate::output::{OutputMode, Table}; -pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { +/// Resolve the `--group`/`--app` pair into exactly one owner. +fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result> { + match (group, app) { + (Some(g), None) => Ok(VarOwnerArg::Group(g)), + (None, Some(a)) => Ok(VarOwnerArg::App(a)), + (Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")), + (None, None) => Err(anyhow!("pass one of --group / --app")), + } +} + +pub async fn ls( + group: Option<&str>, + app: Option<&str>, + env: Option<&str>, + mode: OutputMode, +) -> Result<()> { + let owner = owner(group, app)?; let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let resp = client.secrets_list(app).await?; - let mut table = Table::new(["name", "updated_at"]); + let resp = client.secrets_list(owner, env).await?; + let mut table = Table::new(["name", "env", "updated_at"]); for s in resp.secrets { - table.row([s.name, s.updated_at.to_rfc3339()]); + table.row([s.name, s.env, s.updated_at.to_rfc3339()]); } table.print(mode); Ok(()) } -pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> { +pub async fn set( + group: Option<&str>, + app: Option<&str>, + name: &str, + env: Option<&str>, + as_json: bool, +) -> Result<()> { + let owner = owner(group, app)?; let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let mut buf = String::new(); @@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> { } else { serde_json::Value::String(trimmed.to_string()) }; - client.secrets_set(app, name, value).await?; + client.secrets_set(owner, name, value, env).await?; println!("Set secret {name}"); Ok(()) } -pub async fn rm(app: &str, name: &str) -> Result<()> { +pub async fn rm( + group: Option<&str>, + app: Option<&str>, + name: &str, + env: Option<&str>, +) -> Result<()> { + let owner = owner(group, app)?; let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - client.secrets_delete(app, name).await?; + client.secrets_delete(owner, name, env).await?; println!("Deleted secret {name}"); Ok(()) } + +/// `pic secrets read --group ` — fetch and print a secret's +/// PLAINTEXT value. This is the ONLY command that reveals a secret value, +/// and it is gated server-side at the owning group (there is no app-secret +/// equivalent). String values print raw; JSON values print pretty. +pub async fn read(group: &str, name: &str, env: Option<&str>) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let resp = client.group_secret_read_value(group, name, env).await?; + match resp.value { + serde_json::Value::String(s) => println!("{s}"), + other => println!("{}", serde_json::to_string_pretty(&other)?), + } + Ok(()) +} diff --git a/crates/picloud-cli/src/cmds/vars.rs b/crates/picloud-cli/src/cmds/vars.rs new file mode 100644 index 0000000..f6d42d2 --- /dev/null +++ b/crates/picloud-cli/src/cmds/vars.rs @@ -0,0 +1,85 @@ +//! `pic vars ls | set | rm` — manage Phase-3 group/app config vars. +//! +//! Wraps `/api/v1/admin/{apps,groups}/{id}/vars*`. Exactly one of +//! `--group` / `--app` selects the owner. `ls` shows the owner's OWN rows +//! (not the resolved/inherited view). Set values are JSON strings by +//! default; `--json` parses the value as raw JSON. + +use anyhow::{anyhow, Result}; + +use crate::client::{Client, VarOwnerArg}; +use crate::config; +use crate::output::{OutputMode, Table}; + +/// Resolve the `--group`/`--app` pair into exactly one owner. +fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result> { + match (group, app) { + (Some(g), None) => Ok(VarOwnerArg::Group(g)), + (None, Some(a)) => Ok(VarOwnerArg::App(a)), + (Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")), + (None, None) => Err(anyhow!("pass one of --group / --app")), + } +} + +pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> { + let owner = owner(group, app)?; + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let resp = client.vars_list(owner).await?; + let mut table = Table::new(["key", "env", "value", "tombstone", "updated_at"]); + for v in resp.vars { + table.row([ + v.key, + v.env, + v.value.to_string(), + v.is_tombstone.to_string(), + v.updated_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +pub async fn set( + group: Option<&str>, + app: Option<&str>, + key: &str, + value: &str, + env: Option<&str>, + as_json: bool, + tombstone: bool, +) -> Result<()> { + let owner = owner(group, app)?; + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + // A tombstone carries no value (the server stores JSON null + the + // deletion marker); otherwise parse per `--json`. + let parsed = if tombstone { + serde_json::Value::Null + } else if as_json { + serde_json::from_str(value).map_err(|e| anyhow!("parse value as JSON: {e}"))? + } else { + serde_json::Value::String(value.to_string()) + }; + client.vars_set(owner, key, parsed, env, tombstone).await?; + if tombstone { + println!("Set tombstone for {key}"); + } else { + println!("Set var {key}"); + } + Ok(()) +} + +pub async fn rm( + group: Option<&str>, + app: Option<&str>, + key: &str, + env: Option<&str>, +) -> Result<()> { + let owner = owner(group, app)?; + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + client.vars_delete(owner, key, env).await?; + println!("Deleted var {key}"); + Ok(()) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 6ac8a90..35a8076 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -144,6 +144,14 @@ enum Cmd { cmd: MembersCmd, }, + /// Config vars (Phase 3) — set / list / delete group- or app-owned + /// env-scoped vars. Values inherit down the group tree; an app value + /// overrides an inherited one (proximity wins). + Vars { + #[command(subcommand)] + cmd: VarsCmd, + }, + /// Files inspection — list a collection's blobs, download bytes, or /// delete a file. Read + delete only; writes go through scripts. Files { @@ -259,6 +267,10 @@ struct ConfigArgs { /// Show the effective (resolved) config with secrets masked. #[arg(long)] effective: bool, + /// With `--effective`, also print each resolved var's `merged_from` + /// provenance — the ordered (depth, scope) layers it merged from. + #[arg(long)] + explain: bool, /// Resolve against the `picloud..toml` overlay (per-env slug + /// secrets), matching `pic plan --env` / `pic apply --env`. #[arg(long)] @@ -1134,31 +1146,114 @@ enum DeadLettersCmd { #[derive(Subcommand)] enum SecretsCmd { - /// List secret names + last-modified for an app. Values never - /// leave the server. + /// List secret names + last-modified for the owner. Values never + /// leave the server. `--env` filters group secrets (app secrets are + /// env-agnostic; the `env` column shows the scope for groups). Ls { + /// Owning group (slug or id). Mutually exclusive with `--app`. #[arg(long)] - app: String, + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, + /// Environment scope (group owners only). + #[arg(long)] + env: Option, }, /// Set a secret. Reads the value from stdin (the only safe /// channel — inline values would land in shell history). Pipe /// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`. + /// For group owners, `--env` scopes the secret. Set { + /// Owning group (slug or id). Mutually exclusive with `--app`. #[arg(long)] - app: String, + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, name: String, + /// Environment scope (group owners only). + #[arg(long)] + env: Option, /// Treat stdin as raw JSON (numbers, maps, …) instead of a /// string literal. #[arg(long)] json: bool, }, - /// Delete a secret by name. + /// Delete a secret by name (optionally env-scoped for groups). Rm { + /// Owning group (slug or id). Mutually exclusive with `--app`. #[arg(long)] - app: String, + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, name: String, + /// Environment scope (group owners only). + #[arg(long)] + env: Option, + }, + + /// Fetch and print a secret's PLAINTEXT value. This is the ONLY + /// command that reveals a secret value, and it is gated server-side + /// at the owning group — hence `--group` only, no `--app`. + Read { + /// Owning group (slug or id). + #[arg(long)] + group: String, + name: String, + /// Environment scope. + #[arg(long)] + env: Option, + }, +} + +#[derive(Subcommand)] +enum VarsCmd { + /// List the owner's OWN vars (not the resolved/inherited view). + Ls { + /// Owning group (slug or id). Mutually exclusive with `--app`. + #[arg(long)] + group: Option, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, + }, + + /// Set a var. The value is stored as a JSON string by default; pass + /// `--json` to parse it as raw JSON. `--tombstone` writes a deletion + /// marker that suppresses an inherited key. + Set { + key: String, + /// Ignored (but accepted) when `--tombstone` is set. + #[arg(default_value = "")] + value: String, + #[arg(long)] + group: Option, + #[arg(long)] + app: Option, + /// Environment scope (`*` = env-agnostic, the default). + #[arg(long)] + env: Option, + /// Parse `value` as raw JSON instead of a string literal. + #[arg(long)] + json: bool, + /// Write a tombstone (suppress an inherited key) instead of a value. + #[arg(long)] + tombstone: bool, + }, + + /// Delete a var by key (optionally scoped to one environment). + Rm { + key: String, + #[arg(long)] + group: Option, + #[arg(long)] + app: Option, + #[arg(long)] + env: Option, }, } @@ -1238,7 +1333,14 @@ async fn main() -> ExitCode { Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await, Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await, Cmd::Config(args) => { - cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await + cmds::config::run( + &args.file, + args.effective, + args.explain, + args.env.as_deref(), + mode, + ) + .await } Cmd::Init(args) => cmds::init::run( &args.dir, @@ -1741,14 +1843,39 @@ async fn main() -> ExitCode { cmd: DeadLettersCmd::Resolve { app, dl_id, reason }, } => cmds::dead_letters::resolve(&app, &dl_id, &reason).await, Cmd::Secrets { - cmd: SecretsCmd::Ls { app }, - } => cmds::secrets::ls(&app, mode).await, + cmd: SecretsCmd::Ls { group, app, env }, + } => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await, Cmd::Secrets { - cmd: SecretsCmd::Set { app, name, json }, - } => cmds::secrets::set(&app, &name, json).await, + cmd: + SecretsCmd::Set { + group, + app, + name, + env, + json, + }, + } => { + cmds::secrets::set( + group.as_deref(), + app.as_deref(), + &name, + env.as_deref(), + json, + ) + .await + } Cmd::Secrets { - cmd: SecretsCmd::Rm { app, name }, - } => cmds::secrets::rm(&app, &name).await, + cmd: + SecretsCmd::Rm { + group, + app, + name, + env, + }, + } => cmds::secrets::rm(group.as_deref(), app.as_deref(), &name, env.as_deref()).await, + Cmd::Secrets { + cmd: SecretsCmd::Read { group, name, env }, + } => cmds::secrets::read(&group, &name, env.as_deref()).await, Cmd::Members { cmd: MembersCmd::Ls { app }, } => cmds::members::ls(&app, mode).await, @@ -1761,6 +1888,41 @@ async fn main() -> ExitCode { Cmd::Members { cmd: MembersCmd::Rm { app, user_id }, } => cmds::members::rm(&app, &user_id).await, + Cmd::Vars { + cmd: VarsCmd::Ls { group, app }, + } => cmds::vars::ls(group.as_deref(), app.as_deref(), mode).await, + Cmd::Vars { + cmd: + VarsCmd::Set { + key, + value, + group, + app, + env, + json, + tombstone, + }, + } => { + cmds::vars::set( + group.as_deref(), + app.as_deref(), + &key, + &value, + env.as_deref(), + json, + tombstone, + ) + .await + } + Cmd::Vars { + cmd: + VarsCmd::Rm { + key, + group, + app, + env, + }, + } => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await, Cmd::Files { cmd: FilesCmd::Ls { diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 9ace257..3d0d930 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -23,6 +23,7 @@ mod dead_letters; mod email_queue; mod enabled; mod env_overlay; +mod group_secrets; mod groups; mod init; mod invoke; @@ -37,3 +38,4 @@ mod scripts; mod secrets; mod staleness; mod triggers; +mod vars; diff --git a/crates/picloud-cli/tests/fixtures/read-secret.rhai b/crates/picloud-cli/tests/fixtures/read-secret.rhai new file mode 100644 index 0000000..0b2db58 --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/read-secret.rhai @@ -0,0 +1,5 @@ +// Phase-3 group-secrets journey fixture: returns the resolved `stripe-key` +// secret verbatim so the test can assert runtime injection across the group +// chain (inherited group value) vs an app-owned proximity override. The +// value is decrypted under the resolved owner's AAD before injection. +secrets::get("stripe-key") diff --git a/crates/picloud-cli/tests/fixtures/read-var.rhai b/crates/picloud-cli/tests/fixtures/read-var.rhai new file mode 100644 index 0000000..8f9b1fe --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/read-var.rhai @@ -0,0 +1,4 @@ +// Phase-3 vars journey fixture: returns the resolved `region` config var +// verbatim so the test can assert on inheritance (group value) vs an app +// proximity override. +vars::get("region") diff --git a/crates/picloud-cli/tests/group_secrets.rs b/crates/picloud-cli/tests/group_secrets.rs new file mode 100644 index 0000000..c651cbc --- /dev/null +++ b/crates/picloud-cli/tests/group_secrets.rs @@ -0,0 +1,187 @@ +//! Phase-3 group secrets, end to end via `pic`: +//! +//! 1. **Inheritance + proximity** — a group-owned secret is injected into a +//! descendant app's script via `secrets::get` (decrypted under the +//! group AAD), and an app-owned secret of the same name shadows it +//! (decrypted under the app AAD). Exercises the resolver + the dual +//! owner-AAD open path against real Postgres. +//! 2. **Masked-read boundary** — a `group_admin` reads the secret VALUE, +//! an app-only dev is denied the value (403) yet still sees the secret +//! EXISTS (masked) in `config/effective`. That is the headline §5.3 +//! property: an app runs with config its own devs cannot read. + +use predicates::prelude::*; + +use crate::common; +use crate::common::cleanup::{AppGuard, GroupGuard}; +use crate::common::member; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_secret_is_injected_then_app_value_overrides() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("gs-acme"); + let app = common::unique_slug("gs-app"); + + // Group `acme` with a `stripe-key` group secret (value via stdin). + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + common::pic_as(&env) + .args(["secrets", "set", "--group", &acme, "stripe-key"]) + .write_stdin("sk_group") + .assert() + .success(); + + // App under acme with a script that reads + returns the resolved secret. + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &acme]) + .assert() + .success(); + let fixture = common::fixture_path("read-secret.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &app, + ]) + .assert() + .success(); + let ls = common::pic_as(&env) + .args(["scripts", "ls", "--app", &app]) + .output() + .expect("scripts ls"); + let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap()) + .expect("scripts ls should produce one row"); + + // Inherited: the app has no own `stripe-key`, so the group's value is + // injected (decrypted under the GROUP AAD). + assert_eq!( + invoke_body(&env, &id), + serde_json::json!("sk_group"), + "inherited group secret" + ); + + // Proximity override: an app-owned `stripe-key` shadows the group value + // (decrypted under the APP AAD — proving both AAD namespaces open). + common::pic_as(&env) + .args(["secrets", "set", "--app", &app, "stripe-key"]) + .write_stdin("sk_app") + .assert() + .success(); + assert_eq!( + invoke_body(&env, &id), + serde_json::json!("sk_app"), + "app override" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_secret_value_is_masked_from_app_devs() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("gsm-acme"); + let app = common::unique_slug("gsm-app"); + + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + common::pic_as(&env) + .args(["secrets", "set", "--group", &acme, "stripe-key"]) + .write_stdin("sk_live_masked") + .assert() + .success(); + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &acme]) + .assert() + .success(); + + // An app dev: a Member granted `editor` on the app, but NO group role. + let dev = member::member_user(fx, &common::unique_username("appdev")); + let dev_env = common::custom_env(&fx.url, &dev.token); + common::seed_credentials(&dev_env, &dev.username); + member::grant_membership(fx, &app, &dev.id, "editor"); + + // Denied the VALUE: the value endpoint is gated GroupSecretsRead at the + // owning group, which the app dev does not hold. + common::pic_as(&dev_env) + .args(["secrets", "read", "--group", &acme, "stripe-key"]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 403")); + + // But the dev DOES see it EXISTS (masked) in the app's effective config — + // status `set`, owner group, never the value. Asserted directly against + // the endpoint to avoid the CLI's manifest requirement. + let effective = reqwest::blocking::Client::new() + .get(format!( + "{}/api/v1/admin/apps/{}/config/effective", + fx.url, app + )) + .bearer_auth(&dev.token) + .send() + .expect("config effective"); + assert!( + effective.status().is_success(), + "dev can read effective config" + ); + let body: serde_json::Value = effective.json().expect("effective json"); + let masked = &body["secrets"]["stripe-key"]; + assert_eq!(masked["status"], "set", "secret shown as set"); + assert_eq!(masked["owner"]["kind"], "group", "owned by the group"); + assert!( + masked.get("value").is_none(), + "value must never appear in effective config: {masked}" + ); + + // A group_admin CAN read the value. + let gadmin = member::member_user(fx, &common::unique_username("gadmin")); + let gadmin_env = common::custom_env(&fx.url, &gadmin.token); + common::seed_credentials(&gadmin_env, &gadmin.username); + common::pic_as(&env) + .args([ + "groups", + "members", + "add", + &acme, + &gadmin.id, + "--role", + "app_admin", + ]) + .assert() + .success(); + common::pic_as(&gadmin_env) + .args(["secrets", "read", "--group", &acme, "stripe-key"]) + .assert() + .success() + .stdout(predicate::str::contains("sk_live_masked")); +} + +/// Invoke a script via `pic scripts invoke ` (→ `/api/v1/execute/{id}`) +/// and parse its JSON body. +fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { + let out = common::pic_as(env) + .args(["scripts", "invoke", id]) + .output() + .expect("scripts invoke"); + assert!( + out.status.success(), + "invoke failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).expect("invoke body is JSON") +} diff --git a/crates/picloud-cli/tests/logs.rs b/crates/picloud-cli/tests/logs.rs index 1b8e732..12f6cb9 100644 --- a/crates/picloud-cli/tests/logs.rs +++ b/crates/picloud-cli/tests/logs.rs @@ -7,7 +7,7 @@ use predicates::prelude::*; use crate::common; /// Pick out the data rows from `pic logs` TSV output — the header line -/// (`created_at\tstatus\tsummary`) is now always present, so the old +/// (`created_at\tsource\tstatus\tsummary`) is now always present, so the old /// "no non-empty lines means no logs" check needs to skip it. fn data_rows(stdout: &str) -> Vec<&str> { stdout @@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() { let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect(); assert_eq!( cols.len(), - 3, - "row should be 3 tab-delimited cells: {rows:?}" + 4, + "row should be 4 tab-delimited cells (created_at, source, status, summary): {rows:?}" ); - assert_eq!(cols[1], "success"); + assert_eq!(cols[2], "success"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] @@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() { .next() .expect("at least one data row"); let cols: Vec<&str> = row.split('\t').map(str::trim).collect(); - assert_eq!(cols[1], "error", "expected error status, got row: {row}"); + assert_eq!(cols[2], "error", "expected error status, got row: {row}"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] @@ -166,7 +166,7 @@ fn logs_truncates_long_summary() { .into_iter() .next() .expect("at least one data row"); - let summary = row.split('\t').nth(2).expect("summary column"); + let summary = row.split('\t').nth(3).expect("summary column"); assert!( summary.ends_with('…'), "summary should be truncated with `…`, got: {summary}" diff --git a/crates/picloud-cli/tests/roles.rs b/crates/picloud-cli/tests/roles.rs index 29fb46c..1374f35 100644 --- a/crates/picloud-cli/tests/roles.rs +++ b/crates/picloud-cli/tests/roles.rs @@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() { ]) .assert() .success() - .stdout(predicate::str::contains("Created hello v1")); + .stdout(predicate::str::contains("hello").and(predicate::str::contains("created"))); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] diff --git a/crates/picloud-cli/tests/scripts.rs b/crates/picloud-cli/tests/scripts.rs index 3cd8c65..fec05f8 100644 --- a/crates/picloud-cli/tests/scripts.rs +++ b/crates/picloud-cli/tests/scripts.rs @@ -7,6 +7,16 @@ use predicates::prelude::*; use crate::common; use crate::common::cleanup::AppGuard; +/// Extract a field value from a `pic` KvBlock (`key\tvalue` per line). +/// `pic scripts deploy` prints `name`/`version`/`action` rows, not a prose +/// "Created X vN" line, so version-bump tests assert on these fields. +fn kv_field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> { + stdout.lines().find_map(|l| { + let (k, v) = l.split_once('\t')?; + (k.trim() == key).then(|| v.trim()) + }) +} + #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn deploy_against_unknown_app_errors() { @@ -48,7 +58,7 @@ fn deploy_with_name_override() { let _guard = AppGuard::new(&env.url, &env.token, &slug); let fixture = common::fixture_path("hello.rhai"); - common::pic_as(&env) + let out = common::pic_as(&env) .args([ "scripts", "deploy", @@ -58,11 +68,14 @@ fn deploy_with_name_override() { "--name", "custom-name", ]) - .assert() - .success() - .stdout(predicate::str::contains("Created custom-name v1")); + .output() + .expect("deploy v1"); + let v1 = String::from_utf8(out.stdout).unwrap(); + assert_eq!(kv_field(&v1, "name"), Some("custom-name"), "v1 name: {v1}"); + assert_eq!(kv_field(&v1, "version"), Some("1"), "v1 version: {v1}"); + assert_eq!(kv_field(&v1, "action"), Some("created"), "v1 action: {v1}"); - common::pic_as(&env) + let out = common::pic_as(&env) .args([ "scripts", "deploy", @@ -72,9 +85,11 @@ fn deploy_with_name_override() { "--name", "custom-name", ]) - .assert() - .success() - .stdout(predicate::str::contains("Updated custom-name v2")); + .output() + .expect("deploy v2"); + let v2 = String::from_utf8(out.stdout).unwrap(); + assert_eq!(kv_field(&v2, "version"), Some("2"), "v2 version: {v2}"); + assert_eq!(kv_field(&v2, "action"), Some("updated"), "v2 action: {v2}"); let out = common::pic_as(&env) .args(["scripts", "ls", "--app", &slug]) @@ -106,8 +121,8 @@ fn deploy_bumps_version_each_redeploy() { let _guard = AppGuard::new(&env.url, &env.token, &slug); let fixture = common::fixture_path("hello.rhai"); - for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] { - common::pic_as(&env) + for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] { + let out = common::pic_as(&env) .args([ "scripts", "deploy", @@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() { "--app", &slug, ]) - .assert() - .success() - .stdout(predicate::str::contains(expected)); + .output() + .expect("deploy"); + assert!(out.status.success(), "deploy v{version} failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + kv_field(&stdout, "version"), + Some(version), + "version: {stdout}" + ); + assert_eq!( + kv_field(&stdout, "action"), + Some(action), + "action: {stdout}" + ); } } diff --git a/crates/picloud-cli/tests/secrets.rs b/crates/picloud-cli/tests/secrets.rs index 6e93470..157ce35 100644 --- a/crates/picloud-cli/tests/secrets.rs +++ b/crates/picloud-cli/tests/secrets.rs @@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() { .expect("secrets ls"); let stdout = String::from_utf8(out.stdout).unwrap(); let header = stdout.lines().next().expect("header"); - assert_eq!(common::cells(header), vec!["name", "updated_at"]); + assert_eq!(common::cells(header), vec!["name", "env", "updated_at"]); assert!( stdout.lines().skip(1).any(|l| l.starts_with("api_key")), "api_key missing from ls: {stdout}" diff --git a/crates/picloud-cli/tests/vars.rs b/crates/picloud-cli/tests/vars.rs new file mode 100644 index 0000000..49c9045 --- /dev/null +++ b/crates/picloud-cli/tests/vars.rs @@ -0,0 +1,84 @@ +//! Phase-3 config `vars`, end to end via `pic`: a group-owned var is +//! inherited by an app underneath it, and an app-owned var of the same key +//! overrides the inherited value (proximity wins, §3). +//! +//! Drives the real resolution path: a script does `vars::get("region")` +//! and returns it; we invoke it via `/api/v1/execute/{id}` and assert on +//! the body. First the group value flows down (inheritance), then an app +//! value shadows it (override). + +use crate::common; +use crate::common::cleanup::{AppGuard, GroupGuard}; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_var_is_inherited_then_app_value_overrides() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let acme = common::unique_slug("v-acme"); + let app = common::unique_slug("v-app"); + + // Group `acme`, then a `region` var on it (a JSON string "eu"). + let _g_acme = GroupGuard::new(&env.url, &env.token, &acme); + common::pic_as(&env) + .args(["groups", "create", &acme]) + .assert() + .success(); + common::pic_as(&env) + .args(["vars", "set", "region", "eu", "--group", &acme]) + .assert() + .success(); + + // App under acme with a script that reads + returns the resolved var. + let _app = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &acme]) + .assert() + .success(); + let fixture = common::fixture_path("read-var.rhai"); + common::pic_as(&env) + .args([ + "scripts", + "deploy", + fixture.to_str().unwrap(), + "--app", + &app, + ]) + .assert() + .success(); + + // Resolve the deployed script's id. + let ls = common::pic_as(&env) + .args(["scripts", "ls", "--app", &app]) + .output() + .expect("scripts ls"); + let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap()) + .expect("scripts ls should produce one row"); + + // Inherited: the app has no own `region`, so the group's "eu" resolves. + assert_eq!(invoke_body(&env, &id), serde_json::json!("eu"), "inherited"); + + // Proximity override: an app-owned `region` shadows the group value. + common::pic_as(&env) + .args(["vars", "set", "region", "us", "--app", &app]) + .assert() + .success(); + assert_eq!(invoke_body(&env, &id), serde_json::json!("us"), "override"); +} + +/// Invoke a script via `pic scripts invoke ` (→ `/api/v1/execute/{id}`) +/// and parse its JSON body. +fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { + let out = common::pic_as(env) + .args(["scripts", "invoke", id]) + .output() + .expect("scripts invoke"); + assert!( + out.status.success(), + "invoke failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).expect("invoke body is JSON") +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 40d4850..7dffc2b 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -14,26 +14,27 @@ use picloud_manager_core::{ apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router, dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations, require_authenticated, route_admin_router, secrets_router, topics_router, - triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, - AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, - AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, - AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, - EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, - FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, - InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo, - PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, - PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, - PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, - PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, - PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, - PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository, - PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, - PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, - PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, - RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, - SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, - TriggersState, UsersServiceConfig, UsersServiceImpl, + triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, + AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, + AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, + AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, + DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, + FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, + HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, + OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, + PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, + PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, + PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, + PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, + PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, + PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository, + PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, + PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, + PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl, + RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, + ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, + TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, + UsersServiceImpl, VarsApiState, VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -322,6 +323,8 @@ pub async fn build_app( // under script-as-gate semantics. .with_authz(authz.clone()), ); + let vars: Arc = + Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone())); let services = Services::new( kv, docs, @@ -336,6 +339,7 @@ pub async fn build_app( users.clone(), queue, invoke, + vars, ); // v1.1.9: keep the invoke depth bound aligned with the dispatcher's // trigger-depth bound (same counter under the hood). @@ -509,6 +513,7 @@ pub async fn build_app( let secrets_state = SecretsState { repo: secrets_repo, apps: apps_repo.clone(), + groups: groups_repo.clone(), authz: authz.clone(), master_key, max_value_bytes: secrets_max_value_bytes, @@ -566,6 +571,17 @@ pub async fn build_app( users: auth.users.clone(), authz: authz.clone(), }; + let vars_state = VarsApiState { + vars: Arc::new(PostgresVarsRepo::new(pool.clone())), + apps: apps_repo.clone(), + groups: groups_repo.clone(), + authz: authz.clone(), + }; + let config_state = picloud_manager_core::ConfigApiState { + pool: pool.clone(), + apps: apps_repo.clone(), + authz: authz.clone(), + }; let app_users_admin_state = picloud_manager_core::AppUsersState { apps: apps_state.apps.clone(), authz: authz.clone(), @@ -589,6 +605,8 @@ pub async fn build_app( .merge(apps_router(apps_state)) .merge(app_members_router(app_members_state)) .merge(groups_router(groups_state)) + .merge(vars_router(vars_state)) + .merge(picloud_manager_core::config_router(config_state)) .merge(picloud_manager_core::app_users_router( app_users_admin_state, )) diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 94a6992..53eca9b 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -38,6 +38,7 @@ pub mod subscriber_token; pub mod trigger_event; pub mod users; pub mod validator; +pub mod vars; pub mod version; /// serde `default` for `enabled`-style boolean fields that should default to @@ -101,4 +102,5 @@ pub use users::{ UsersService, }; pub use validator::{ScriptValidator, ValidatedScript, ValidationError}; +pub use vars::{NoopVarsService, VarsError, VarsService}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 81971fb..bf527ee 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -24,7 +24,8 @@ use crate::{ KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, - PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, + NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter, + UsersService, VarsService, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -107,6 +108,12 @@ pub struct Services { /// and `invoke_async()` (fire-and-forget through the outbox). /// Cross-app invokes are rejected at the service entry point. pub invoke: Arc, + + /// Group-inherited, env-scoped config (Phase 3). Scripts get + /// read-only `vars::{get,all}`; values resolve down the group tree + /// (§3). Backed by `config_resolver` over Postgres in the picloud + /// binary; `NoopVarsService` in tests that don't read config. + pub vars: Arc, } impl Services { @@ -129,6 +136,7 @@ impl Services { users: Arc, queue: Arc, invoke: Arc, + vars: Arc, ) -> Self { Self { kv, @@ -144,6 +152,7 @@ impl Services { users, queue, invoke, + vars, } } @@ -168,6 +177,7 @@ impl Services { Arc::new(NoopUsersService), Arc::new(NoopQueueService), Arc::new(NoopInvokeService), + Arc::new(NoopVarsService), ) } } diff --git a/crates/shared/src/vars.rs b/crates/shared/src/vars.rs new file mode 100644 index 0000000..96af8f4 --- /dev/null +++ b/crates/shared/src/vars.rs @@ -0,0 +1,48 @@ +//! `vars::*` — read-only access from scripts to the app's resolved +//! configuration (Phase 3). Values are inherited down the group tree and +//! env-filtered per the §3 resolution rule; the resolution happens in +//! manager-core (`config_resolver`). Writes go through the admin API, not +//! the SDK — scripts only read. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::SdkCallCx; + +#[derive(Debug, thiserror::Error)] +pub enum VarsError { + /// Caller principal lacked `AppVarsRead`. Only raised when + /// `cx.principal.is_some()` (public-HTTP scripts skip the check — + /// script-as-gate). + #[error("forbidden")] + Forbidden, + /// Postgres unavailable, malformed row, etc. + #[error("vars backend error: {0}")] + Backend(String), +} + +#[async_trait] +pub trait VarsService: Send + Sync { + /// Resolve a single config key for the calling app's environment. + /// `None` if no level defines it (or a tombstone suppresses it). + async fn get(&self, cx: &SdkCallCx, key: &str) -> Result, VarsError>; + + /// The app's fully-resolved config map (every inherited + own key). + async fn all(&self, cx: &SdkCallCx) -> Result, VarsError>; +} + +/// All-noop fallback for engines that don't wire vars (tests). Every call +/// surfaces an explicit error rather than silently returning empty. +pub struct NoopVarsService; + +#[async_trait] +impl VarsService for NoopVarsService { + async fn get(&self, _cx: &SdkCallCx, _key: &str) -> Result, VarsError> { + Err(VarsError::Backend("vars is not wired in".into())) + } + async fn all(&self, _cx: &SdkCallCx) -> Result, VarsError> { + Err(VarsError::Backend("vars is not wired in".into())) + } +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 0f3ca47..bcc7af6 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -1,17 +1,23 @@ # Groups, Projects & the Declarative Project Tool -> **Status:** Draft — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions +> **Status:** Active — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions > grounded in precedent (GitLab, Kustomize, Helm, Terraform, Kubernetes Server-Side Apply, Pulumi) > and corrected against the codebase after three independent review passes (consistency, gaps, -> feasibility). Not yet scheduled into a phase. +> feasibility). **Scheduled as the *Hierarchies* track of v1.2** (blueprint §12 Phase 5). §11 Phases +> 1–3 (project tool, groups + hierarchy RBAC, group-inherited config) are **shipped**; Phases 4–6 +> remain. NB: §11's own Phase 1–6 numbering is local to this initiative and is **distinct from the +> blueprint product-phase numbering** — "Phase 3" here = group-inherited config, not the blueprint's +> admin-auth phase. > > **Scope:** turns the imperative `pic` CLI into a declarative, file-based project tool, and > introduces a server-side **groups** hierarchy so apps can share and inherit config/scripts > without duplication. > > **Blueprint impact:** this reverses the §11.5 *snapshot-copy, not live-link* stance — top-down -> hierarchical inheritance (group → app) is now in scope, implemented as a *materialized, auto- -> invalidated* view (§5.1). Fold the outcome back into the blueprint before it drifts. +> hierarchical inheritance (group → app) is now in scope. *Originally* designed as a *materialized, +> auto-invalidated* view (§5.1); **Phase 3 shipped it as live, resolve-on-read inheritance** (one +> recursive CTE per call, no cache), with materialization deferred to a future optimization (see the +> Phase 3 status note in §11.3). Fold the outcome back into the blueprint before it drifts. --- @@ -425,6 +431,15 @@ Two distinct constraints: - **Runtime model: a materialized effective view + versioned cache (not per-request live-resolve).** An earlier draft said "live-resolve," which is underspecified and would fight the existing cache. The real model: + + > **⚠️ Superseded for config by what Phase 3 actually shipped (§11.3): live resolution.** For + > **vars + secrets**, PiCloud resolves on-demand via one recursive CTE per call — *no* materialized + > view, generation counter, or invalidation fan-out. There was no pre-existing config cache to + > "fight" (config is net-new, §3), and the org tree is small, so resolve-on-read is cheap and + > sidesteps the invalidation SLA below. The materialized model in this bullet stands only as the + > **deferred plan for *script-body* inheritance (Phase 4)** — where a hot per-request path may + > justify a cache — and as a future optimization for config if a deep tree ever demands it. Read the + > rest of §5.1 as "the materialization design, if/when we need it," not as Phase 3's runtime. - manager-core **resolves-at-write into a materialized per-app effective view** (§3 rule applied: sparse merge, env filter, proximity, CoW, `enabled`). - The orchestrator/executor serve from that view, **keyed by `app_id` + a generation/version**. The @@ -800,7 +815,10 @@ Resolved items now live inline next to their topic. What genuinely remains: - **Effective-view invalidation SLA (§5.1)** — the security-staleness guarantee at fan-out to many descendants is unsolved; synchronous-for-security + eventual-elsewhere is the proposed shape, not a - proven one. Highest-risk open item. + proven one. *(Was the highest-risk open item.)* **Moot for config as of Phase 3:** vars/secrets ship + with **live resolution** (no cache → no staleness window; a disable/rotation is visible on the next + call). The SLA only re-arises if **script-body** materialization is built (Phase 4) or if config is + ever materialized for performance; until then there is nothing to invalidate. - **Conflict bit vs. operational lock (§4.2)** — *decided:* phase 1 ships the `enabled` / secret- reference **conflict bit**; the operational-lock flag is the documented fallback if the bit proves too heavy. (Was listed as undecided; resolved here to match §11 phase 1.) @@ -810,8 +828,11 @@ Resolved items now live inline next to their topic. What genuinely remains: precondition to adopting the rule. - **Inherited-membership revocation lag (§5.3)** — revoking a group admin must drop implicit admin on every descendant app, but §5.1's synchronous-invalidation subset covers only `enabled`/secrets, not - **role revocation** — leaving an unbounded window. New residual risk; should get the same - synchronous-for-security bound, lands with phase 3's authz. + **role revocation** — leaving an unbounded window. ~~New residual risk; should get the same + synchronous-for-security bound, lands with phase 3's authz.~~ **Resolved — never materialized:** + Phase 2 RBAC and Phase 3 config both resolve roles/config **live** (no inherited-role or + effective-view cache), so a revoked group grant is gone on the next request. The lag only exists if a + cache is introduced. - **External execution cancel (§4.2 kill-switch)** — the executor has **no external-cancel path today** (`spawn_blocking` + self-checked deadline, verified); the kill-switch is a net-new capability (a cancel flag polled in `on_progress`), deferred past phase 1. Until it exists, the strongest stop @@ -879,6 +900,46 @@ Resolved items now live inline next to their topic. What genuinely remains: tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard requirement, since multi-level resolution first ships here). + + > **Status (Phase 3): ✅ shipped — with one deliberate architecture change: LIVE resolution, not + > materialization.** §5.1 / this bullet / §12 prescribed a *materialized* per-app effective view + > with a generation counter and fan-out invalidation. We shipped **live, on-demand resolution + > instead**: one depth-bounded recursive CTE (`config_resolver::CHAIN_LEVELS_CTE`, walking + > `apps.group_id → groups.parent_id → root`) runs per `vars::get`/`secrets::get`/`config/effective`, + > and the §3 merge/proximity/tombstone semantics run in a pure, unit-tested `resolve()` pass on the + > fetched rows. No materialized store, no generation, no invalidation protocol. Rationale: the group + > tree is small org structure, resolve-on-read is cheap, and it sidesteps §10's unsolved + > fan-out-invalidation SLA entirely. **Consequence:** §10's "effective-view invalidation SLA" and + > "inherited-membership revocation lag" are **moot for config** — with no cache, a security disable + > or secret rotation propagates immediately (same posture as Phase 2's live RBAC). Materialization + > (and a per-execution memo keyed by `cx.execution_id`) is re-scoped to a **future optimization**, + > to revisit only if a deep tree + hot path ever makes live resolution measurably costly. + > + > Shipped surface: + > - **Schema:** `0048_vars.sql` (`vars` polymorphic owner [`group_id` XOR `app_id`], + > `environment_scope`, `is_tombstone`, partial-unique indexes) + **`apps.environment TEXT NULL`** + > (the explicit realization of "an environment is an app", §2 — set at app-create, read by the + > resolver, never entering `SdkCallCx`). `0049_group_secrets.sql` reshapes the live `secrets` + > table to the **same** polymorphic-owner + `environment_scope` contract (PK → two partial-unique + > indexes); existing app rows backfill to `app_id` + scope `'*'` and decrypt byte-for-byte (the v1 + > AAD excludes the scope). + > - **§3 resolver:** env-filter in SQL (`scope IN ('*', app_env)`), then proximity-first + per-key + > deep-merge / replace / tombstone in Rust. A same-level `@E` value **suppresses** the same-level + > `*` (env is eligibility, not a merge tier — §3.1), incl. the map-vs-map case. + > - **Vars:** `VarsService` + `vars::get`/`vars::all` SDK + `App/GroupVars{Read,Write}` caps + + > `{apps,groups}/{id}/vars` admin CRUD + `pic vars`. + > - **Group secrets:** owner-discriminated AAD (`secret:group:{group_id}:{name}`; app AAD + > unchanged); inherited `secrets::get` (resolves app-own + ancestor-group, decrypts under the + > resolved owner's AAD, anchored to `cx.app_id`); the **two orthogonal gates** (§5.3): runtime + > injection (no human-read check) vs. the group-gated value endpoint + > `GET /groups/{id}/secrets/{name}/value` (`GroupSecretsRead` = group_admin). `GroupSecretsWrite` + > sits at `script:write` scope (matches its editor role tier). + > - **Effective view (masked):** `GET /apps/{id}/config/effective` → resolved vars (with + > provenance) + secrets masked to `{status, owner, scope}` (value never returned). `pic config + > --effective [--explain]` renders it. + > + > Deferred (unchanged): group-*owned scripts/modules* (Phase 4), the manifest `[vars]`/`[secrets]` + > apply reconciliation (Phase 5 — Phase 3 ships CRUD API + CLI), and materialization (above). 4. **Group-inherited scripts/modules.** CoW overrides; the **scope-aware module/import resolver + extension points** (§5.5); cache-invalidation fan-out hardening; versioning/pinning if needed. 5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed @@ -887,13 +948,41 @@ Resolved items now live inline next to their topic. What genuinely remains: real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality demands. +### 11.1 Re-sequencing review (post-Phase-3) + +A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions +to take, not yet taken: + +- **Phase 4 should resolve scripts *live*, not materialize.** §11.4 carries "cache-invalidation + fan-out hardening" as a deliverable, a holdover from the materialized-view model. Phase 3 proved + **live resolution** (recursive CTE per call) is sufficient and sidesteps the §10 invalidation SLA, + and script *bodies are already live-resolved per request today* (§5.1). So Phase 4 should resolve + group-owned scripts live too and **drop the fan-out-invalidation sub-project** unless a *measured* + hot-path cost appears. This removes the scariest item from Phase 4; the real remaining cost is the + **module/import resolver redesign** (§5.5), which is unaffected by this and stays the gating risk. +- **Consider a "Phase 5-lite" *before* Phase 4.** The declarative project tool already applies app + scripts (Phase 1) and Phase 3 shipped the group vars/secrets CRUD, so a project tool that declares + **group vars/secrets + app scripts** needs *nothing* from Phase 4 (group-owned scripts). The + highest-leverage day-to-day workflow (declarative config apply) can ship ahead of the hard + module-resolver work. Phase 4 then adds group-owned *scripts* to an already-working project tool. +- **Two deferred decisions to force, not default:** + - *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding + tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations). + Decide against **actual** target tenant counts before defaulting it late. + - *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial + and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a + "one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists + before building the ownership layer. + --- ## 12. Contracts still to draft - The **apply bundle / plan artifact / change-report** wire contract (what the CLI ships, what the server persists and returns), including the conflict and blast-radius shapes. -- The **effective-view resolver** (the read primitive) — the §3 rule made executable, plus the - materialization + invalidation protocol (§5.1). +- The **effective-view resolver** (the read primitive) — the §3 rule made executable. *(Phase 3: + shipped as a **live** recursive-CTE resolver, `config_resolver`/`secrets_repo::resolve`; the + materialization + invalidation protocol of §5.1 was **not** built — see the Phase 3 status note in + §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). diff --git a/serverless_cloud_blueprint.md b/serverless_cloud_blueprint.md index 27a3226..641e87a 100644 --- a/serverless_cloud_blueprint.md +++ b/serverless_cloud_blueprint.md @@ -1256,6 +1256,28 @@ Released in patch steps (v1.1.0 → v1.1.8), each landing one focused capability --- ### Phase 5: v1.2 (Advanced Workflows & Hierarchies) + +Two tracks under the same minor line. The **Hierarchies** track is in active development; the +**Workflows** track follows it (or runs in parallel — sequencing is an open call, see the design doc's +§11 re-sequencing note). + +**Hierarchies — groups + the declarative project tool** (the detailed design and 6-phase breakdown +live in [docs/design/groups-and-project-tool.md](../docs/design/groups-and-project-tool.md) §11; this +absorbs the original one-line "Hierarchies" bullet into a full initiative): +- A single-parent **groups** tree above apps, with hierarchy-aware RBAC (inherited membership). *(§11 + Phase 2 — shipped: `0047_groups.sql`, `effective_app_role`/`effective_group_role`.)* +- **Group-inherited, environment-scoped config** — `vars` + group secrets, resolved **live** (recursive + CTE, no materialized cache) with the §3 proximity/merge/tombstone rule; the group-secret AAD scheme; + masked secrets (an app runs with config its own devs cannot read). *(§11 Phase 3 — shipped: + `0048_vars.sql`, `0049_group_secrets.sql`.)* +- A **declarative, file-based project tool** (`pic plan`/`apply`/`prune`, env overlays, the three-state + `enabled` lifecycle, bound-plan staleness check). *(§11 Phase 1 — shipped.)* +- Remaining: group-owned **scripts/modules** with the scope-aware import resolver (§11 Phase 4); the + project tool **mapping onto the group tree** (nested manifests, attach points, ownership — §11 Phase + 5). Group-level shared *data* (collections/topics) stays in v1.3 (the cross-app-sharing problem + below). + +**Workflows:** - Function workflows (DAG execution, conditional branching, error handling) - Nested workflows - Call graph visualization + execution tracing