Compare commits
14 Commits
feat/group
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46667c309 | ||
|
|
ca360d84cb | ||
|
|
79f8c9d420 | ||
|
|
bb68e5e50a | ||
|
|
11ac168839 | ||
|
|
30441549d5 | ||
|
|
c914758c09 | ||
|
|
e6b4792389 | ||
|
|
b588fc9d35 | ||
|
|
6bd0f4699d | ||
|
|
9ee85993d8 | ||
|
|
343f6d3b4d | ||
|
|
35dbd9f368 | ||
|
|
6db057fb08 |
@@ -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.
|
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
|
## Three-Service Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod retry;
|
|||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod vars;
|
||||||
|
|
||||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
pub use cx::SdkCallCx;
|
pub use cx::SdkCallCx;
|
||||||
@@ -62,6 +63,7 @@ pub fn register_all(
|
|||||||
queue::register(engine, services, cx.clone());
|
queue::register(engine, services, cx.clone());
|
||||||
retry::register(engine, services, cx.clone());
|
retry::register(engine, services, cx.clone());
|
||||||
secrets::register(engine, services, cx.clone());
|
secrets::register(engine, services, cx.clone());
|
||||||
|
vars::register(engine, services, cx.clone());
|
||||||
email::register(engine, services, cx.clone());
|
email::register(engine, services, cx.clone());
|
||||||
users::register(engine, services, cx.clone());
|
users::register(engine, services, cx.clone());
|
||||||
invoke::register(engine, services, cx, limits, self_engine);
|
invoke::register(engine, services, cx, limits, self_engine);
|
||||||
|
|||||||
57
crates/executor-core/src/sdk/vars.rs
Normal file
57
crates/executor-core/src/sdk/vars.rs
Normal file
@@ -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<SdkCallCx>) {
|
||||||
|
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<Dynamic, Box<EvalAltResult>> {
|
||||||
|
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<Map, Box<EvalAltResult>> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
let engine = Engine::new(Limits::default(), services);
|
let engine = Engine::new(Limits::default(), services);
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
|||||||
Arc::new(NoopUsersService),
|
Arc::new(NoopUsersService),
|
||||||
Arc::new(NoopQueueService),
|
Arc::new(NoopQueueService),
|
||||||
svc,
|
svc,
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||||
engine.set_self_weak(Arc::downgrade(&engine));
|
engine.set_self_weak(Arc::downgrade(&engine));
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
svc,
|
svc,
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopUsersService),
|
Arc::new(NoopUsersService),
|
||||||
Arc::new(NoopQueueService),
|
Arc::new(NoopQueueService),
|
||||||
Arc::new(NoopInvokeService),
|
Arc::new(NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(picloud_shared::NoopUsersService),
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
Arc::new(picloud_shared::NoopQueueService),
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
Arc::new(picloud_shared::NoopInvokeService),
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
|
|||||||
47
crates/manager-core/migrations/0048_vars.sql
Normal file
47
crates/manager-core/migrations/0048_vars.sql
Normal file
@@ -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;
|
||||||
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
@@ -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;
|
||||||
@@ -818,7 +818,7 @@ impl ApplyService {
|
|||||||
) -> Result<(Vec<u8>, Vec<u8>), ApplyError> {
|
) -> Result<(Vec<u8>, Vec<u8>), ApplyError> {
|
||||||
let stored = self
|
let stored = self
|
||||||
.secrets
|
.secrets
|
||||||
.get(app_id, name)
|
.get(crate::secrets_service::SecretOwner::App(app_id), "*", name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -827,8 +827,13 @@ impl ApplyService {
|
|||||||
(push it with `pic secret set {name}`)"
|
(push it with `pic secret set {name}`)"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let plaintext = crate::secrets_service::open(&self.master_key, app_id, name, &stored)
|
let plaintext = crate::secrets_service::open(
|
||||||
.map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
|
&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
|
// 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).
|
// key is forgeable (preserves the spirit of audit 2026-06-11 H-B2).
|
||||||
match plaintext.as_str() {
|
match plaintext.as_str() {
|
||||||
@@ -1013,7 +1018,11 @@ impl ApplyService {
|
|||||||
loop {
|
loop {
|
||||||
let page = self
|
let page = self
|
||||||
.secrets
|
.secrets
|
||||||
.list_names(app_id, cursor.as_deref(), 200)
|
.list_names(
|
||||||
|
crate::secrets_service::SecretOwner::App(app_id),
|
||||||
|
cursor.as_deref(),
|
||||||
|
200,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
names.extend(page.names);
|
names.extend(page.names);
|
||||||
|
|||||||
@@ -116,6 +116,25 @@ pub enum Capability {
|
|||||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||||
AppSecretsWrite(AppId),
|
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
|
/// 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
|
/// to `script:write` on API keys (sending mail is an outbound
|
||||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||||
@@ -176,7 +195,11 @@ impl Capability {
|
|||||||
// app) is denied group management at the binding layer.
|
// app) is denied group management at the binding layer.
|
||||||
| Self::GroupRead(_)
|
| Self::GroupRead(_)
|
||||||
| Self::GroupWrite(_)
|
| Self::GroupWrite(_)
|
||||||
| Self::GroupAdmin(_) => None,
|
| Self::GroupAdmin(_)
|
||||||
|
| Self::GroupVarsRead(_)
|
||||||
|
| Self::GroupVarsWrite(_)
|
||||||
|
| Self::GroupSecretsRead(_)
|
||||||
|
| Self::GroupSecretsWrite(_) => None,
|
||||||
Self::AppRead(id)
|
Self::AppRead(id)
|
||||||
| Self::AppWriteScript(id)
|
| Self::AppWriteScript(id)
|
||||||
| Self::AppWriteRoute(id)
|
| Self::AppWriteRoute(id)
|
||||||
@@ -194,6 +217,8 @@ impl Capability {
|
|||||||
| Self::AppQueueEnqueue(id)
|
| Self::AppQueueEnqueue(id)
|
||||||
| Self::AppSecretsRead(id)
|
| Self::AppSecretsRead(id)
|
||||||
| Self::AppSecretsWrite(id)
|
| Self::AppSecretsWrite(id)
|
||||||
|
| Self::AppVarsRead(id)
|
||||||
|
| Self::AppVarsWrite(id)
|
||||||
| Self::AppEmailSend(id)
|
| Self::AppEmailSend(id)
|
||||||
| Self::AppManageTriggers(id)
|
| Self::AppManageTriggers(id)
|
||||||
| Self::AppDeadLetterManage(id)
|
| Self::AppDeadLetterManage(id)
|
||||||
@@ -223,7 +248,9 @@ impl Capability {
|
|||||||
| Self::AppFilesRead(_)
|
| Self::AppFilesRead(_)
|
||||||
| Self::AppSecretsRead(_)
|
| Self::AppSecretsRead(_)
|
||||||
| Self::AppUsersRead(_)
|
| Self::AppUsersRead(_)
|
||||||
| Self::GroupRead(_) => Scope::ScriptRead,
|
| Self::AppVarsRead(_)
|
||||||
|
| Self::GroupRead(_)
|
||||||
|
| Self::GroupVarsRead(_) => Scope::ScriptRead,
|
||||||
Self::AppWriteScript(_)
|
Self::AppWriteScript(_)
|
||||||
| Self::AppKvWrite(_)
|
| Self::AppKvWrite(_)
|
||||||
| Self::AppDocsWrite(_)
|
| Self::AppDocsWrite(_)
|
||||||
@@ -235,6 +262,12 @@ impl Capability {
|
|||||||
| Self::AppEmailSend(_)
|
| Self::AppEmailSend(_)
|
||||||
| Self::AppUsersWrite(_)
|
| Self::AppUsersWrite(_)
|
||||||
| Self::AppUsersAdmin(_)
|
| 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::AppInvoke(_) => Scope::ScriptWrite,
|
||||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
@@ -243,7 +276,9 @@ impl Capability {
|
|||||||
| Self::AppDeadLetterManage(_)
|
| Self::AppDeadLetterManage(_)
|
||||||
| Self::AppTopicManage(_)
|
| Self::AppTopicManage(_)
|
||||||
| Self::GroupWrite(_)
|
| Self::GroupWrite(_)
|
||||||
| Self::GroupAdmin(_) => Scope::AppAdmin,
|
| Self::GroupAdmin(_)
|
||||||
|
| Self::GroupVarsWrite(_)
|
||||||
|
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
|
||||||
Self::AppLogRead(_) => Scope::LogRead,
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,7 +447,13 @@ async fn role_grants(
|
|||||||
// walk (a group_admin on an ancestor is implicitly admin of
|
// walk (a group_admin on an ancestor is implicitly admin of
|
||||||
// the descendant group). Routed before member_grants because
|
// the descendant group). Routed before member_grants because
|
||||||
// group caps carry no app_id.
|
// 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
|
group_member_grants(repo, principal.user_id, cap, g).await
|
||||||
}
|
}
|
||||||
// Creating a root-level group is an instance act — members
|
// 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.
|
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
match cap {
|
match cap {
|
||||||
Capability::GroupRead(_) => true, // any role can read
|
// viewer+ reads group metadata and config vars.
|
||||||
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
|
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
|
||||||
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
|
// 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,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +544,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppFilesRead(_)
|
| Capability::AppFilesRead(_)
|
||||||
| Capability::AppSecretsRead(_)
|
| Capability::AppSecretsRead(_)
|
||||||
| Capability::AppUsersRead(_)
|
| Capability::AppUsersRead(_)
|
||||||
|
| Capability::AppVarsRead(_)
|
||||||
);
|
);
|
||||||
let in_editor = in_viewer
|
let in_editor = in_viewer
|
||||||
|| matches!(
|
|| matches!(
|
||||||
@@ -508,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
|||||||
| Capability::AppSecretsWrite(_)
|
| Capability::AppSecretsWrite(_)
|
||||||
| Capability::AppEmailSend(_)
|
| Capability::AppEmailSend(_)
|
||||||
| Capability::AppUsersWrite(_)
|
| Capability::AppUsersWrite(_)
|
||||||
|
| Capability::AppVarsWrite(_)
|
||||||
| Capability::AppInvoke(_)
|
| Capability::AppInvoke(_)
|
||||||
);
|
);
|
||||||
let in_app_admin = in_editor
|
let in_app_admin = in_editor
|
||||||
|
|||||||
158
crates/manager-core/src/config_api.rs
Normal file
158
crates/manager-core/src/config_api.rs
Normal file
@@ -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<dyn AppRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ConfigApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<AppId, ConfigApiError> {
|
||||||
|
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<AuthzDenied> for ConfigApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
456
crates/manager-core/src/config_resolver.rs
Normal file
456
crates/manager-core/src/config_resolver.rs
Normal file
@@ -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<String, Value>, src: &Map<String, Value>) {
|
||||||
|
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<Candidate>) -> 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<Candidate>,
|
||||||
|
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
|
||||||
|
let mut by_key: BTreeMap<String, Vec<Candidate>> = 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<Vec<Candidate>, 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<Vec<EffectiveSecretMeta>, 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<VarCandidateRow> 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}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ pub mod auth_api;
|
|||||||
pub mod auth_bootstrap;
|
pub mod auth_bootstrap;
|
||||||
pub mod auth_middleware;
|
pub mod auth_middleware;
|
||||||
pub mod authz;
|
pub mod authz;
|
||||||
|
pub mod config_api;
|
||||||
|
pub mod config_resolver;
|
||||||
pub mod cron_scheduler;
|
pub mod cron_scheduler;
|
||||||
pub mod dead_letter_repo;
|
pub mod dead_letter_repo;
|
||||||
pub mod dead_letter_service;
|
pub mod dead_letter_service;
|
||||||
@@ -84,6 +86,9 @@ pub mod trigger_repo;
|
|||||||
pub mod triggers_api;
|
pub mod triggers_api;
|
||||||
pub mod users_admin_api;
|
pub mod users_admin_api;
|
||||||
pub mod users_service;
|
pub mod users_service;
|
||||||
|
pub mod vars_api;
|
||||||
|
pub mod vars_repo;
|
||||||
|
pub mod vars_service;
|
||||||
|
|
||||||
pub use abandoned_repo::{
|
pub use abandoned_repo::{
|
||||||
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
||||||
@@ -146,6 +151,7 @@ pub use auth_middleware::{
|
|||||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
||||||
};
|
};
|
||||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
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 cron_scheduler::spawn_cron_scheduler;
|
||||||
pub use dead_letter_repo::{
|
pub use dead_letter_repo::{
|
||||||
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
||||||
@@ -200,11 +206,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
|||||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||||
pub use secrets_repo::{
|
pub use secrets_repo::{
|
||||||
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||||
SecretsRepoError, StoredSecret,
|
SecretsRepoError, StoredSecret,
|
||||||
};
|
};
|
||||||
pub use secrets_service::{
|
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,
|
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||||
};
|
};
|
||||||
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
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 triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||||
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||||
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
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;
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
|
||||||
//! (v1.1.7).
|
|
||||||
//!
|
//!
|
||||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
|
||||||
//! (NEVER values).
|
//! (NEVER values).
|
||||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
|
||||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a 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
|
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
|
||||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
|
||||||
//! returns only names + timestamps — the dashboard never receives
|
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
|
||||||
//! plaintext. Values are encrypted with the process master key before
|
//! the masked-secret boundary: a descendant app's dev can see that a group
|
||||||
//! they touch the database (same envelope as the script `secrets::set`).
|
//! 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;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -19,19 +30,24 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
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::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
|
use crate::group_repo::GroupRepository;
|
||||||
use crate::secrets_service::seal;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct SecretsState {
|
pub struct SecretsState {
|
||||||
pub repo: Arc<dyn SecretsRepo>,
|
pub repo: Arc<dyn SecretsRepo>,
|
||||||
pub apps: Arc<dyn AppRepository>,
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub groups: Arc<dyn GroupRepository>,
|
||||||
pub authz: Arc<dyn AuthzRepo>,
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub master_key: MasterKey,
|
pub master_key: MasterKey,
|
||||||
pub max_value_bytes: usize,
|
pub max_value_bytes: usize,
|
||||||
@@ -39,10 +55,25 @@ pub struct SecretsState {
|
|||||||
|
|
||||||
pub fn secrets_router(state: SecretsState) -> Router {
|
pub fn secrets_router(state: SecretsState) -> Router {
|
||||||
Router::new()
|
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(
|
.route(
|
||||||
"/apps/{app_id}/secrets/{name}",
|
"/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)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
@@ -55,9 +86,18 @@ pub struct ListQuery {
|
|||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EnvQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
struct SecretItem {
|
struct SecretItem {
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Environment scope — `*` for app secrets, possibly a concrete env for
|
||||||
|
/// group secrets.
|
||||||
|
env: String,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +107,23 @@ struct ListSecretsResponse {
|
|||||||
next_cursor: Option<String>,
|
next_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App handlers (env-agnostic, scope `*`)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_app_secrets(
|
||||||
State(s): State<SecretsState>,
|
State(s): State<SecretsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
@@ -80,32 +136,10 @@ async fn list_secrets(
|
|||||||
Capability::AppSecretsRead(app_id),
|
Capability::AppSecretsRead(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let page = s
|
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
|
||||||
.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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
async fn set_app_secret(
|
||||||
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(
|
|
||||||
State(s): State<SecretsState>,
|
State(s): State<SecretsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
@@ -118,23 +152,17 @@ async fn set_secret(
|
|||||||
Capability::AppSecretsWrite(app_id),
|
Capability::AppSecretsWrite(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
validate_secret_name(&input.name)?;
|
// App secrets are always env-agnostic; reject a stray `env`.
|
||||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
|
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
|
||||||
// (app_id, name). Same path as the SDK secrets::set.
|
return Err(SecretsApiError::Invalid(
|
||||||
let (ciphertext, nonce, version) = seal(
|
"app secrets are env-agnostic; set an environment scope on a group secret instead"
|
||||||
&s.master_key,
|
.into(),
|
||||||
app_id,
|
));
|
||||||
&input.name,
|
}
|
||||||
&input.value,
|
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
|
||||||
s.max_value_bytes,
|
|
||||||
)?;
|
|
||||||
s.repo
|
|
||||||
.set(app_id, &input.name, &ciphertext, &nonce, version)
|
|
||||||
.await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_secret(
|
async fn delete_app_secret(
|
||||||
State(s): State<SecretsState>,
|
State(s): State<SecretsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Path((id_or_slug, name)): Path<(String, String)>,
|
Path((id_or_slug, name)): Path<(String, String)>,
|
||||||
@@ -146,12 +174,161 @@ async fn delete_secret(
|
|||||||
Capability::AppSecretsWrite(app_id),
|
Capability::AppSecretsWrite(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.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<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<ListQuery>,
|
||||||
|
) -> Result<Json<ListSecretsResponse>, 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<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SetSecretRequest>,
|
||||||
|
) -> Result<StatusCode, SecretsApiError> {
|
||||||
|
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<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, name)): Path<(String, String)>,
|
||||||
|
Query(q): Query<EnvQuery>,
|
||||||
|
) -> Result<StatusCode, SecretsApiError> {
|
||||||
|
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<SecretsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, name)): Path<(String, String)>,
|
||||||
|
Query(q): Query<EnvQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<Json<ListSecretsResponse>, 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<StatusCode, SecretsApiError> {
|
||||||
|
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<StatusCode, SecretsApiError> {
|
||||||
|
if !repo.delete(owner, env, name).await? {
|
||||||
return Err(SecretsApiError::NotFound);
|
return Err(SecretsApiError::NotFound);
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Resolution + validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
||||||
crate::app_repo::resolve_app(apps, ident)
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
.await
|
.await
|
||||||
@@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
|
|||||||
.ok_or(SecretsApiError::AppNotFound)
|
.ok_or(SecretsApiError::AppNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_group(
|
||||||
|
groups: &dyn GroupRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<GroupId, SecretsApiError> {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum SecretsApiError {
|
pub enum SecretsApiError {
|
||||||
#[error("app not found")]
|
#[error("app not found")]
|
||||||
AppNotFound,
|
AppNotFound,
|
||||||
|
#[error("group not found")]
|
||||||
|
GroupNotFound,
|
||||||
#[error("secret not found")]
|
#[error("secret not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
#[error("invalid request: {0}")]
|
#[error("invalid request: {0}")]
|
||||||
@@ -214,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
|
|||||||
impl IntoResponse for SecretsApiError {
|
impl IntoResponse for SecretsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
Self::AppNotFound | Self::NotFound => {
|
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
}
|
}
|
||||||
Self::Invalid(_) => (
|
Self::Invalid(_) => (
|
||||||
|
|||||||
@@ -2,13 +2,23 @@
|
|||||||
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
|
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
|
||||||
//! encoding, authorization, name validation, and the value-size cap all
|
//! encoding, authorization, name validation, and the value-size cap all
|
||||||
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
|
//! 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 async_trait::async_trait;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::AppId;
|
use picloud_shared::{AppId, GroupId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum SecretsRepoError {
|
pub enum SecretsRepoError {
|
||||||
@@ -19,14 +29,22 @@ pub enum SecretsRepoError {
|
|||||||
InvalidCursor,
|
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
|
/// An encrypted secret as it lives on disk: ciphertext (auth tag
|
||||||
/// appended) plus the nonce it was sealed with.
|
/// appended) plus the nonce it was sealed with.
|
||||||
///
|
///
|
||||||
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
|
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
|
||||||
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
|
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
|
||||||
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
|
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
|
||||||
/// `0042_secrets_envelope_version.sql` adds the column with a default
|
|
||||||
/// of `0`, so existing rows keep working.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StoredSecret {
|
pub struct StoredSecret {
|
||||||
pub encrypted_value: Vec<u8>,
|
pub encrypted_value: Vec<u8>,
|
||||||
@@ -34,11 +52,21 @@ pub struct StoredSecret {
|
|||||||
pub version: i16,
|
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 —
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SecretMeta {
|
pub struct SecretMeta {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub environment_scope: String,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,38 +88,62 @@ pub struct SecretsMetaPage {
|
|||||||
/// substitute an in-memory backing without Postgres.
|
/// substitute an in-memory backing without Postgres.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SecretsRepo: Send + Sync {
|
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,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
) -> Result<Option<ResolvedSecret>, 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<Option<StoredSecret>, SecretsRepoError>;
|
) -> Result<Option<StoredSecret>, SecretsRepoError>;
|
||||||
|
|
||||||
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
|
/// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
|
||||||
/// discriminator from [`StoredSecret::version`].
|
/// `version` is the AES-GCM envelope discriminator.
|
||||||
async fn set(
|
async fn set(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
encrypted_value: &[u8],
|
encrypted_value: &[u8],
|
||||||
nonce: &[u8],
|
nonce: &[u8],
|
||||||
version: i16,
|
version: i16,
|
||||||
) -> Result<(), SecretsRepoError>;
|
) -> Result<(), SecretsRepoError>;
|
||||||
|
|
||||||
/// Delete; returns whether a row was present.
|
/// Delete one `(owner, env_scope, name)` row; returns whether a row
|
||||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
|
/// was present.
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<bool, SecretsRepoError>;
|
||||||
|
|
||||||
/// 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(
|
async fn list_names(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsNamePage, SecretsRepoError>;
|
) -> Result<SecretsNamePage, SecretsRepoError>;
|
||||||
|
|
||||||
/// 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(
|
async fn list_meta(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsMetaPage, SecretsRepoError>;
|
) -> Result<SecretsMetaPage, SecretsRepoError>;
|
||||||
@@ -131,21 +183,98 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
|
|||||||
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
|
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]
|
#[async_trait]
|
||||||
impl SecretsRepo for PostgresSecretsRepo {
|
impl SecretsRepo for PostgresSecretsRepo {
|
||||||
async fn get(
|
async fn resolve(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
|
||||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
|
// Reuse the shared chain-walk ($1 = app_id), join secrets by name,
|
||||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
// env-filter, and take the nearest level — `@E` beating `*` within a
|
||||||
WHERE app_id = $1 AND name = $2",
|
// 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<u8>, Vec<u8>, 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)
|
async fn get(
|
||||||
.await?;
|
&self,
|
||||||
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<StoredSecret>, 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<u8>, Vec<u8>, 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 {
|
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
|
||||||
encrypted_value,
|
encrypted_value,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo {
|
|||||||
|
|
||||||
async fn set(
|
async fn set(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
encrypted_value: &[u8],
|
encrypted_value: &[u8],
|
||||||
nonce: &[u8],
|
nonce: &[u8],
|
||||||
version: i16,
|
version: i16,
|
||||||
) -> Result<(), SecretsRepoError> {
|
) -> Result<(), SecretsRepoError> {
|
||||||
sqlx::query(
|
// Owner-kind-specific SQL: write only the owner's nullable column and
|
||||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
|
// restate the partial-unique predicate as the ON CONFLICT arbiter.
|
||||||
VALUES ($1, $2, $3, $4, $5) \
|
let (col, id) = owner_bind(owner);
|
||||||
ON CONFLICT (app_id, name) DO UPDATE \
|
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, \
|
SET encrypted_value = EXCLUDED.encrypted_value, \
|
||||||
nonce = EXCLUDED.nonce, \
|
nonce = EXCLUDED.nonce, \
|
||||||
version = EXCLUDED.version, \
|
version = EXCLUDED.version, \
|
||||||
updated_at = NOW()",
|
updated_at = NOW()"
|
||||||
)
|
);
|
||||||
.bind(app_id.into_inner())
|
sqlx::query(&sql)
|
||||||
.bind(name)
|
.bind(id)
|
||||||
.bind(encrypted_value)
|
.bind(env_scope)
|
||||||
.bind(nonce)
|
.bind(name)
|
||||||
.bind(version)
|
.bind(encrypted_value)
|
||||||
.execute(&self.pool)
|
.bind(nonce)
|
||||||
.await?;
|
.bind(version)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
async fn delete(
|
||||||
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
|
&self,
|
||||||
.bind(app_id.into_inner())
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<bool, SecretsRepoError> {
|
||||||
|
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)
|
.bind(name)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo {
|
|||||||
|
|
||||||
async fn list_names(
|
async fn list_names(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||||
@@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let take = i64::from(limit) + 1;
|
let take = i64::from(limit) + 1;
|
||||||
let rows: Vec<(String,)> = sqlx::query_as(
|
let (col, id) = owner_bind(owner);
|
||||||
"SELECT name FROM secrets \
|
// DISTINCT collapses a name that exists at several env scopes into
|
||||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
// one entry — the SDK `list` is a name catalogue, not per-scope.
|
||||||
ORDER BY name ASC LIMIT $3",
|
let sql = format!(
|
||||||
)
|
"SELECT DISTINCT name FROM secrets \
|
||||||
.bind(app_id.into_inner())
|
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
|
||||||
.bind(last_name.as_deref())
|
ORDER BY name ASC LIMIT $3"
|
||||||
.bind(take)
|
);
|
||||||
.fetch_all(&self.pool)
|
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
||||||
.await?;
|
.bind(id)
|
||||||
|
.bind(last_name.as_deref())
|
||||||
|
.bind(take)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
|
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
|
||||||
let next_cursor = if names.len() > limit as usize {
|
let next_cursor = if names.len() > limit as usize {
|
||||||
@@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo {
|
|||||||
|
|
||||||
async fn list_meta(
|
async fn list_meta(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||||
let limit = clamp_limit(limit);
|
let limit = clamp_limit(limit);
|
||||||
let last_name = match cursor {
|
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
|
||||||
Some(c) => Some(decode_cursor(c)?),
|
let last = match cursor {
|
||||||
|
Some(c) => Some(decode_meta_cursor(c)?),
|
||||||
None => None,
|
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 take = i64::from(limit) + 1;
|
||||||
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
|
let (col, id) = owner_bind(owner);
|
||||||
"SELECT name, updated_at FROM secrets \
|
let sql = format!(
|
||||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
"SELECT name, environment_scope, updated_at FROM secrets \
|
||||||
ORDER BY name ASC LIMIT $3",
|
WHERE {col} = $1 \
|
||||||
)
|
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
|
||||||
.bind(app_id.into_inner())
|
ORDER BY name ASC, environment_scope ASC LIMIT $4"
|
||||||
.bind(last_name.as_deref())
|
);
|
||||||
.bind(take)
|
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
|
||||||
.fetch_all(&self.pool)
|
.bind(id)
|
||||||
.await?;
|
.bind(last_name)
|
||||||
|
.bind(last_scope)
|
||||||
|
.bind(take)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut items: Vec<SecretMeta> = rows
|
let mut items: Vec<SecretMeta> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, updated_at)| SecretMeta { name, updated_at })
|
.map(|(name, environment_scope, updated_at)| SecretMeta {
|
||||||
|
name,
|
||||||
|
environment_scope,
|
||||||
|
updated_at,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let next_cursor = if items.len() > limit as usize {
|
let next_cursor = if items.len() > limit as usize {
|
||||||
items.truncate(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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,22 +22,39 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||||
SecretsService,
|
SecretsService,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::authz::{self, AuthzRepo, Capability};
|
use crate::authz::{self, AuthzRepo, Capability};
|
||||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
|
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.
|
/// Current AES-GCM envelope version for the per-app secret store.
|
||||||
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
|
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
|
||||||
pub const SECRET_ENVELOPE_V1: i16 = 1;
|
pub const SECRET_ENVELOPE_V1: i16 = 1;
|
||||||
|
|
||||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a
|
/// The env scope under which an app's OWN secrets are stored. App secrets
|
||||||
/// cross-row swap (e.g. moving one app's ciphertext under another
|
/// are env-agnostic — only group secrets carry a concrete environment.
|
||||||
/// app's `(app_id, name)` slot) fails decryption.
|
const APP_SECRET_SCOPE: &str = "*";
|
||||||
fn secret_aad(app_id: AppId, name: &str) -> Vec<u8> {
|
|
||||||
format!("secret:{app_id}:{name}").into_bytes()
|
/// 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<u8> {
|
||||||
|
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
|
/// 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`).
|
/// failure (should not happen for a `serde_json::Value`).
|
||||||
pub fn seal(
|
pub fn seal(
|
||||||
master_key: &MasterKey,
|
master_key: &MasterKey,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
name: &str,
|
name: &str,
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
max_value_bytes: usize,
|
max_value_bytes: usize,
|
||||||
@@ -109,7 +126,7 @@ pub fn seal(
|
|||||||
actual: plaintext.len(),
|
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());
|
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
|
||||||
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
|
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
|
||||||
}
|
}
|
||||||
@@ -125,7 +142,7 @@ pub fn seal(
|
|||||||
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
||||||
pub fn open(
|
pub fn open(
|
||||||
master_key: &MasterKey,
|
master_key: &MasterKey,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
name: &str,
|
name: &str,
|
||||||
stored: &StoredSecret,
|
stored: &StoredSecret,
|
||||||
) -> Result<serde_json::Value, SecretsError> {
|
) -> Result<serde_json::Value, SecretsError> {
|
||||||
@@ -136,7 +153,7 @@ pub fn open(
|
|||||||
master_key.as_bytes(),
|
master_key.as_bytes(),
|
||||||
),
|
),
|
||||||
SECRET_ENVELOPE_V1 => {
|
SECRET_ENVELOPE_V1 => {
|
||||||
let aad = secret_aad(app_id, name);
|
let aad = secret_aad(owner, name);
|
||||||
crypto::decrypt_with_aad(
|
crypto::decrypt_with_aad(
|
||||||
&stored.encrypted_value,
|
&stored.encrypted_value,
|
||||||
&stored.nonce,
|
&stored.nonce,
|
||||||
@@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl {
|
|||||||
) -> Result<Option<serde_json::Value>, SecretsError> {
|
) -> Result<Option<serde_json::Value>, SecretsError> {
|
||||||
validate_secret_name(name)?;
|
validate_secret_name(name)?;
|
||||||
self.check_read(cx).await?;
|
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);
|
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)),
|
Ok(value) => Ok(Some(value)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A decrypt failure is operationally significant — surface
|
// A decrypt failure is operationally significant — surface
|
||||||
@@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl {
|
|||||||
) -> Result<(), SecretsError> {
|
) -> Result<(), SecretsError> {
|
||||||
validate_secret_name(name)?;
|
validate_secret_name(name)?;
|
||||||
self.check_write(cx).await?;
|
self.check_write(cx).await?;
|
||||||
let (ciphertext, nonce, version) = seal(
|
let owner = SecretOwner::App(cx.app_id);
|
||||||
&self.master_key,
|
let (ciphertext, nonce, version) =
|
||||||
cx.app_id,
|
seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
|
||||||
name,
|
|
||||||
&value,
|
|
||||||
self.max_value_bytes,
|
|
||||||
)?;
|
|
||||||
self.repo
|
self.repo
|
||||||
.set(cx.app_id, name, &ciphertext, &nonce, version)
|
.set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl {
|
|||||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||||
validate_secret_name(name)?;
|
validate_secret_name(name)?;
|
||||||
self.check_write(cx).await?;
|
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(
|
async fn list(
|
||||||
@@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsListPage, SecretsError> {
|
) -> Result<SecretsListPage, SecretsError> {
|
||||||
self.check_read(cx).await?;
|
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 {
|
Ok(SecretsListPage {
|
||||||
names: page.names,
|
names: page.names,
|
||||||
next_cursor: page.next_cursor,
|
next_cursor: page.next_cursor,
|
||||||
@@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::authz::{AuthzError, AuthzRepo};
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
|
use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
|
||||||
UserId,
|
ScriptId, UserId,
|
||||||
};
|
};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use tokio::sync::Mutex;
|
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)]
|
#[derive(Default)]
|
||||||
struct InMemorySecretsRepo {
|
struct InMemorySecretsRepo {
|
||||||
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
|
data: Mutex<BTreeMap<(String, String, String), StoredSecret>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owner_key(owner: SecretOwner) -> String {
|
||||||
|
match owner {
|
||||||
|
SecretOwner::App(a) => format!("app:{a}"),
|
||||||
|
SecretOwner::Group(g) => format!("group:{g}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SecretsRepo for InMemorySecretsRepo {
|
impl SecretsRepo for InMemorySecretsRepo {
|
||||||
async fn get(
|
async fn resolve(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
) -> Result<Option<ResolvedSecret>, 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<Option<StoredSecret>, SecretsRepoError> {
|
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(&(app_id, name.to_string()))
|
.get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
async fn set(
|
async fn set(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
encrypted_value: &[u8],
|
encrypted_value: &[u8],
|
||||||
nonce: &[u8],
|
nonce: &[u8],
|
||||||
version: i16,
|
version: i16,
|
||||||
) -> Result<(), SecretsRepoError> {
|
) -> Result<(), SecretsRepoError> {
|
||||||
self.data.lock().await.insert(
|
self.data.lock().await.insert(
|
||||||
(app_id, name.to_string()),
|
(owner_key(owner), env_scope.to_string(), name.to_string()),
|
||||||
StoredSecret {
|
StoredSecret {
|
||||||
encrypted_value: encrypted_value.to_vec(),
|
encrypted_value: encrypted_value.to_vec(),
|
||||||
nonce: nonce.to_vec(),
|
nonce: nonce.to_vec(),
|
||||||
@@ -374,29 +430,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
owner: SecretOwner,
|
||||||
|
env_scope: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<bool, SecretsRepoError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.remove(&(app_id, name.to_string()))
|
.remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||||
.is_some())
|
.is_some())
|
||||||
}
|
}
|
||||||
async fn list_names(
|
async fn list_names(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
owner: SecretOwner,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||||
let data = self.data.lock().await;
|
let data = self.data.lock().await;
|
||||||
|
let ok = owner_key(owner);
|
||||||
let last = cursor.map(std::string::ToString::to_string);
|
let last = cursor.map(std::string::ToString::to_string);
|
||||||
let mut names: Vec<String> = data
|
let mut names: Vec<String> = data
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|((a, _), _)| *a == app_id)
|
.filter(|((o, _, _), _)| *o == ok)
|
||||||
.map(|((_, n), _)| n.clone())
|
.map(|((_, _, n), _)| n.clone())
|
||||||
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
||||||
.collect();
|
.collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
let take = (limit as usize).max(1);
|
let take = (limit as usize).max(1);
|
||||||
let next_cursor = if names.len() > take {
|
let next_cursor = if names.len() > take {
|
||||||
names.truncate(take);
|
names.truncate(take);
|
||||||
@@ -408,7 +471,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
async fn list_meta(
|
async fn list_meta(
|
||||||
&self,
|
&self,
|
||||||
_app_id: AppId,
|
_owner: SecretOwner,
|
||||||
_cursor: Option<&str>,
|
_cursor: Option<&str>,
|
||||||
_limit: u32,
|
_limit: u32,
|
||||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||||
@@ -635,7 +698,11 @@ mod tests {
|
|||||||
repo.data
|
repo.data
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get_mut(&(app, "k".to_string()))
|
.get_mut(&(
|
||||||
|
owner_key(SecretOwner::App(app)),
|
||||||
|
"*".to_string(),
|
||||||
|
"k".to_string(),
|
||||||
|
))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.encrypted_value[0] ^= 0xff;
|
.encrypted_value[0] ^= 0xff;
|
||||||
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
||||||
@@ -666,10 +733,21 @@ mod tests {
|
|||||||
.data
|
.data
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(&(a, "k".to_string()))
|
.get(&(
|
||||||
|
owner_key(SecretOwner::App(a)),
|
||||||
|
"*".to_string(),
|
||||||
|
"k".to_string(),
|
||||||
|
))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap();
|
.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();
|
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, SecretsError::Corrupted),
|
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]
|
#[tokio::test]
|
||||||
async fn aad_blocks_cross_name_ciphertext_swap() {
|
async fn aad_blocks_cross_name_ciphertext_swap() {
|
||||||
// Same app, different name → AAD mismatch.
|
// Same app, different name → AAD mismatch.
|
||||||
@@ -695,13 +822,21 @@ mod tests {
|
|||||||
.data
|
.data
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(&(a, "real".to_string()))
|
.get(&(
|
||||||
|
owner_key(SecretOwner::App(a)),
|
||||||
|
"*".to_string(),
|
||||||
|
"real".to_string(),
|
||||||
|
))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.data
|
repo.data.lock().await.insert(
|
||||||
.lock()
|
(
|
||||||
.await
|
owner_key(SecretOwner::App(a)),
|
||||||
.insert((a, "renamed".to_string()), stolen);
|
"*".to_string(),
|
||||||
|
"renamed".to_string(),
|
||||||
|
),
|
||||||
|
stolen,
|
||||||
|
);
|
||||||
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
|
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
|
||||||
assert!(matches!(err, SecretsError::Corrupted));
|
assert!(matches!(err, SecretsError::Corrupted));
|
||||||
}
|
}
|
||||||
@@ -726,7 +861,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.data.lock().await.insert(
|
repo.data.lock().await.insert(
|
||||||
(app, "old".to_string()),
|
(
|
||||||
|
owner_key(SecretOwner::App(app)),
|
||||||
|
"*".to_string(),
|
||||||
|
"old".to_string(),
|
||||||
|
),
|
||||||
StoredSecret {
|
StoredSecret {
|
||||||
encrypted_value: ct,
|
encrypted_value: ct,
|
||||||
nonce: nonce.to_vec(),
|
nonce: nonce.to_vec(),
|
||||||
|
|||||||
410
crates/manager-core/src/vars_api.rs
Normal file
410
crates/manager-core/src/vars_api.rs
Normal file
@@ -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<dyn VarsRepo>,
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub groups: Arc<dyn GroupRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct VarItem {
|
||||||
|
key: String,
|
||||||
|
env: String,
|
||||||
|
value: serde_json::Value,
|
||||||
|
is_tombstone: bool,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
struct ListVarsResponse {
|
||||||
|
vars: Vec<VarItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_app_vars(
|
||||||
|
State(s): State<VarsApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<ListVarsResponse>, 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<VarsApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SetVarRequest>,
|
||||||
|
) -> Result<StatusCode, VarsApiError> {
|
||||||
|
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<VarsApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, key)): Path<(String, String)>,
|
||||||
|
Query(q): Query<EnvQuery>,
|
||||||
|
) -> Result<StatusCode, VarsApiError> {
|
||||||
|
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<VarsApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<ListVarsResponse>, 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<VarsApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SetVarRequest>,
|
||||||
|
) -> Result<StatusCode, VarsApiError> {
|
||||||
|
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<VarsApiState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, key)): Path<(String, String)>,
|
||||||
|
Query(q): Query<EnvQuery>,
|
||||||
|
) -> Result<StatusCode, VarsApiError> {
|
||||||
|
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<Json<ListVarsResponse>, 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<StatusCode, VarsApiError> {
|
||||||
|
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<StatusCode, VarsApiError> {
|
||||||
|
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<AppId, VarsApiError> {
|
||||||
|
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<GroupId, VarsApiError> {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
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<AuthzDenied> for VarsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for VarsApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VarsRepoError> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
210
crates/manager-core/src/vars_repo.rs
Normal file
210
crates/manager-core/src/vars_repo.rs
Normal file
@@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<bool, VarsRepoError>;
|
||||||
|
|
||||||
|
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
|
||||||
|
/// (key, environment_scope).
|
||||||
|
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, 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<bool, VarsRepoError> {
|
||||||
|
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<Vec<VarRow>, VarsRepoError> {
|
||||||
|
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/manager-core/src/vars_service.rs
Normal file
63
crates/manager-core/src/vars_service.rs
Normal file
@@ -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<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VarsServiceImpl {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> 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<BTreeMap<String, Value>, 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<Option<Value>, VarsError> {
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
Ok(self.resolved(cx).await?.remove(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||||
|
self.check_read(cx).await?;
|
||||||
|
self.resolved(cx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -657,39 +657,138 @@ impl Client {
|
|||||||
decode_status(resp).await
|
decode_status(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/v1/admin/apps/{id}/secrets`
|
/// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names +
|
||||||
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
|
/// last-modified for the owner. Values never travel on this path. `env`
|
||||||
let app = seg(app);
|
/// is only meaningful for group owners (app secrets are env-agnostic).
|
||||||
|
pub async fn secrets_list(
|
||||||
|
&self,
|
||||||
|
owner: VarOwnerArg<'_>,
|
||||||
|
env: Option<&str>,
|
||||||
|
) -> Result<SecretListDto> {
|
||||||
|
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
|
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<SecretValueDto> {
|
||||||
|
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<VarListDto> {
|
||||||
|
let resp = self
|
||||||
|
.request(Method::GET, &format!("{}/vars", owner.base_path()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/v1/admin/apps/{id}/secrets`
|
/// `PUT /api/v1/admin/{apps,groups}/{id}/vars`
|
||||||
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
|
pub async fn vars_set(
|
||||||
// `name` travels in the JSON body, not the path — only `app` needs encoding.
|
&self,
|
||||||
let app = seg(app);
|
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
|
let resp = self
|
||||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
|
.request(Method::PUT, &format!("{}/vars", owner.base_path()))
|
||||||
.json(&serde_json::json!({ "name": name, "value": value }))
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
decode_status(resp).await
|
decode_status(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
|
/// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}`
|
||||||
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
|
pub async fn vars_delete(
|
||||||
let (app, name) = (seg(app), seg(name));
|
&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<EffectiveConfigDto> {
|
||||||
|
let app = seg(app);
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(
|
.request(
|
||||||
Method::DELETE,
|
Method::GET,
|
||||||
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
|
&format!("/api/v1/admin/apps/{app}/config/effective"),
|
||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
decode_status(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- domains ----------
|
// ---------- domains ----------
|
||||||
@@ -1394,6 +1493,37 @@ pub struct DeadLetterDto {
|
|||||||
pub resolution: Option<String>,
|
pub resolution: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<VarItemDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SecretListDto {
|
pub struct SecretListDto {
|
||||||
pub secrets: Vec<SecretItemDto>,
|
pub secrets: Vec<SecretItemDto>,
|
||||||
@@ -1405,9 +1535,72 @@ pub struct SecretListDto {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SecretItemDto {
|
pub struct SecretItemDto {
|
||||||
pub name: String,
|
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<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<MergedFromDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String, EffectiveVarDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets: std::collections::BTreeMap<String, EffectiveSecretDto>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-script runtime config the CLI can now set (G3). All optional — an
|
/// 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
|
/// unset field is omitted so the server applies its own default (and the
|
||||||
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
||||||
|
|||||||
@@ -1,26 +1,38 @@
|
|||||||
//! `pic config --effective` — read-only view of an app's resolved
|
//! `pic config --effective` — read-only view of an app's resolved
|
||||||
//! configuration, with secret values masked (§4.6).
|
//! configuration, with secret values masked (§4.6).
|
||||||
//!
|
//!
|
||||||
//! Single-app today: "config" means the app's secrets, cross-referenced
|
//! Two sections:
|
||||||
//! against the manifest so an operator can see at a glance which declared
|
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
|
||||||
//! secrets are still unset and which live secrets aren't declared. Values are
|
//! annotated with the owner that won (kind + depth) and its scope. Pass
|
||||||
//! never fetched or shown — only `<set>` / `<unset>`. The command is shaped to
|
//! `--explain` to also dump each var's `merged_from` provenance — the
|
||||||
//! grow an `--explain` mode and inherited `vars` once groups/vars land (the
|
//! ordered (depth, scope) layers that fed the resolution.
|
||||||
//! Phase-3 multi-level 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 `<set>` / `<unset>` 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::collections::BTreeSet;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::{Client, EffectiveOwnerDto};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::manifest::Manifest;
|
use crate::manifest::Manifest;
|
||||||
use crate::output::{OutputMode, Table};
|
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(
|
pub async fn run(
|
||||||
manifest_path: &Path,
|
manifest_path: &Path,
|
||||||
effective: bool,
|
effective: bool,
|
||||||
|
explain: bool,
|
||||||
env: Option<&str>,
|
env: Option<&str>,
|
||||||
mode: OutputMode,
|
mode: OutputMode,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -34,24 +46,54 @@ pub async fn run(
|
|||||||
// base slug — when an overlay re-points slug/secrets.
|
// base slug — when an overlay re-points slug/secrets.
|
||||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||||
|
|
||||||
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
let eff = client.config_effective(&manifest.app.slug).await?;
|
||||||
let on_server: BTreeSet<String> = client
|
|
||||||
.secrets_list(&manifest.app.slug)
|
|
||||||
.await?
|
|
||||||
.secrets
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| s.name)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
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<String> = manifest.secrets.names.iter().cloned().collect();
|
||||||
|
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
|
||||||
|
|
||||||
|
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
|
||||||
for name in declared.union(&on_server) {
|
for name in declared.union(&on_server) {
|
||||||
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
||||||
(true, true) => ("<set>", "managed"),
|
(true, true) => ("<set>", "managed"),
|
||||||
(true, false) => ("<unset>", "declared, not pushed — `pic secret set`"),
|
(true, false) => ("<unset>", "declared, not pushed — `pic secrets set`"),
|
||||||
(false, true) => ("<set>", "on server, not in manifest"),
|
(false, true) => ("<set>", "on server, not in manifest"),
|
||||||
(false, false) => unreachable!("name came from one of the two sets"),
|
(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);
|
table.print(mode);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ pub mod secrets;
|
|||||||
pub mod topics;
|
pub mod topics;
|
||||||
pub mod triggers;
|
pub mod triggers;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod vars;
|
||||||
pub mod whoami;
|
pub mod whoami;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use anyhow::{Context, Result};
|
|||||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::{Client, VarOwnerArg};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::manifest::{
|
use crate::manifest::{
|
||||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
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 app = client.apps_get(app_ident).await?;
|
||||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
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<ScriptId, String> =
|
let name_by_id: HashMap<ScriptId, String> =
|
||||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||||
|
|||||||
@@ -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 —
|
//! Exactly one of `--group` / `--app` selects the owner (mirroring
|
||||||
//! inline values would leak into shell history). The value is sent
|
//! `pic vars`). Set reads the secret value from stdin (the only safe
|
||||||
//! as a JSON string; pass `--json` to interpret stdin as raw JSON
|
//! channel — inline values would leak into shell history). The value is
|
||||||
//! (numbers, maps, …) instead.
|
//! 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 std::io::Read;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
|
||||||
use crate::client::Client;
|
use crate::client::{Client, VarOwnerArg};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::output::{OutputMode, Table};
|
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<VarOwnerArg<'a>> {
|
||||||
|
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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let resp = client.secrets_list(app).await?;
|
let resp = client.secrets_list(owner, env).await?;
|
||||||
let mut table = Table::new(["name", "updated_at"]);
|
let mut table = Table::new(["name", "env", "updated_at"]);
|
||||||
for s in resp.secrets {
|
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);
|
table.print(mode);
|
||||||
Ok(())
|
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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
@@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
serde_json::Value::String(trimmed.to_string())
|
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}");
|
println!("Set secret {name}");
|
||||||
Ok(())
|
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 creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
client.secrets_delete(app, name).await?;
|
client.secrets_delete(owner, name, env).await?;
|
||||||
println!("Deleted secret {name}");
|
println!("Deleted secret {name}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `pic secrets read --group <slug> <name>` — 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(())
|
||||||
|
}
|
||||||
|
|||||||
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
@@ -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<VarOwnerArg<'a>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -144,6 +144,14 @@ enum Cmd {
|
|||||||
cmd: MembersCmd,
|
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
|
/// Files inspection — list a collection's blobs, download bytes, or
|
||||||
/// delete a file. Read + delete only; writes go through scripts.
|
/// delete a file. Read + delete only; writes go through scripts.
|
||||||
Files {
|
Files {
|
||||||
@@ -259,6 +267,10 @@ struct ConfigArgs {
|
|||||||
/// Show the effective (resolved) config with secrets masked.
|
/// Show the effective (resolved) config with secrets masked.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
effective: bool,
|
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.<env>.toml` overlay (per-env slug +
|
/// Resolve against the `picloud.<env>.toml` overlay (per-env slug +
|
||||||
/// secrets), matching `pic plan --env` / `pic apply --env`.
|
/// secrets), matching `pic plan --env` / `pic apply --env`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -1134,31 +1146,114 @@ enum DeadLettersCmd {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum SecretsCmd {
|
enum SecretsCmd {
|
||||||
/// List secret names + last-modified for an app. Values never
|
/// List secret names + last-modified for the owner. Values never
|
||||||
/// leave the server.
|
/// leave the server. `--env` filters group secrets (app secrets are
|
||||||
|
/// env-agnostic; the `env` column shows the scope for groups).
|
||||||
Ls {
|
Ls {
|
||||||
|
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
|
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
/// Environment scope (group owners only).
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Set a secret. Reads the value from stdin (the only safe
|
/// Set a secret. Reads the value from stdin (the only safe
|
||||||
/// channel — inline values would land in shell history). Pipe
|
/// channel — inline values would land in shell history). Pipe
|
||||||
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
|
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
|
||||||
|
/// For group owners, `--env` scopes the secret.
|
||||||
Set {
|
Set {
|
||||||
|
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
|
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Environment scope (group owners only).
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
|
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
|
||||||
/// string literal.
|
/// string literal.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Delete a secret by name.
|
/// Delete a secret by name (optionally env-scoped for groups).
|
||||||
Rm {
|
Rm {
|
||||||
|
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
app: String,
|
group: Option<String>,
|
||||||
|
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Environment scope (group owners only).
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
/// Environment scope (`*` = env-agnostic, the default).
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
env: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1238,7 +1333,14 @@ async fn main() -> ExitCode {
|
|||||||
Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
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::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
||||||
Cmd::Config(args) => {
|
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(
|
Cmd::Init(args) => cmds::init::run(
|
||||||
&args.dir,
|
&args.dir,
|
||||||
@@ -1741,14 +1843,39 @@ async fn main() -> ExitCode {
|
|||||||
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
|
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
|
||||||
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
|
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
|
||||||
Cmd::Secrets {
|
Cmd::Secrets {
|
||||||
cmd: SecretsCmd::Ls { app },
|
cmd: SecretsCmd::Ls { group, app, env },
|
||||||
} => cmds::secrets::ls(&app, mode).await,
|
} => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await,
|
||||||
Cmd::Secrets {
|
Cmd::Secrets {
|
||||||
cmd: SecretsCmd::Set { app, name, json },
|
cmd:
|
||||||
} => cmds::secrets::set(&app, &name, json).await,
|
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::Secrets {
|
||||||
cmd: SecretsCmd::Rm { app, name },
|
cmd:
|
||||||
} => cmds::secrets::rm(&app, &name).await,
|
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::Members {
|
||||||
cmd: MembersCmd::Ls { app },
|
cmd: MembersCmd::Ls { app },
|
||||||
} => cmds::members::ls(&app, mode).await,
|
} => cmds::members::ls(&app, mode).await,
|
||||||
@@ -1761,6 +1888,41 @@ async fn main() -> ExitCode {
|
|||||||
Cmd::Members {
|
Cmd::Members {
|
||||||
cmd: MembersCmd::Rm { app, user_id },
|
cmd: MembersCmd::Rm { app, user_id },
|
||||||
} => cmds::members::rm(&app, &user_id).await,
|
} => 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::Files {
|
||||||
cmd:
|
cmd:
|
||||||
FilesCmd::Ls {
|
FilesCmd::Ls {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ mod dead_letters;
|
|||||||
mod email_queue;
|
mod email_queue;
|
||||||
mod enabled;
|
mod enabled;
|
||||||
mod env_overlay;
|
mod env_overlay;
|
||||||
|
mod group_secrets;
|
||||||
mod groups;
|
mod groups;
|
||||||
mod init;
|
mod init;
|
||||||
mod invoke;
|
mod invoke;
|
||||||
@@ -37,3 +38,4 @@ mod scripts;
|
|||||||
mod secrets;
|
mod secrets;
|
||||||
mod staleness;
|
mod staleness;
|
||||||
mod triggers;
|
mod triggers;
|
||||||
|
mod vars;
|
||||||
|
|||||||
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
@@ -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")
|
||||||
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
@@ -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")
|
||||||
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
@@ -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 <id>` (→ `/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")
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use predicates::prelude::*;
|
|||||||
use crate::common;
|
use crate::common;
|
||||||
|
|
||||||
/// Pick out the data rows from `pic logs` TSV output — the header line
|
/// 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.
|
/// "no non-empty lines means no logs" check needs to skip it.
|
||||||
fn data_rows(stdout: &str) -> Vec<&str> {
|
fn data_rows(stdout: &str) -> Vec<&str> {
|
||||||
stdout
|
stdout
|
||||||
@@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() {
|
|||||||
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cols.len(),
|
cols.len(),
|
||||||
3,
|
4,
|
||||||
"row should be 3 tab-delimited cells: {rows:?}"
|
"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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
@@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() {
|
|||||||
.next()
|
.next()
|
||||||
.expect("at least one data row");
|
.expect("at least one data row");
|
||||||
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
|
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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
@@ -166,7 +166,7 @@ fn logs_truncates_long_summary() {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.expect("at least one data row");
|
.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!(
|
assert!(
|
||||||
summary.ends_with('…'),
|
summary.ends_with('…'),
|
||||||
"summary should be truncated with `…`, got: {summary}"
|
"summary should be truncated with `…`, got: {summary}"
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() {
|
|||||||
])
|
])
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ use predicates::prelude::*;
|
|||||||
use crate::common;
|
use crate::common;
|
||||||
use crate::common::cleanup::AppGuard;
|
use crate::common::cleanup::AppGuard;
|
||||||
|
|
||||||
|
/// Extract a field value from a `pic` KvBlock (`key<pad>\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"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[test]
|
#[test]
|
||||||
fn deploy_against_unknown_app_errors() {
|
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 _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
let fixture = common::fixture_path("hello.rhai");
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
common::pic_as(&env)
|
let out = common::pic_as(&env)
|
||||||
.args([
|
.args([
|
||||||
"scripts",
|
"scripts",
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -58,11 +68,14 @@ fn deploy_with_name_override() {
|
|||||||
"--name",
|
"--name",
|
||||||
"custom-name",
|
"custom-name",
|
||||||
])
|
])
|
||||||
.assert()
|
.output()
|
||||||
.success()
|
.expect("deploy v1");
|
||||||
.stdout(predicate::str::contains("Created custom-name 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([
|
.args([
|
||||||
"scripts",
|
"scripts",
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -72,9 +85,11 @@ fn deploy_with_name_override() {
|
|||||||
"--name",
|
"--name",
|
||||||
"custom-name",
|
"custom-name",
|
||||||
])
|
])
|
||||||
.assert()
|
.output()
|
||||||
.success()
|
.expect("deploy v2");
|
||||||
.stdout(predicate::str::contains("Updated custom-name 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)
|
let out = common::pic_as(&env)
|
||||||
.args(["scripts", "ls", "--app", &slug])
|
.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 _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
let fixture = common::fixture_path("hello.rhai");
|
let fixture = common::fixture_path("hello.rhai");
|
||||||
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
|
for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] {
|
||||||
common::pic_as(&env)
|
let out = common::pic_as(&env)
|
||||||
.args([
|
.args([
|
||||||
"scripts",
|
"scripts",
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() {
|
|||||||
"--app",
|
"--app",
|
||||||
&slug,
|
&slug,
|
||||||
])
|
])
|
||||||
.assert()
|
.output()
|
||||||
.success()
|
.expect("deploy");
|
||||||
.stdout(predicate::str::contains(expected));
|
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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() {
|
|||||||
.expect("secrets ls");
|
.expect("secrets ls");
|
||||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
let header = stdout.lines().next().expect("header");
|
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!(
|
assert!(
|
||||||
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
|
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
|
||||||
"api_key missing from ls: {stdout}"
|
"api_key missing from ls: {stdout}"
|
||||||
|
|||||||
84
crates/picloud-cli/tests/vars.rs
Normal file
84
crates/picloud-cli/tests/vars.rs
Normal file
@@ -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 <id>` (→ `/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")
|
||||||
|
}
|
||||||
@@ -14,26 +14,27 @@ use picloud_manager_core::{
|
|||||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
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,
|
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
|
||||||
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
||||||
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
|
||||||
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||||
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
|
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
|
||||||
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
|
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
|
||||||
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
|
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||||
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
|
FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
|
||||||
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
|
||||||
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
|
||||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
|
||||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
|
||||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
|
||||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||||
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
|
||||||
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
|
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||||
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
|
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
|
||||||
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||||
TriggersState, UsersServiceConfig, UsersServiceImpl,
|
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
|
||||||
|
UsersServiceImpl, VarsApiState, VarsServiceImpl,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
@@ -322,6 +323,8 @@ pub async fn build_app(
|
|||||||
// under script-as-gate semantics.
|
// under script-as-gate semantics.
|
||||||
.with_authz(authz.clone()),
|
.with_authz(authz.clone()),
|
||||||
);
|
);
|
||||||
|
let vars: Arc<dyn picloud_shared::VarsService> =
|
||||||
|
Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone()));
|
||||||
let services = Services::new(
|
let services = Services::new(
|
||||||
kv,
|
kv,
|
||||||
docs,
|
docs,
|
||||||
@@ -336,6 +339,7 @@ pub async fn build_app(
|
|||||||
users.clone(),
|
users.clone(),
|
||||||
queue,
|
queue,
|
||||||
invoke,
|
invoke,
|
||||||
|
vars,
|
||||||
);
|
);
|
||||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||||
// trigger-depth bound (same counter under the hood).
|
// trigger-depth bound (same counter under the hood).
|
||||||
@@ -509,6 +513,7 @@ pub async fn build_app(
|
|||||||
let secrets_state = SecretsState {
|
let secrets_state = SecretsState {
|
||||||
repo: secrets_repo,
|
repo: secrets_repo,
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
|
groups: groups_repo.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
master_key,
|
master_key,
|
||||||
max_value_bytes: secrets_max_value_bytes,
|
max_value_bytes: secrets_max_value_bytes,
|
||||||
@@ -566,6 +571,17 @@ pub async fn build_app(
|
|||||||
users: auth.users.clone(),
|
users: auth.users.clone(),
|
||||||
authz: authz.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 {
|
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||||
apps: apps_state.apps.clone(),
|
apps: apps_state.apps.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
@@ -589,6 +605,8 @@ pub async fn build_app(
|
|||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
.merge(app_members_router(app_members_state))
|
.merge(app_members_router(app_members_state))
|
||||||
.merge(groups_router(groups_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(
|
.merge(picloud_manager_core::app_users_router(
|
||||||
app_users_admin_state,
|
app_users_admin_state,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub mod subscriber_token;
|
|||||||
pub mod trigger_event;
|
pub mod trigger_event;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod validator;
|
pub mod validator;
|
||||||
|
pub mod vars;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
/// serde `default` for `enabled`-style boolean fields that should default to
|
/// serde `default` for `enabled`-style boolean fields that should default to
|
||||||
@@ -101,4 +102,5 @@ pub use users::{
|
|||||||
UsersService,
|
UsersService,
|
||||||
};
|
};
|
||||||
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
||||||
|
pub use vars::{NoopVarsService, VarsError, VarsService};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ use crate::{
|
|||||||
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
||||||
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
||||||
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
|
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
|
/// 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).
|
/// and `invoke_async()` (fire-and-forget through the outbox).
|
||||||
/// Cross-app invokes are rejected at the service entry point.
|
/// Cross-app invokes are rejected at the service entry point.
|
||||||
pub invoke: Arc<dyn InvokeService>,
|
pub invoke: Arc<dyn InvokeService>,
|
||||||
|
|
||||||
|
/// 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<dyn VarsService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Services {
|
impl Services {
|
||||||
@@ -129,6 +136,7 @@ impl Services {
|
|||||||
users: Arc<dyn UsersService>,
|
users: Arc<dyn UsersService>,
|
||||||
queue: Arc<dyn QueueService>,
|
queue: Arc<dyn QueueService>,
|
||||||
invoke: Arc<dyn InvokeService>,
|
invoke: Arc<dyn InvokeService>,
|
||||||
|
vars: Arc<dyn VarsService>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kv,
|
kv,
|
||||||
@@ -144,6 +152,7 @@ impl Services {
|
|||||||
users,
|
users,
|
||||||
queue,
|
queue,
|
||||||
invoke,
|
invoke,
|
||||||
|
vars,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +177,7 @@ impl Services {
|
|||||||
Arc::new(NoopUsersService),
|
Arc::new(NoopUsersService),
|
||||||
Arc::new(NoopQueueService),
|
Arc::new(NoopQueueService),
|
||||||
Arc::new(NoopInvokeService),
|
Arc::new(NoopInvokeService),
|
||||||
|
Arc::new(NoopVarsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
crates/shared/src/vars.rs
Normal file
48
crates/shared/src/vars.rs
Normal file
@@ -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<Option<Value>, VarsError>;
|
||||||
|
|
||||||
|
/// The app's fully-resolved config map (every inherited + own key).
|
||||||
|
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, 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<Option<Value>, VarsError> {
|
||||||
|
Err(VarsError::Backend("vars is not wired in".into()))
|
||||||
|
}
|
||||||
|
async fn all(&self, _cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||||
|
Err(VarsError::Backend("vars is not wired in".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
# Groups, Projects & the Declarative Project Tool
|
# 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)
|
> grounded in precedent (GitLab, Kustomize, Helm, Terraform, Kubernetes Server-Side Apply, Pulumi)
|
||||||
> and corrected against the codebase after three independent review passes (consistency, gaps,
|
> 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
|
> **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
|
> introduces a server-side **groups** hierarchy so apps can share and inherit config/scripts
|
||||||
> without duplication.
|
> without duplication.
|
||||||
>
|
>
|
||||||
> **Blueprint impact:** this reverses the §11.5 *snapshot-copy, not live-link* stance — top-down
|
> **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-
|
> hierarchical inheritance (group → app) is now in scope. *Originally* designed as a *materialized,
|
||||||
> invalidated* view (§5.1). Fold the outcome back into the blueprint before it drifts.
|
> 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).**
|
- **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.
|
An earlier draft said "live-resolve," which is underspecified and would fight the existing cache.
|
||||||
The real model:
|
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:
|
- manager-core **resolves-at-write into a materialized per-app effective view** (§3 rule applied:
|
||||||
sparse merge, env filter, proximity, CoW, `enabled`).
|
sparse merge, env filter, proximity, CoW, `enabled`).
|
||||||
- The orchestrator/executor serve from that view, **keyed by `app_id` + a generation/version**. The
|
- 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
|
- **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
|
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-
|
- **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
|
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.)
|
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.
|
precondition to adopting the rule.
|
||||||
- **Inherited-membership revocation lag (§5.3)** — revoking a group admin must drop implicit admin on
|
- **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
|
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
|
**role revocation** — leaving an unbounded window. ~~New residual risk; should get the same
|
||||||
synchronous-for-security bound, lands with phase 3's authz.
|
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
|
- **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
|
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
|
(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
|
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
|
||||||
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
|
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
|
||||||
requirement, since multi-level resolution first ships here).
|
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 +
|
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.
|
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
|
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
|
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
||||||
demands.
|
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
|
## 12. Contracts still to draft
|
||||||
|
|
||||||
- The **apply bundle / plan artifact / change-report** wire contract (what the CLI ships, what the
|
- 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.
|
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
|
- The **effective-view resolver** (the read primitive) — the §3 rule made executable. *(Phase 3:
|
||||||
materialization + invalidation protocol (§5.1).
|
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
|
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
|
||||||
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
|
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
|
||||||
|
|||||||
@@ -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)
|
### 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)
|
- Function workflows (DAG execution, conditional branching, error handling)
|
||||||
- Nested workflows
|
- Nested workflows
|
||||||
- Call graph visualization + execution tracing
|
- Call graph visualization + execution tracing
|
||||||
|
|||||||
Reference in New Issue
Block a user