Compare commits
36 Commits
feat/group
...
52da8a8704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52da8a8704 | ||
|
|
c8acac1d20 | ||
|
|
b53eabb786 | ||
|
|
1844bef132 | ||
|
|
7fef098b48 | ||
|
|
3ef3038eb7 | ||
|
|
1662d7806a | ||
|
|
8a559ea178 | ||
|
|
da03fda1d6 | ||
|
|
65049a6262 | ||
|
|
99e7100f0b | ||
|
|
9598db149b | ||
|
|
c29e4b9c53 | ||
|
|
701ec90b56 | ||
|
|
719a17432f | ||
|
|
43ed4e8e7f | ||
|
|
48178c5f60 | ||
|
|
27dc04819f | ||
|
|
181622a84a | ||
|
|
34dcae98a2 | ||
|
|
e517f6dc8c | ||
|
|
b2822c8435 | ||
|
|
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.
|
||||
|
||||
**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), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them). Next: §5.5 **opt-in extension points** (the one deferred §5.5 piece) or §11.6 (group-level collections, v1.3).
|
||||
|
||||
**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 tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary).
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
|
||||
@@ -300,11 +300,18 @@ impl Engine {
|
||||
// installed with the real per-call resolver. The resolver owns
|
||||
// `cx.clone()` so cross-app isolation derives from this exact
|
||||
// call's context, not from any script-passed argument.
|
||||
// The lexical origin for `import` resolution (§5.5): the top-level
|
||||
// script's defining node. Falls back to the executing app when the
|
||||
// request predates Phase 4b (old payloads / cluster wire).
|
||||
let default_origin = req
|
||||
.script_owner
|
||||
.unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
|
||||
let resolver = PicloudModuleResolver::new(
|
||||
self.services.modules.clone(),
|
||||
cx.clone(),
|
||||
self.module_cache.clone(),
|
||||
effective_limits.module_import_depth_max,
|
||||
default_origin,
|
||||
);
|
||||
engine.set_module_resolver(resolver);
|
||||
let self_engine = self.self_arc();
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
||||
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
|
||||
//! origin-aware / lexical in v1.2 Phase 4b).
|
||||
//!
|
||||
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
||||
//! `import "<name>"` request resolves against the calling app
|
||||
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
||||
//! — that's the load-bearing cross-app isolation property.
|
||||
//! fresh per `Engine::execute` call.
|
||||
//!
|
||||
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
|
||||
//! module set visible at the **importing script's own defining node**,
|
||||
//! walking up its group chain — not the inheriting app's effective view.
|
||||
//! The importing node is read from Rhai's `_source` (the importing AST's
|
||||
//! source tag, which the resolver sets to each module's owner) and falls
|
||||
//! back to `default_origin` (the entry script's defining node) for the
|
||||
//! top-level AST. So an inherited group script's imports seal to the group
|
||||
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||
//!
|
||||
//! Three runtime invariants are enforced:
|
||||
//!
|
||||
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
||||
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
||||
//! script-passed argument).
|
||||
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||
//! importing node's owner (a trusted dispatch-derived value), never a
|
||||
//! script-passed argument.
|
||||
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||
//!
|
||||
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
||||
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
||||
//! `Engine` and shared across calls; only the resolver state (stack,
|
||||
//! depth) is per-call.
|
||||
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
|
||||
//! is lexically self-contained) and invalidated by `updated_at` change —
|
||||
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
|
||||
//! calls; only the resolver state (stack, depth) is per-call.
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use lru::LruCache;
|
||||
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
|
||||
use picloud_shared::{
|
||||
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
|
||||
};
|
||||
use rhai::module_resolvers::ModuleResolver;
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||
|
||||
@@ -35,10 +45,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||
/// `sync` feature that the workspace pins.
|
||||
type SharedRhaiModule = Shared<Module>;
|
||||
|
||||
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
||||
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
||||
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
||||
pub type ModuleCacheKey = (AppId, String);
|
||||
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
|
||||
/// module is lexically self-contained (its nested imports resolve from
|
||||
/// its own defining node, baked in at compile time), so the *body* is a
|
||||
/// pure function of `(script_id, updated_at)` — independent of which
|
||||
/// script imported it. Keying by id also dedupes a shared group module
|
||||
/// across every inheriting app, and keeps cross-app modules of the same
|
||||
/// name distinct (distinct ids).
|
||||
pub type ModuleCacheKey = ScriptId;
|
||||
|
||||
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
|
||||
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
|
||||
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
|
||||
/// that seals each module's imports to where it was authored (§5.5).
|
||||
fn encode_origin(owner: ScriptOwner) -> String {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => format!("app:{a}"),
|
||||
ScriptOwner::Group(g) => format!("group:{g}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
|
||||
/// source set by something other than this resolver) — the caller then
|
||||
/// falls back to the resolver's `default_origin`.
|
||||
fn decode_origin(s: &str) -> Option<ScriptOwner> {
|
||||
let (kind, id) = s.split_once(':')?;
|
||||
let uuid = uuid::Uuid::parse_str(id).ok()?;
|
||||
match kind {
|
||||
"app" => Some(ScriptOwner::App(uuid.into())),
|
||||
"group" => Some(ScriptOwner::Group(uuid.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||
@@ -65,17 +103,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
||||
|
||||
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||
pub struct PicloudModuleResolver {
|
||||
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
||||
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
||||
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
||||
/// Backend the resolver consults to resolve a module name against an
|
||||
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
|
||||
/// async `ModuleSource::resolve()` together via
|
||||
/// `tokio::runtime::Handle::block_on(...)` — safe because
|
||||
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||
/// that still carries a `Handle`.
|
||||
source: Arc<dyn ModuleSource>,
|
||||
|
||||
/// Calling context. `cx.app_id` is the cross-app isolation
|
||||
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
||||
/// call so the backend can scope its queries.
|
||||
/// Calling context. `cx.app_id` is the execution boundary every SDK
|
||||
/// call inside a module scopes to; the resolver keeps it for diagnostic
|
||||
/// logging. (Module *resolution* is keyed by the importing node's owner,
|
||||
/// not `cx.app_id` — see the module docs.)
|
||||
cx: Arc<SdkCallCx>,
|
||||
|
||||
/// Compiled-module cache. Shared across executions; invalidated
|
||||
@@ -96,6 +136,12 @@ pub struct PicloudModuleResolver {
|
||||
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
||||
/// resolver construction.
|
||||
depth_limit: u32,
|
||||
|
||||
/// Lexical origin for a top-level `import` — the defining node of the
|
||||
/// script being executed (Phase 4b, §5.5). Used when Rhai gives no
|
||||
/// `_source` (the entry AST). Nested imports inside a resolved module
|
||||
/// override this via the module AST's source (C3).
|
||||
default_origin: ScriptOwner,
|
||||
}
|
||||
|
||||
impl PicloudModuleResolver {
|
||||
@@ -105,6 +151,7 @@ impl PicloudModuleResolver {
|
||||
cx: Arc<SdkCallCx>,
|
||||
cache: Arc<ModuleCache>,
|
||||
depth_limit: u32,
|
||||
default_origin: ScriptOwner,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
@@ -113,6 +160,7 @@ impl PicloudModuleResolver {
|
||||
in_progress: Mutex::new(Vec::new()),
|
||||
depth: Mutex::new(0),
|
||||
depth_limit,
|
||||
default_origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +272,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
engine: &RhaiEngine,
|
||||
_source: Option<&str>,
|
||||
importer_source: Option<&str>,
|
||||
path: &str,
|
||||
pos: Position,
|
||||
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||
@@ -319,8 +367,18 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Lexical resolution (§5.5): resolve `path` against the *importing
|
||||
// node's* chain. Rhai gives `_source` = the importing AST's source
|
||||
// tag — set to the module's defining node for a nested import, or
|
||||
// unset (`None`) for the top-level entry script, where we fall back
|
||||
// to `default_origin` (the executing script's node). An app-rooted
|
||||
// walk reaches ancestor group modules; a group-rooted walk is sealed
|
||||
// from app modules below it (the trust boundary).
|
||||
let origin = importer_source
|
||||
.and_then(decode_origin)
|
||||
.unwrap_or(self.default_origin);
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(Some(m)) => m,
|
||||
@@ -355,8 +413,10 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
};
|
||||
|
||||
// Cache lookup: hit only if both key matches AND updated_at
|
||||
// matches (cache is invalidated lazily on version change).
|
||||
let cache_key = (self.cx.app_id, path.to_string());
|
||||
// matches (cache is invalidated lazily on version change). Keyed by
|
||||
// the resolved module's id (lexically self-contained — see
|
||||
// `ModuleCacheKey`).
|
||||
let cache_key = module_row.script_id;
|
||||
{
|
||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
@@ -389,7 +449,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
// already been gated at create-time (admin endpoint runs
|
||||
// `validate_module`), but we revalidate here to catch DB-direct
|
||||
// inserts that bypass the API surface.
|
||||
let ast = engine.compile(&module_row.source).map_err(|e| {
|
||||
let mut ast = engine.compile(&module_row.source).map_err(|e| {
|
||||
// Wrap as an ErrorRuntime to preserve the parse message
|
||||
// text without trying to reconstruct rhai's internal
|
||||
// ParseErrorType variant (which would require matching on
|
||||
@@ -404,6 +464,15 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Seal this module's own imports to its defining node (§5.5): tag
|
||||
// the AST source with the resolved module's owner so that when
|
||||
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
|
||||
// that tag as `_source` and we resolve them lexically from *here*,
|
||||
// not from the original importer. Fall back to the lookup origin
|
||||
// for a corrupt owner row (never reached under the DB CHECK).
|
||||
let module_owner = module_row.owner().unwrap_or(origin);
|
||||
ast.set_source(encode_origin(module_owner));
|
||||
|
||||
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
|
||||
@@ -179,6 +179,10 @@ fn invoke_blocking(
|
||||
rest: String::new(),
|
||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||
app_id: cx.app_id,
|
||||
// Lexical origin (§5.5): the callee's defining node — a group
|
||||
// script's imports resolve from the group even when invoked by an
|
||||
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||
script_owner: resolved.owner,
|
||||
// Same-app invoke is a function call, not a re-auth boundary —
|
||||
// inherit the caller's principal.
|
||||
principal: cx.principal.clone(),
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod retry;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
pub use cx::SdkCallCx;
|
||||
@@ -62,6 +63,7 @@ pub fn register_all(
|
||||
queue::register(engine, services, cx.clone());
|
||||
retry::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
vars::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx.clone());
|
||||
users::register(engine, services, cx.clone());
|
||||
invoke::register(engine, services, cx, limits, self_engine);
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
|
||||
ScriptId, ScriptSandbox, TriggerEvent,
|
||||
ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
@@ -69,6 +69,16 @@ pub struct ExecRequest {
|
||||
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||
pub app_id: AppId,
|
||||
|
||||
/// The **defining node** of the top-level script being executed —
|
||||
/// the resolved `Script`'s owner (Phase 4b). This is the lexical
|
||||
/// origin its `import` statements resolve against (§5.5): an inherited
|
||||
/// group endpoint's imports seal to the **group**, not the inheriting
|
||||
/// app, so a leaf can't shadow them. Distinct from `app_id`, which is
|
||||
/// always the execution boundary (where SDK calls scope). `None` (old
|
||||
/// payloads / cluster wire) falls back to `App(app_id)` in the engine.
|
||||
#[serde(default)]
|
||||
pub script_owner: Option<ScriptOwner>,
|
||||
|
||||
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||
/// data-plane HTTP requests (the common case for public scripts);
|
||||
/// `Some` when a bearer token or session cookie was resolved.
|
||||
|
||||
@@ -23,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -16,7 +16,7 @@ use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
ScriptOwner, ScriptSandbox, Services,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
@@ -27,9 +27,9 @@ struct FailingSource;
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for FailingSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
@@ -74,6 +74,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
@@ -107,6 +108,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
let engine = Engine::new(Limits::default(), services);
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -55,7 +55,8 @@ impl CountingModuleSource {
|
||||
(app_id, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id,
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at,
|
||||
@@ -71,20 +72,27 @@ impl CountingModuleSource {
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for CountingModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
self.lookups.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
||||
return Err(ModuleSourceError::Backend(err.clone()));
|
||||
}
|
||||
// This fake is flat/app-scoped — the inheritance + lexical
|
||||
// resolution semantics are covered by the CLI journey tests
|
||||
// against real Postgres. A group origin has no entries here.
|
||||
let app_id = match origin {
|
||||
ScriptOwner::App(a) => a,
|
||||
ScriptOwner::Group(_) => return Ok(None),
|
||||
};
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, name.to_string()))
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
@@ -104,6 +112,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,6 +137,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
@@ -599,3 +609,130 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
v.imports
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4b — lexical (origin-aware) resolution.
|
||||
//
|
||||
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
||||
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
||||
// `default_origin` for the entry AST, and each module's own owner (decoded
|
||||
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
||||
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct LexicalModuleSource {
|
||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for LexicalModuleSource {
|
||||
async fn resolve(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||
let mut r = req(app_id);
|
||||
r.script_owner = Some(owner);
|
||||
r
|
||||
}
|
||||
|
||||
/// A group module's `import` resolves from the GROUP (its own defining
|
||||
/// node), not the inheriting app — even when an app-owned module of the
|
||||
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
||||
/// a module an inherited group script depends on.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lexical_nested_import_seals_to_module_owner() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
|
||||
// Two "inner" modules: the group's (real) and the app's (a trap).
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||
.await;
|
||||
// The group's "outer" imports "inner" — must bind the group's inner.
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"outer",
|
||||
r#"import "inner" as i; fn val() { i::v() }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry script runs as the inherited GROUP script (default_origin = group).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "outer" as o; o::val()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!(42),
|
||||
"group module's import must seal to the group's `inner`, not the app's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same registry, entered as an APP-owned script, reaches the app's
|
||||
/// module — proving the fake distinguishes origins and the entry uses
|
||||
/// `default_origin`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lexical_entry_origin_selects_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "inner" as i; i::v()"#,
|
||||
req_with_owner(app, ScriptOwner::App(app)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(999));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fn baseline_request() -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -256,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -65,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -193,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -116,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -58,6 +58,7 @@ impl InvokeService for FakeInvokeService {
|
||||
Ok(ResolvedScript {
|
||||
script_id: id,
|
||||
app_id: app,
|
||||
owner: None,
|
||||
source,
|
||||
updated_at: Utc::now(),
|
||||
name,
|
||||
@@ -90,6 +91,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||
engine.set_self_weak(Arc::downgrade(&engine));
|
||||
@@ -113,6 +115,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -135,6 +136,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -73,6 +74,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -87,6 +88,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
Arc::new(NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -51,6 +52,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -126,6 +127,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
@@ -120,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: with_principal.then(|| Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
|
||||
@@ -37,6 +37,7 @@ fn baseline_request() -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
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;
|
||||
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
|
||||
--
|
||||
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
|
||||
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
|
||||
-- it is shared by every descendant app — resolved by name, nearest-owner
|
||||
-- wins (CoW: an app's own script of the same name shadows the inherited one),
|
||||
-- exactly like `vars`/`secrets` (0048/0049).
|
||||
--
|
||||
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
|
||||
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
|
||||
-- group-owned script must be self-contained (enforced in the service layer).
|
||||
--
|
||||
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
|
||||
-- a group can't be deleted out from under the scripts it owns, matching the
|
||||
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
|
||||
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||
-- enforces "owned by an app XOR a group".
|
||||
-- * the per-app unique name index becomes two partial indexes, one per owner.
|
||||
|
||||
ALTER TABLE scripts
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE scripts
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE scripts
|
||||
ADD CONSTRAINT scripts_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
|
||||
-- only constrains its own rows; existing app rows (group_id NULL) keep the
|
||||
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
|
||||
DROP INDEX scripts_name_uidx;
|
||||
CREATE UNIQUE INDEX scripts_app_name_uidx
|
||||
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX scripts_group_name_uidx
|
||||
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -12,8 +12,8 @@ use axum::{
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
||||
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
|
||||
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -181,6 +181,27 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
||||
Ok(lookup.app.id)
|
||||
}
|
||||
|
||||
/// Capability authorizing an operation on a script of either owner (Phase 4).
|
||||
/// App-owned scripts map to their `App*` capability exactly as before; a
|
||||
/// group-owned script maps to the group-scoped capability, resolved against
|
||||
/// the group ancestor walk. An orphan row (neither owner — impossible under
|
||||
/// the DB CHECK) reads as a missing id.
|
||||
///
|
||||
/// The `app`/`group` arguments are the capability constructors for each
|
||||
/// owner, so one helper serves read / write / admin / log-read by passing
|
||||
/// the matching pair.
|
||||
fn script_cap(
|
||||
script: &Script,
|
||||
app: fn(AppId) -> Capability,
|
||||
group: fn(GroupId) -> Capability,
|
||||
) -> Result<Capability, ApiError> {
|
||||
match script.owner() {
|
||||
Some(ScriptOwner::App(a)) => Ok(app(a)),
|
||||
Some(ScriptOwner::Group(g)) => Ok(group(g)),
|
||||
None => Err(ApiError::NotFound(script.id)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
@@ -190,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(script))
|
||||
@@ -234,7 +255,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
let created = state
|
||||
.repo
|
||||
.create(NewScript {
|
||||
app_id: input.app_id,
|
||||
app_id: Some(input.app_id),
|
||||
group_id: None,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
@@ -291,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
script_cap(
|
||||
&script,
|
||||
Capability::AppWriteScript,
|
||||
Capability::GroupScriptsWrite,
|
||||
)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -361,13 +387,15 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
// Delete is gated tighter than Save: editors can edit scripts but
|
||||
// only app_admin / instance admin / owner can remove them. See
|
||||
// blueprint §11.6.
|
||||
// Delete is gated tighter than Save for app-owned scripts: editors can
|
||||
// edit but only app_admin / instance admin / owner can remove them (§11.6).
|
||||
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
|
||||
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
|
||||
// that created it; group deletion itself stays group_admin-gated elsewhere.
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppAdmin(script.app_id),
|
||||
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
|
||||
)
|
||||
.await?;
|
||||
state.repo.delete(id).await?;
|
||||
@@ -408,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppLogRead(script.app_id),
|
||||
script_cap(
|
||||
&script,
|
||||
Capability::AppLogRead,
|
||||
Capability::GroupScriptsRead,
|
||||
)?,
|
||||
)
|
||||
.await?;
|
||||
// Cap to keep the dashboard responsive; the data plane writes are
|
||||
|
||||
@@ -60,7 +60,8 @@ async fn seed_into(
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let script = scripts
|
||||
.create(NewScript {
|
||||
app_id: default.id,
|
||||
app_id: Some(default.id),
|
||||
group_id: None,
|
||||
name: "hello".to_string(),
|
||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||
source: HELLO_RHAI_SOURCE.to_string(),
|
||||
|
||||
@@ -11,21 +11,27 @@ use axum::{
|
||||
routing::post,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
|
||||
TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
pub fn apply_router(service: ApplyService) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id}/plan", post(plan_handler))
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
@@ -47,53 +53,7 @@ async fn apply_handler(
|
||||
Json(req): Json<ApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// Read is always needed; write caps are required for the resource kinds
|
||||
// the bundle touches — and for ALL kinds when `prune` is set, since
|
||||
// pruning deletes resources whose bundle section is empty (and a script
|
||||
// delete cascades its routes/triggers).
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if req.prune || !req.bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve and decrypt a stored secret by name server-side,
|
||||
// which the secrets API guards with `AppSecretsRead`. Require it here too
|
||||
// so apply can't bind a secret a principal couldn't otherwise read — the
|
||||
// caps aren't strictly nested on the API-key scope path.
|
||||
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||
let report = svc
|
||||
.apply(
|
||||
app_id,
|
||||
@@ -126,6 +86,254 @@ async fn plan_handler(
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
|
||||
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
|
||||
// the service rejects routes/triggers on a group node.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn group_plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<PlanResult>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
// Plan discloses the group's script + var + secret names; viewer-tier reads.
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
async fn group_apply_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(req): Json<ApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
|
||||
let report = svc
|
||||
.apply_owner(
|
||||
ApplyOwner::Group(group_id),
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
|
||||
// must hold the relevant read/write caps for EVERY node touched.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreeApplyRequest {
|
||||
bundle: TreeBundle,
|
||||
#[serde(default)]
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(bundle): Json<TreeBundle>,
|
||||
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &bundle, None).await?;
|
||||
Ok(Json(svc.plan_tree(&bundle).await?))
|
||||
}
|
||||
|
||||
async fn tree_apply_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||
/// only the per-node read cap.
|
||||
async fn authz_tree(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<bool>,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// App-node write caps: per resource kind the bundle touches, widened to all
|
||||
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
|
||||
/// is always needed. Shared by the single-app and tree apply paths.
|
||||
async fn require_app_node_writes(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
app_id: picloud_shared::AppId,
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.vars.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve + decrypt a stored secret by name server-side
|
||||
// (`AppSecretsRead`), so require it so apply can't bind a secret the
|
||||
// principal couldn't otherwise read.
|
||||
if bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
|
||||
/// `GroupVarsWrite`, both on prune.
|
||||
async fn require_group_node_writes(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
group_id: GroupId,
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsWrite(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.vars.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||
async fn resolve_group_id(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, ApplyError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
};
|
||||
found
|
||||
.map(|g| g.id)
|
||||
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||
/// Mirrors the `triggers_api` helper of the same shape.
|
||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,32 @@ pub enum Capability {
|
||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||
AppSecretsWrite(AppId),
|
||||
/// Read this app's resolved config vars (Phase 3). Same trust shape as
|
||||
/// secrets-read — granted to `viewer`+, maps to `script:read`.
|
||||
AppVarsRead(AppId),
|
||||
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
|
||||
/// `editor`+, maps to `script:write`.
|
||||
AppVarsWrite(AppId),
|
||||
/// Read a group's config vars (Phase 3). Resolved via the group
|
||||
/// ancestor walk; viewer+ on the group.
|
||||
GroupVarsRead(GroupId),
|
||||
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
|
||||
/// the group.
|
||||
GroupVarsWrite(GroupId),
|
||||
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
|
||||
/// group_admin on the owning group — distinct from runtime injection,
|
||||
/// which an inheriting app does without this check.
|
||||
GroupSecretsRead(GroupId),
|
||||
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
|
||||
/// group.
|
||||
GroupSecretsWrite(GroupId),
|
||||
/// Read (get / list) group-owned scripts (Phase 4). Resolved via the
|
||||
/// group ancestor walk; viewer+ on the group. Maps to `script:read`.
|
||||
GroupScriptsRead(GroupId),
|
||||
/// Create / update / delete a group-owned script (Phase 4). editor+ on
|
||||
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
||||
/// for an app, lifted to the group owner.
|
||||
GroupScriptsWrite(GroupId),
|
||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||
/// to `script:write` on API keys (sending mail is an outbound
|
||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||
@@ -176,7 +202,13 @@ impl Capability {
|
||||
// app) is denied group management at the binding layer.
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_) => None,
|
||||
| Self::GroupAdmin(_)
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_)
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsRead(_)
|
||||
| Self::GroupScriptsWrite(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -194,6 +226,8 @@ impl Capability {
|
||||
| Self::AppQueueEnqueue(id)
|
||||
| Self::AppSecretsRead(id)
|
||||
| Self::AppSecretsWrite(id)
|
||||
| Self::AppVarsRead(id)
|
||||
| Self::AppVarsWrite(id)
|
||||
| Self::AppEmailSend(id)
|
||||
| Self::AppManageTriggers(id)
|
||||
| Self::AppDeadLetterManage(id)
|
||||
@@ -223,7 +257,10 @@ impl Capability {
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_)
|
||||
| Self::AppUsersRead(_)
|
||||
| Self::GroupRead(_) => Scope::ScriptRead,
|
||||
| Self::AppVarsRead(_)
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -235,6 +272,13 @@ impl Capability {
|
||||
| Self::AppEmailSend(_)
|
||||
| Self::AppUsersWrite(_)
|
||||
| Self::AppUsersAdmin(_)
|
||||
| Self::AppVarsWrite(_)
|
||||
// Group-secret WRITE is editor-role-gated (same tier as
|
||||
// GroupVarsWrite), so its API-key scope matches: script:write,
|
||||
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
|
||||
// the admin tier below.
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsWrite(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
@@ -243,7 +287,9 @@ impl Capability {
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_) => Scope::AppAdmin,
|
||||
| Self::GroupAdmin(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -412,7 +458,15 @@ async fn role_grants(
|
||||
// walk (a group_admin on an ancestor is implicitly admin of
|
||||
// the descendant group). Routed before member_grants because
|
||||
// group caps carry no app_id.
|
||||
Capability::GroupRead(g) | Capability::GroupWrite(g) | Capability::GroupAdmin(g) => {
|
||||
Capability::GroupRead(g)
|
||||
| Capability::GroupWrite(g)
|
||||
| Capability::GroupAdmin(g)
|
||||
| Capability::GroupVarsRead(g)
|
||||
| Capability::GroupVarsWrite(g)
|
||||
| Capability::GroupSecretsRead(g)
|
||||
| Capability::GroupSecretsWrite(g)
|
||||
| Capability::GroupScriptsRead(g)
|
||||
| Capability::GroupScriptsWrite(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
@@ -472,9 +526,22 @@ async fn group_member_grants(
|
||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
match cap {
|
||||
Capability::GroupRead(_) => true, // any role can read
|
||||
Capability::GroupWrite(_) => matches!(role, AppRole::Editor | AppRole::AppAdmin),
|
||||
Capability::GroupAdmin(_) => matches!(role, AppRole::AppAdmin),
|
||||
// viewer+ reads group metadata, config vars, and scripts.
|
||||
Capability::GroupRead(_)
|
||||
| Capability::GroupVarsRead(_)
|
||||
| Capability::GroupScriptsRead(_) => true,
|
||||
// editor+ writes config vars/secrets/scripts.
|
||||
Capability::GroupWrite(_)
|
||||
| Capability::GroupVarsWrite(_)
|
||||
| Capability::GroupSecretsWrite(_)
|
||||
| Capability::GroupScriptsWrite(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
// human-read gate, distinct from an app's runtime injection).
|
||||
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
|
||||
matches!(role, AppRole::AppAdmin)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -493,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppFilesRead(_)
|
||||
| Capability::AppSecretsRead(_)
|
||||
| Capability::AppUsersRead(_)
|
||||
| Capability::AppVarsRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
@@ -508,6 +576,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppSecretsWrite(_)
|
||||
| Capability::AppEmailSend(_)
|
||||
| Capability::AppUsersWrite(_)
|
||||
| Capability::AppVarsWrite(_)
|
||||
| Capability::AppInvoke(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
472
crates/manager-core/src/config_resolver.rs
Normal file
472
crates/manager-core/src/config_resolver.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
//! 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 \
|
||||
)";
|
||||
|
||||
/// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a
|
||||
/// **group** (depth 0 = the group itself, `group_owner` set), then walks
|
||||
/// its ancestor groups nearest-first. There is no `app_owner` level — a
|
||||
/// group origin never sees app-owned rows below it (the §5.5 trust
|
||||
/// boundary). Bind `$1 = group_id`. Used for the lexical module resolver
|
||||
/// when the importing script's defining node is a group.
|
||||
pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\
|
||||
WITH RECURSIVE chain AS ( \
|
||||
SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \
|
||||
FROM groups g WHERE g.id = $1 \
|
||||
UNION ALL \
|
||||
SELECT g.id, g.parent_id, c.depth + 1 \
|
||||
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}));
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,9 @@ use picloud_executor_core::{
|
||||
};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||
AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
|
||||
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
@@ -191,6 +191,32 @@ impl ExecLogContext {
|
||||
}
|
||||
|
||||
impl Dispatcher {
|
||||
/// Phase 4 isolation backstop: may `script` run under `app_id`'s context?
|
||||
/// True when the script is owned by `app_id` directly, or by a group on
|
||||
/// its ancestor chain (an inherited group script). App-owned is the
|
||||
/// in-memory fast path — only a group-owned script pays the chain query,
|
||||
/// and a query error fails closed (not invocable). This generalizes the
|
||||
/// pre-Phase-4 same-app guard (`script.app_id == row.app_id`) to the
|
||||
/// hierarchy without changing behavior for app-owned scripts.
|
||||
async fn script_invocable(&self, script: &Script, app_id: AppId) -> bool {
|
||||
if script.is_owned_by_app(app_id) {
|
||||
return true;
|
||||
}
|
||||
if script.group_id.is_none() {
|
||||
return false;
|
||||
}
|
||||
match self.scripts.is_invocable_by_app(script.id, app_id).await {
|
||||
Ok(ok) => ok,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e, script_id = %script.id, %app_id,
|
||||
"chain-membership check failed; treating script as not invocable"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
|
||||
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
|
||||
/// run for the process lifetime; returned `JoinHandle`s are
|
||||
@@ -359,9 +385,14 @@ impl Dispatcher {
|
||||
// existing consumers. Dead-letter the message so the misfire is
|
||||
// observable rather than executing one app's script under
|
||||
// another app's SdkCallCx.
|
||||
if script.app_id != claimed.app_id {
|
||||
// Phase 4: the target must be invocable in the claimed message's app
|
||||
// context — owned by that app or an inherited group script on its
|
||||
// chain. App-owned scripts take the in-memory fast path; only a group
|
||||
// script pays the chain query, and a non-member fails closed
|
||||
// (dead-lettered, not silently run under another app's SdkCallCx).
|
||||
if !self.script_invocable(&script, claimed.app_id).await {
|
||||
tracing::error!(
|
||||
script_app = %script.app_id,
|
||||
script_app = ?script.app_id,
|
||||
claim_app = %claimed.app_id,
|
||||
script_id = %consumer.script_id,
|
||||
"queue consumer script belongs to a different app; dead-lettering"
|
||||
@@ -440,6 +471,7 @@ impl Dispatcher {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: claimed.app_id,
|
||||
script_owner: script.owner(),
|
||||
principal: Some(principal),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
@@ -786,7 +818,9 @@ impl Dispatcher {
|
||||
// boundary. Not reachable via the trigger-create or `apply` paths
|
||||
// (both resolve the script within the app's own scope), so this is
|
||||
// the runtime backstop the other arms already carry.
|
||||
if script.app_id != row.app_id {
|
||||
// Phase 4: invocable in the outbox row's app context (app-owned or an
|
||||
// inherited group script on the chain); a non-member fails closed.
|
||||
if !self.script_invocable(&script, row.app_id).await {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"trigger outbox target belongs to a different app".into(),
|
||||
));
|
||||
@@ -797,6 +831,7 @@ impl Dispatcher {
|
||||
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
|
||||
active: trigger.enabled && script.enabled,
|
||||
script_id: script.id,
|
||||
script_owner: script.owner(),
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
script_updated_at: script.updated_at,
|
||||
@@ -839,6 +874,7 @@ impl Dispatcher {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: resolved.sandbox_overrides,
|
||||
app_id: row.app_id,
|
||||
script_owner: resolved.script_owner,
|
||||
principal: Some(principal),
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
|
||||
@@ -876,7 +912,8 @@ impl Dispatcher {
|
||||
// build_invoke_request and dispatch_one_queue. A hand-edited
|
||||
// outbox row could otherwise execute one app's script under
|
||||
// another app's SdkCallCx.
|
||||
if script.app_id != row.app_id {
|
||||
// Phase 4: invocable in this app's context (see queue/trigger arms).
|
||||
if !self.script_invocable(&script, row.app_id).await {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"http outbox target belongs to a different app".into(),
|
||||
));
|
||||
@@ -901,6 +938,7 @@ impl Dispatcher {
|
||||
rest: payload.rest,
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: row.app_id,
|
||||
script_owner: script.owner(),
|
||||
// HTTP outbox rows don't run as the trigger registrant —
|
||||
// they run with no principal (public ingress) or the
|
||||
// attached one (origin_principal forensic field is not
|
||||
@@ -919,6 +957,7 @@ impl Dispatcher {
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id,
|
||||
script_owner: script.owner(),
|
||||
script_source: script.source,
|
||||
script_name: payload.script_name,
|
||||
script_updated_at: script.updated_at,
|
||||
@@ -969,7 +1008,8 @@ impl Dispatcher {
|
||||
})?;
|
||||
// Same-app guard — the script could have been re-assigned or
|
||||
// the row could have been hand-edited. Reject mismatches.
|
||||
if script.app_id != row.app_id {
|
||||
// Phase 4: invocable in this app's context (app-owned or inherited).
|
||||
if !self.script_invocable(&script, row.app_id).await {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"invoke target belongs to a different app".into(),
|
||||
));
|
||||
@@ -1000,6 +1040,7 @@ impl Dispatcher {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: row.app_id,
|
||||
script_owner: script.owner(),
|
||||
// invoke_async runs with no principal — same as HTTP outbox.
|
||||
principal: None,
|
||||
trigger_depth,
|
||||
@@ -1019,6 +1060,7 @@ impl Dispatcher {
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id: script.id,
|
||||
script_owner: script.owner(),
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
script_updated_at: script.updated_at,
|
||||
@@ -1321,6 +1363,10 @@ pub struct ResolvedTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub script_source: String,
|
||||
pub script_name: String,
|
||||
/// Phase 4b: the resolved script's defining node — the lexical origin
|
||||
/// for its `import`s (§5.5). `None` only for a corrupt owner row; the
|
||||
/// executor then falls back to `App(app_id)`.
|
||||
pub script_owner: Option<ScriptOwner>,
|
||||
/// v1.1.3: freshness comparator for the orchestrator's top-level
|
||||
/// script cache. The dispatcher hands `(script_id, updated_at)`
|
||||
/// in alongside the source so cached ASTs can be reused across
|
||||
@@ -1640,6 +1686,20 @@ mod tests {
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
Ok(self.script.is_owned_by_app(app_id))
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
@@ -1649,6 +1709,12 @@ mod tests {
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
@@ -2097,7 +2163,8 @@ mod tests {
|
||||
pub(super) fn disabled_script(app_id: AppId) -> Script {
|
||||
Script {
|
||||
id: ScriptId::new(),
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: "worker".into(),
|
||||
description: None,
|
||||
version: 1,
|
||||
|
||||
225
crates/manager-core/src/group_scripts_api.rs
Normal file
225
crates/manager-core/src/group_scripts_api.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! `/api/v1/admin/groups/{id}/scripts*` — group-owned script admin (Phase 4).
|
||||
//!
|
||||
//! * `GET /groups/{id}/scripts` — list the group's own scripts (not
|
||||
//! inherited; just rows owned directly by this group). Gated by
|
||||
//! `GroupScriptsRead` (viewer+ on the group).
|
||||
//! * `POST /groups/{id}/scripts` — create a group-owned script. Gated by
|
||||
//! `GroupScriptsWrite` (editor+ on the group).
|
||||
//!
|
||||
//! A group script is a **template** inherited by every descendant app,
|
||||
//! resolved by name with nearest-owner-wins (CoW). Get / update / delete of an
|
||||
//! existing group script go through the by-id `/scripts/{id}` endpoints, which
|
||||
//! are owner-polymorphic — a group script there gates on `GroupScripts*`.
|
||||
//!
|
||||
//! **Phase 4-lite scope:** group ENDPOINT scripts only, and they must be
|
||||
//! self-contained — `kind=module` and any `import` are rejected here, because
|
||||
//! the origin-aware (lexical) module resolver is Phase 4b. The owner is
|
||||
//! resolved FIRST (slug-or-uuid) and `authz::require` binds the capability to
|
||||
//! the resolved group id, never to a caller-controlled path param.
|
||||
|
||||
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::{
|
||||
GroupId, Principal, Script, ScriptKind, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||
use crate::sandbox::SandboxCeiling;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupScriptsState {
|
||||
pub scripts: Arc<dyn ScriptRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub validator: Arc<dyn ScriptValidator>,
|
||||
pub sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
|
||||
pub fn group_scripts_router(state: GroupScriptsState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/groups/{group_id}/scripts",
|
||||
get(list_group_scripts).post(create_group_script),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupScriptRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
/// Phase 4-lite accepts only `endpoint`. A `module` is rejected (group
|
||||
/// modules + the lexical import resolver are Phase 4b).
|
||||
#[serde(default)]
|
||||
pub kind: ScriptKind,
|
||||
pub timeout_seconds: Option<i32>,
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub sandbox: ScriptSandbox,
|
||||
}
|
||||
|
||||
async fn list_group_scripts(
|
||||
State(s): State<GroupScriptsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<Script>>, GroupScriptsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(s.scripts.list_for_group(group_id).await?))
|
||||
}
|
||||
|
||||
async fn create_group_script(
|
||||
State(s): State<GroupScriptsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<CreateGroupScriptRequest>,
|
||||
) -> Result<(StatusCode, Json<Script>), GroupScriptsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 4b: group modules + imports are allowed. Imports resolve
|
||||
// lexically from this group's chain at runtime (§5.5); the recorded
|
||||
// edges feed the declarative dangling-import plan check.
|
||||
let validated = s.validator.validate(&input.source)?;
|
||||
s.sandbox_ceiling
|
||||
.check(&input.sandbox)
|
||||
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
|
||||
|
||||
let created = s
|
||||
.scripts
|
||||
.create(NewScript {
|
||||
app_id: None,
|
||||
group_id: Some(group_id),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
kind: input.kind,
|
||||
timeout_seconds: input.timeout_seconds,
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: if input.sandbox.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
enabled: true,
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, GroupScriptsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found
|
||||
.map(|g| g.id)
|
||||
.ok_or(GroupScriptsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupScriptsApiError {
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("scripts backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for GroupScriptsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for GroupScriptsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ValidationError> for GroupScriptsApiError {
|
||||
fn from(e: ValidationError) -> Self {
|
||||
Self::Invalid(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScriptRepositoryError> for GroupScriptsApiError {
|
||||
fn from(e: ScriptRepositoryError) -> Self {
|
||||
match e {
|
||||
ScriptRepositoryError::Conflict(m) => Self::Conflict(m),
|
||||
ScriptRepositoryError::NotFound(id) => Self::Invalid(format!("script {id} not found")),
|
||||
ScriptRepositoryError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for GroupScriptsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::GroupNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "group-scripts admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "group-scripts admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ impl InvokeServiceImpl {
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("id {script_id}")))?;
|
||||
if script.app_id != cx.app_id {
|
||||
// Phase 4: group scripts (`app_id: None`) fail closed here — invoke()
|
||||
// by-id stays app-owned until chain-aware resolution lands. The
|
||||
// execution context is the *caller's* app (`cx.app_id`), never read
|
||||
// off the target script.
|
||||
if !script.is_owned_by_app(cx.app_id) {
|
||||
return Err(InvokeError::CrossApp);
|
||||
}
|
||||
if !script.enabled {
|
||||
@@ -90,7 +94,8 @@ impl InvokeServiceImpl {
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
app_id: cx.app_id,
|
||||
owner: script.owner(),
|
||||
source: script.source,
|
||||
updated_at: script.updated_at,
|
||||
name: script.name,
|
||||
@@ -102,9 +107,13 @@ impl InvokeServiceImpl {
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
) -> Result<ResolvedScript, InvokeError> {
|
||||
// Phase 4: resolve down the app's chain — an inherited group script
|
||||
// of this name is reachable, nearest-owner-wins (an app's own script
|
||||
// shadows it). The chain only contains the app + its ancestors, so the
|
||||
// resolved script is invocable by `cx.app_id` by construction.
|
||||
let script = self
|
||||
.scripts
|
||||
.get_by_name(cx.app_id, name)
|
||||
.get_by_name_inherited(cx.app_id, name)
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
||||
@@ -113,7 +122,12 @@ impl InvokeServiceImpl {
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
// The execution-context app is always the caller's app — a group
|
||||
// script runs under the inheriting app, never its owner.
|
||||
app_id: cx.app_id,
|
||||
// …but its `import`s resolve from its *defining node* (the group
|
||||
// for an inherited script) — the lexical origin (§5.5).
|
||||
owner: script.owner(),
|
||||
source: script.source,
|
||||
updated_at: script.updated_at,
|
||||
name: script.name,
|
||||
@@ -227,19 +241,40 @@ mod tests {
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
Ok(
|
||||
if app_id == self.script.app_id && name == self.script.name {
|
||||
if self.script.app_id == Some(app_id) && name == self.script.name {
|
||||
Some(self.script.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
// This one-script repo has no hierarchy; inherited == own-scope.
|
||||
self.get_by_name(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
_app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
Ok(script_id == self.script.id)
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
Ok(vec![self.script.clone()])
|
||||
}
|
||||
async fn list_for_app(&self, _app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
@@ -312,7 +347,8 @@ mod tests {
|
||||
fn make_script(app_id: AppId, name: &str) -> Script {
|
||||
Script {
|
||||
id: ScriptId::new(),
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: name.into(),
|
||||
description: None,
|
||||
version: 1,
|
||||
|
||||
@@ -31,6 +31,8 @@ pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod config_api;
|
||||
pub mod config_resolver;
|
||||
pub mod cron_scheduler;
|
||||
pub mod dead_letter_repo;
|
||||
pub mod dead_letter_service;
|
||||
@@ -49,6 +51,7 @@ pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod group_scripts_api;
|
||||
pub mod groups_api;
|
||||
pub mod http_service;
|
||||
pub mod invoke_service;
|
||||
@@ -84,6 +87,9 @@ pub mod trigger_repo;
|
||||
pub mod triggers_api;
|
||||
pub mod users_admin_api;
|
||||
pub mod users_service;
|
||||
pub mod vars_api;
|
||||
pub mod vars_repo;
|
||||
pub mod vars_service;
|
||||
|
||||
pub use abandoned_repo::{
|
||||
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
||||
@@ -146,6 +152,7 @@ pub use auth_middleware::{
|
||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
||||
};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use config_api::{config_router, ConfigApiError, ConfigApiState};
|
||||
pub use cron_scheduler::spawn_cron_scheduler;
|
||||
pub use dead_letter_repo::{
|
||||
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
||||
@@ -176,6 +183,7 @@ pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
};
|
||||
pub use group_scripts_api::{group_scripts_router, GroupScriptsApiError, GroupScriptsState};
|
||||
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||
@@ -200,11 +208,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||
pub use secrets_repo::{
|
||||
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
SecretsRepoError, StoredSecret,
|
||||
};
|
||||
pub use secrets_service::{
|
||||
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
|
||||
open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl,
|
||||
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
};
|
||||
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
@@ -219,3 +227,6 @@ pub use trigger_repo::{
|
||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
||||
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
|
||||
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
|
||||
pub use vars_service::VarsServiceImpl;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
||||
//!
|
||||
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
|
||||
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
|
||||
//! `PgPool` that owns a single statement returning the module by
|
||||
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
|
||||
//! `executor-core` and consumes this trait through the `Services`
|
||||
//! bundle, so manager-core stays the only crate that touches
|
||||
//! Postgres.
|
||||
//! Resolution is **lexical** (§5.5): a module is looked up by walking the
|
||||
//! group chain rooted at the **importing script's defining node**
|
||||
//! (`origin`), nearest-owner-wins — *not* the inheriting app's effective
|
||||
//! view. An `App` origin walks `app → its group → ancestors` (reusing
|
||||
//! [`CHAIN_LEVELS_CTE`]); a `Group` origin walks `group → ancestors`
|
||||
//! ([`GROUP_CHAIN_LEVELS_CTE`]) and never sees app-owned modules below it
|
||||
//! (the trust boundary). The resolver lives in `executor-core` and consumes
|
||||
//! this trait through the `Services` bundle, so manager-core stays the only
|
||||
//! crate that touches Postgres.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
||||
|
||||
pub struct PostgresModuleSource {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -27,7 +31,8 @@ impl PostgresModuleSource {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ModuleRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
app_id: Option<uuid::Uuid>,
|
||||
group_id: Option<uuid::Uuid>,
|
||||
name: String,
|
||||
source: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
@@ -37,7 +42,8 @@ impl From<ModuleRow> for ModuleScript {
|
||||
fn from(r: ModuleRow) -> Self {
|
||||
Self {
|
||||
script_id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
app_id: r.app_id.map(Into::into),
|
||||
group_id: r.group_id.map(Into::into),
|
||||
name: r.name,
|
||||
source: r.source,
|
||||
updated_at: r.updated_at,
|
||||
@@ -47,28 +53,47 @@ impl From<ModuleRow> for ModuleScript {
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for PostgresModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
// The query is the cross-app isolation boundary: app_id comes
|
||||
// from cx (never from the script-passed argument), and the
|
||||
// CHECK constraint `kind IN ('endpoint','module')` plus the
|
||||
// `kind = 'module'` filter together guarantee endpoint scripts
|
||||
// are never importable. The `(app_id, kind)` index from
|
||||
// migration 0015 makes this an index scan returning at most
|
||||
// one row (per-app uniqueness on `name`).
|
||||
let row: Option<ModuleRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, name, source, updated_at \
|
||||
FROM scripts \
|
||||
WHERE app_id = $1 AND kind = 'module' AND name = $2",
|
||||
)
|
||||
.bind(cx.app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
// Lexical lookup: JOIN module scripts against the chain rooted at
|
||||
// `origin`, take the nearest level (lowest depth). The `kind =
|
||||
// 'module'` filter + the CHECK constraint guarantee endpoint
|
||||
// scripts are never importable. Case-insensitive to match the
|
||||
// per-owner `LOWER(name)` partial-unique indexes (0050). `origin`
|
||||
// is a trusted dispatch-derived value, so the chain it roots is
|
||||
// the script's true ancestry — the cross-app isolation boundary
|
||||
// is preserved (a group origin can't reach an app's modules).
|
||||
let query = match origin {
|
||||
ScriptOwner::App(_) => format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
),
|
||||
ScriptOwner::Group(_) => format!(
|
||||
"{GROUP_CHAIN_LEVELS_CTE} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s \
|
||||
JOIN chain c ON s.group_id = c.group_owner \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
),
|
||||
};
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<ModuleRow> = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, GroupId, RequestId, Script,
|
||||
ScriptId, ScriptKind, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
@@ -34,10 +34,34 @@ pub trait ScriptRepository: Send + Sync {
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: resolve `name → Script` down `app_id`'s ownership chain —
|
||||
/// the app itself, then its ancestor groups nearest-first. Nearest owner
|
||||
/// wins (an app's own script shadows an inherited group one of the same
|
||||
/// name: CoW). Returns `None` if no script in the chain has that name.
|
||||
/// Backs inherited `invoke("name")` and declarative name→id binding.
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: is `script_id` invocable in `app_id`'s context? True iff the
|
||||
/// script is owned by `app_id` directly, OR owned by a group on `app_id`'s
|
||||
/// ancestor chain. The cross-app isolation predicate generalized to the
|
||||
/// hierarchy — the runtime backstop for trigger/queue/invoke dispatch and
|
||||
/// trigger-bind validation. A missing script returns `false` (fail closed).
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: every script owned directly by `group_id` (not inherited —
|
||||
/// just the group's own rows). Backs the group-script admin list.
|
||||
async fn list_for_group(&self, group_id: GroupId)
|
||||
-> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// Every script in any app the user is a member of. Drives
|
||||
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||
/// API never returns scripts they shouldn't see — even before the
|
||||
@@ -93,12 +117,32 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
(**self).get_by_name(app_id, name).await
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
(**self).get_by_name_inherited(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
(**self).is_invocable_by_app(script_id, app_id).await
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list().await
|
||||
}
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list_for_app(app_id).await
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list_for_group(group_id).await
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
@@ -142,7 +186,13 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
||||
/// constraints; the repo enforces them in the DB regardless.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewScript {
|
||||
pub app_id: AppId,
|
||||
/// App owner (the common case). Exactly one of `app_id`/`group_id` must
|
||||
/// be `Some` — the DB CHECK is the backstop, but callers should uphold
|
||||
/// it. App-owned creation continues to pass `Some(app_id)`, `group_id:
|
||||
/// None`; group-owned creation (Phase 4) inverts that.
|
||||
pub app_id: Option<AppId>,
|
||||
/// Group owner (Phase 4). See [`NewScript::app_id`].
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -206,7 +256,7 @@ impl PostgresScriptRepository {
|
||||
/// Columns selected from `scripts` everywhere — kept in one constant so
|
||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||
/// one query.
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, group_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||
created_at, updated_at";
|
||||
|
||||
@@ -237,6 +287,58 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
// Walk app → ancestor groups (CHAIN_LEVELS_CTE binds $1 = app_id),
|
||||
// join scripts owned at each level, and take the nearest (lowest
|
||||
// depth) row named `name`. Case-insensitive to match the per-owner
|
||||
// `LOWER(name)` unique indexes. Mirrors the vars/secrets resolvers.
|
||||
let cols = SCRIPT_SELECT_COLS
|
||||
.split(", ")
|
||||
.map(|c| format!("s.{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let row = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"{cte} SELECT {cols} FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC \
|
||||
LIMIT 1",
|
||||
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
// EXISTS over the same chain: the script's owner (app or group) must
|
||||
// appear on `app_id`'s app→ancestor-group chain. CHAIN_LEVELS_CTE
|
||||
// binds $1 = app_id; $2 = script_id.
|
||||
let exists: (bool,) = sqlx::query_as(&format!(
|
||||
"{cte} SELECT EXISTS ( \
|
||||
SELECT 1 FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.id = $2 \
|
||||
)",
|
||||
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists.0)
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
||||
@@ -256,6 +358,19 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts WHERE group_id = $1 ORDER BY name"
|
||||
))
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
@@ -397,12 +512,13 @@ pub(crate) async fn insert_script_tx(
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
app_id, group_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 30), COALESCE($8, 256), $9, $10) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.app_id.map(AppId::into_inner))
|
||||
.bind(input.group_id.map(GroupId::into_inner))
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
@@ -417,13 +533,18 @@ pub(crate) async fn insert_script_tx(
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
"a script named {:?} already exists in this owner",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
||||
// Module imports are resolved within the owning app's script set. Group
|
||||
// scripts must be self-contained in Phase 4-lite (the origin-aware import
|
||||
// resolver is Phase 4b), so only app-owned scripts wire up import edges.
|
||||
if let Some(app_id) = script.app_id {
|
||||
replace_imports_tx(tx, script.id, app_id, &input.imports).await?;
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
@@ -470,13 +591,15 @@ pub(crate) async fn update_script_tx(
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
"a script with that name already exists in this owner".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
|
||||
if let Some(app_id) = script.app_id {
|
||||
replace_imports_tx(tx, script.id, app_id, imports).await?;
|
||||
}
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
@@ -501,7 +624,10 @@ pub(crate) async fn delete_script_tx(
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
/// Polymorphic owner (Phase 4): exactly one of `app_id`/`group_id` is
|
||||
/// non-NULL (DB CHECK). App-owned rows keep `app_id` set as before.
|
||||
app_id: Option<uuid::Uuid>,
|
||||
group_id: Option<uuid::Uuid>,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
@@ -531,7 +657,8 @@ impl From<ScriptRow> for Script {
|
||||
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
app_id: r.app_id.map(Into::into),
|
||||
group_id: r.group_id.map(Into::into),
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
|
||||
@@ -148,10 +148,16 @@ async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
// Phase 4: routes bind to app-owned scripts; a group-owned script
|
||||
// (`app_id: None`) is not addressable here (binding lands in C3), so it
|
||||
// reads as a missing script.
|
||||
let app_id = script
|
||||
.app_id
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
Capability::AppRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
@@ -176,7 +182,11 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
// Phase 4: only app-owned scripts are route-bindable for now (group
|
||||
// binding is C3); a group script reads as missing here.
|
||||
let app_id = script
|
||||
.app_id
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
||||
//! (v1.1.7).
|
||||
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
|
||||
//!
|
||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
||||
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
|
||||
//! (NEVER values).
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret.
|
||||
//! * `GET /groups/{id}/secrets` — list group secret names + scope
|
||||
//! + updated_at (NEVER values).
|
||||
//! * `POST /groups/{id}/secrets` — set/overwrite a group secret
|
||||
//! (env-scoped).
|
||||
//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret.
|
||||
//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** —
|
||||
//! the ONE endpoint that returns plaintext, gated at the owning group.
|
||||
//!
|
||||
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
|
||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
||||
//! returns only names + timestamps — the dashboard never receives
|
||||
//! plaintext. Values are encrypted with the process master key before
|
||||
//! they touch the database (same envelope as the script `secrets::set`).
|
||||
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
|
||||
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
|
||||
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
|
||||
//! the masked-secret boundary: a descendant app's dev can see that a group
|
||||
//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a
|
||||
//! principal with read rights AT THE OWNING GROUP can read its value. The
|
||||
//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the
|
||||
//! capability to the resolved owner id — never to a caller-controlled path
|
||||
//! param. Values are encrypted with the process master key before they
|
||||
//! touch the database (owner-bound AAD; see `secrets_service`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,19 +30,24 @@ use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
|
||||
use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::seal;
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::{open, seal};
|
||||
|
||||
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
|
||||
const APP_SECRET_SCOPE: &str = "*";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretsState {
|
||||
pub repo: Arc<dyn SecretsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub master_key: MasterKey,
|
||||
pub max_value_bytes: usize,
|
||||
@@ -39,10 +55,25 @@ pub struct SecretsState {
|
||||
|
||||
pub fn secrets_router(state: SecretsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
|
||||
.route(
|
||||
"/apps/{app_id}/secrets",
|
||||
get(list_app_secrets).post(set_app_secret),
|
||||
)
|
||||
.route(
|
||||
"/apps/{app_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_secret),
|
||||
axum::routing::delete(delete_app_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets",
|
||||
get(list_group_secrets).post(set_group_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_group_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets/{name}/value",
|
||||
get(read_group_secret_value),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -55,9 +86,18 @@ pub struct ListQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnvQuery {
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SecretItem {
|
||||
name: String,
|
||||
/// Environment scope — `*` for app secrets, possibly a concrete env for
|
||||
/// group secrets.
|
||||
env: String,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -67,7 +107,23 @@ struct ListSecretsResponse {
|
||||
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>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
@@ -80,32 +136,10 @@ async fn list_secrets(
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.repo
|
||||
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn set_secret(
|
||||
async fn set_app_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
@@ -118,23 +152,17 @@ async fn set_secret(
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&input.name)?;
|
||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
|
||||
// (app_id, name). Same path as the SDK secrets::set.
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&s.master_key,
|
||||
app_id,
|
||||
&input.name,
|
||||
&input.value,
|
||||
s.max_value_bytes,
|
||||
)?;
|
||||
s.repo
|
||||
.set(app_id, &input.name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
// App secrets are always env-agnostic; reject a stray `env`.
|
||||
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"app secrets are env-agnostic; set an environment scope on a group secret instead"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
|
||||
}
|
||||
|
||||
async fn delete_secret(
|
||||
async fn delete_app_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
@@ -146,12 +174,161 @@ async fn delete_secret(
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.repo.delete(app_id, &name).await? {
|
||||
delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group handlers (env-scoped)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_group_secrets(
|
||||
State(s): State<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);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Resolution + validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
@@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
|
||||
.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)]
|
||||
pub enum SecretsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("secret not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
@@ -214,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
|
||||
impl IntoResponse for SecretsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
|
||||
//! encoding, authorization, name validation, and the value-size cap all
|
||||
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
|
||||
//!
|
||||
//! Phase 3 made secrets polymorphic-owner + env-scoped (migration
|
||||
//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one
|
||||
//! ancestor group, and a descendant app resolves the nearest one,
|
||||
//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are
|
||||
//! owner-keyed via [`SecretOwner`]; the SDK read path goes through
|
||||
//! [`SecretsRepo::resolve`], which walks the app→group→root chain.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AppId;
|
||||
use picloud_shared::{AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsRepoError {
|
||||
@@ -19,14 +29,22 @@ pub enum SecretsRepoError {
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
|
||||
/// one group; the owner is bound into the AES-GCM AAD (see
|
||||
/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails
|
||||
/// decryption, and it selects the partial-unique conflict target on write.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecretOwner {
|
||||
App(AppId),
|
||||
Group(GroupId),
|
||||
}
|
||||
|
||||
/// An encrypted secret as it lives on disk: ciphertext (auth tag
|
||||
/// appended) plus the nonce it was sealed with.
|
||||
///
|
||||
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
|
||||
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
|
||||
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
|
||||
/// `0042_secrets_envelope_version.sql` adds the column with a default
|
||||
/// of `0`, so existing rows keep working.
|
||||
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredSecret {
|
||||
pub encrypted_value: Vec<u8>,
|
||||
@@ -34,11 +52,21 @@ pub struct StoredSecret {
|
||||
pub version: i16,
|
||||
}
|
||||
|
||||
/// The winner of an inherited-secret resolution: the stored ciphertext
|
||||
/// plus the owner it actually came from (app-own or an ancestor group),
|
||||
/// which the caller needs to pick the right AAD when decrypting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedSecret {
|
||||
pub owner: SecretOwner,
|
||||
pub stored: StoredSecret,
|
||||
}
|
||||
|
||||
/// Admin-surface metadata for one secret. Values are never returned —
|
||||
/// only the name and the last-modified timestamp.
|
||||
/// only the name, its environment scope, and the last-modified timestamp.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretMeta {
|
||||
pub name: String,
|
||||
pub environment_scope: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -60,38 +88,62 @@ pub struct SecretsMetaPage {
|
||||
/// substitute an in-memory backing without Postgres.
|
||||
#[async_trait]
|
||||
pub trait SecretsRepo: Send + Sync {
|
||||
async fn get(
|
||||
/// Resolve the effective secret for `app_id` by name: walk the
|
||||
/// app→ancestor-group chain (depth 0 = the app), env-filter to the
|
||||
/// app's environment or `*`, and return the nearest winner (with
|
||||
/// `@E` beating `*` within a level). `None` if no level defines it.
|
||||
/// This is the runtime injection path — isolation is anchored to
|
||||
/// `app_id`, so an app only ever sees its own + its ancestors' secrets.
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<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>;
|
||||
|
||||
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
|
||||
/// discriminator from [`StoredSecret::version`].
|
||||
/// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
|
||||
/// `version` is the AES-GCM envelope discriminator.
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError>;
|
||||
|
||||
/// Delete; returns whether a row was present.
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
|
||||
/// Delete one `(owner, env_scope, name)` row; returns whether a row
|
||||
/// was present.
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<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(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> 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(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
/// `list_meta` orders by `(name, environment_scope)` — a name can now have
|
||||
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
|
||||
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
|
||||
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
|
||||
/// secret-name char, so it's an unambiguous delimiter).
|
||||
const CURSOR_SEP: char = '\u{1f}';
|
||||
|
||||
fn encode_meta_cursor(name: &str, scope: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
|
||||
}
|
||||
|
||||
fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
s.split_once(CURSOR_SEP)
|
||||
.map(|(n, sc)| (n.to_string(), sc.to_string()))
|
||||
.ok_or(SecretsRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
/// `(owner_column, owner_uuid)` for binding an owner into a query.
|
||||
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
|
||||
match owner {
|
||||
SecretOwner::App(a) => ("app_id", a.into_inner()),
|
||||
SecretOwner::Group(g) => ("group_id", g.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for PostgresSecretsRepo {
|
||||
async fn get(
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
|
||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
||||
WHERE app_id = $1 AND name = $2",
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
|
||||
// Reuse the shared chain-walk ($1 = app_id), join secrets by name,
|
||||
// env-filter, and take the nearest level — `@E` beating `*` within a
|
||||
// level via the secondary sort key. One row out, or none.
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||
s.encrypted_value, s.nonce, s.version \
|
||||
FROM chain c \
|
||||
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.name = $2 \
|
||||
AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \
|
||||
ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \
|
||||
LIMIT 1"
|
||||
);
|
||||
let row: Option<(String, Uuid, Vec<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)
|
||||
.await?;
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&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 {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
@@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (app_id, name) DO UPDATE \
|
||||
// Owner-kind-specific SQL: write only the owner's nullable column and
|
||||
// restate the partial-unique predicate as the ON CONFLICT arbiter.
|
||||
let (col, id) = owner_bind(owner);
|
||||
let predicate = match owner {
|
||||
SecretOwner::App(_) => "app_id IS NOT NULL",
|
||||
SecretOwner::Group(_) => "group_id IS NOT NULL",
|
||||
};
|
||||
let sql = format!(
|
||||
"INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \
|
||||
SET encrypted_value = EXCLUDED.encrypted_value, \
|
||||
nonce = EXCLUDED.nonce, \
|
||||
version = EXCLUDED.version, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
updated_at = NOW()"
|
||||
);
|
||||
sqlx::query(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
|
||||
.bind(app_id.into_inner())
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<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)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
@@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT name FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let (col, id) = owner_bind(owner);
|
||||
// DISTINCT collapses a name that exists at several env scopes into
|
||||
// one entry — the SDK `list` is a name catalogue, not per-scope.
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT name FROM secrets \
|
||||
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3"
|
||||
);
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
|
||||
let next_cursor = if names.len() > limit as usize {
|
||||
@@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
let limit = clamp_limit(limit);
|
||||
let last_name = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
|
||||
let last = match cursor {
|
||||
Some(c) => Some(decode_meta_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let (last_name, last_scope) = match &last {
|
||||
Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT name, updated_at FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"SELECT name, environment_scope, updated_at FROM secrets \
|
||||
WHERE {col} = $1 \
|
||||
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
|
||||
ORDER BY name ASC, environment_scope ASC LIMIT $4"
|
||||
);
|
||||
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(last_name)
|
||||
.bind(last_scope)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<SecretMeta> = rows
|
||||
.into_iter()
|
||||
.map(|(name, updated_at)| SecretMeta { name, updated_at })
|
||||
.map(|(name, environment_scope, updated_at)| SecretMeta {
|
||||
name,
|
||||
environment_scope,
|
||||
updated_at,
|
||||
})
|
||||
.collect();
|
||||
let next_cursor = if items.len() > limit as usize {
|
||||
items.truncate(limit as usize);
|
||||
items.last().map(|m| encode_cursor(&m.name))
|
||||
items
|
||||
.last()
|
||||
.map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -22,22 +22,39 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
SecretsService,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
|
||||
|
||||
// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both
|
||||
// the storage CRUD and the AAD). Re-exported here so the historical
|
||||
// `secrets_service::SecretOwner` path stays stable.
|
||||
pub use crate::secrets_repo::SecretOwner;
|
||||
|
||||
/// Current AES-GCM envelope version for the per-app secret store.
|
||||
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
|
||||
pub const SECRET_ENVELOPE_V1: i16 = 1;
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a
|
||||
/// cross-row swap (e.g. moving one app's ciphertext under another
|
||||
/// app's `(app_id, name)` slot) fails decryption.
|
||||
fn secret_aad(app_id: AppId, name: &str) -> Vec<u8> {
|
||||
format!("secret:{app_id}:{name}").into_bytes()
|
||||
/// The env scope under which an app's OWN secrets are stored. App secrets
|
||||
/// are env-agnostic — only group secrets carry a concrete environment.
|
||||
const APP_SECRET_SCOPE: &str = "*";
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
|
||||
/// swap (moving one owner's ciphertext under another's slot) fails
|
||||
/// decryption. The **app** form is byte-identical to the pre-Phase-3
|
||||
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
|
||||
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
|
||||
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
|
||||
/// group UUID happened to equal an app UUID).
|
||||
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<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
|
||||
@@ -96,7 +113,7 @@ impl Default for SecretsConfig {
|
||||
/// failure (should not happen for a `serde_json::Value`).
|
||||
pub fn seal(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
max_value_bytes: usize,
|
||||
@@ -109,7 +126,7 @@ pub fn seal(
|
||||
actual: plaintext.len(),
|
||||
});
|
||||
}
|
||||
let aad = secret_aad(app_id, name);
|
||||
let aad = secret_aad(owner, name);
|
||||
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
|
||||
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
|
||||
}
|
||||
@@ -125,7 +142,7 @@ pub fn seal(
|
||||
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
||||
pub fn open(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
name: &str,
|
||||
stored: &StoredSecret,
|
||||
) -> Result<serde_json::Value, SecretsError> {
|
||||
@@ -136,7 +153,7 @@ pub fn open(
|
||||
master_key.as_bytes(),
|
||||
),
|
||||
SECRET_ENVELOPE_V1 => {
|
||||
let aad = secret_aad(app_id, name);
|
||||
let aad = secret_aad(owner, name);
|
||||
crypto::decrypt_with_aad(
|
||||
&stored.encrypted_value,
|
||||
&stored.nonce,
|
||||
@@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl {
|
||||
) -> Result<Option<serde_json::Value>, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_read(cx).await?;
|
||||
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
|
||||
// Inherited resolution: the app's own secret, else the nearest
|
||||
// ancestor group's, env-filtered. `resolve` anchors the walk to
|
||||
// `cx.app_id`, so an app can only ever read its own + its ancestors'
|
||||
// secrets — the cross-app isolation boundary. The winning owner comes
|
||||
// back with the row so we decrypt under the AAD it was sealed with.
|
||||
let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
match open(&self.master_key, cx.app_id, name, &stored) {
|
||||
match open(&self.master_key, resolved.owner, name, &resolved.stored) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) => {
|
||||
// A decrypt failure is operationally significant — surface
|
||||
@@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl {
|
||||
) -> Result<(), SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&self.master_key,
|
||||
cx.app_id,
|
||||
name,
|
||||
&value,
|
||||
self.max_value_bytes,
|
||||
)?;
|
||||
let owner = SecretOwner::App(cx.app_id);
|
||||
let (ciphertext, nonce, version) =
|
||||
seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
|
||||
self.repo
|
||||
.set(cx.app_id, name, &ciphertext, &nonce, version)
|
||||
.set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl {
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
Ok(self.repo.delete(cx.app_id, name).await?)
|
||||
Ok(self
|
||||
.repo
|
||||
.delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
@@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl {
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
self.check_read(cx).await?;
|
||||
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
|
||||
let page = self
|
||||
.repo
|
||||
.list_names(SecretOwner::App(cx.app_id), cursor, limit)
|
||||
.await?;
|
||||
Ok(SecretsListPage {
|
||||
names: page.names,
|
||||
next_cursor: page.next_cursor,
|
||||
@@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
|
||||
use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
|
||||
ScriptId, UserId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner
|
||||
/// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never
|
||||
/// collide. These unit tests exercise the app surface (scope `*`); the
|
||||
/// chain-walk `resolve` is journey-tested against real Postgres, so here
|
||||
/// it degrades to a plain app-own `*` lookup.
|
||||
#[derive(Default)]
|
||||
struct InMemorySecretsRepo {
|
||||
data: Mutex<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]
|
||||
impl SecretsRepo for InMemorySecretsRepo {
|
||||
async fn get(
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
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> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, name.to_string()))
|
||||
.get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
self.data.lock().await.insert(
|
||||
(app_id, name.to_string()),
|
||||
(owner_key(owner), env_scope.to_string(), name.to_string()),
|
||||
StoredSecret {
|
||||
encrypted_value: encrypted_value.to_vec(),
|
||||
nonce: nonce.to_vec(),
|
||||
@@ -374,29 +430,36 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, name.to_string()))
|
||||
.remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let ok = owner_key(owner);
|
||||
let last = cursor.map(std::string::ToString::to_string);
|
||||
let mut names: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == app_id)
|
||||
.map(|((_, n), _)| n.clone())
|
||||
.filter(|((o, _, _), _)| *o == ok)
|
||||
.map(|((_, _, n), _)| n.clone())
|
||||
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
let take = (limit as usize).max(1);
|
||||
let next_cursor = if names.len() > take {
|
||||
names.truncate(take);
|
||||
@@ -408,7 +471,7 @@ mod tests {
|
||||
}
|
||||
async fn list_meta(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_owner: SecretOwner,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
@@ -635,7 +698,11 @@ mod tests {
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&(app, "k".to_string()))
|
||||
.get_mut(&(
|
||||
owner_key(SecretOwner::App(app)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
.encrypted_value[0] ^= 0xff;
|
||||
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
||||
@@ -666,10 +733,21 @@ mod tests {
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(a, "k".to_string()))
|
||||
.get(&(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
repo.data.lock().await.insert((b, "k".to_string()), stolen);
|
||||
repo.data.lock().await.insert(
|
||||
(
|
||||
owner_key(SecretOwner::App(b)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
),
|
||||
stolen,
|
||||
);
|
||||
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SecretsError::Corrupted),
|
||||
@@ -677,6 +755,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aad_distinguishes_app_and_group_owner() {
|
||||
// Phase 3: app and group secrets live in disjoint AAD namespaces
|
||||
// (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
|
||||
// for a group must not open as an app secret (and vice-versa),
|
||||
// even if the raw UUIDs were equal — the `group:` infix separates
|
||||
// them. Exercise the free seal/open functions directly.
|
||||
let k = key();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let value = serde_json::json!("shared-config");
|
||||
|
||||
// Seal under the GROUP owner.
|
||||
let (ct, nonce, version) =
|
||||
seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
|
||||
let stored = StoredSecret {
|
||||
encrypted_value: ct,
|
||||
nonce: nonce.to_vec(),
|
||||
version,
|
||||
};
|
||||
// Opens fine as the same group owner.
|
||||
assert_eq!(
|
||||
open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
|
||||
value
|
||||
);
|
||||
// Fails as an app owner (AAD mismatch) — even reusing the UUID.
|
||||
let app_from_group = AppId::from(group.into_inner());
|
||||
assert!(matches!(
|
||||
open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
|
||||
Err(SecretsError::Corrupted)
|
||||
));
|
||||
// And the symmetric direction: an app-sealed row won't open as a group.
|
||||
let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
|
||||
let stored2 = StoredSecret {
|
||||
encrypted_value: ct2,
|
||||
nonce: nonce2.to_vec(),
|
||||
version: v2,
|
||||
};
|
||||
assert!(matches!(
|
||||
open(
|
||||
&k,
|
||||
SecretOwner::Group(GroupId::from(app.into_inner())),
|
||||
"db_url",
|
||||
&stored2
|
||||
),
|
||||
Err(SecretsError::Corrupted)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aad_blocks_cross_name_ciphertext_swap() {
|
||||
// Same app, different name → AAD mismatch.
|
||||
@@ -695,13 +822,21 @@ mod tests {
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(a, "real".to_string()))
|
||||
.get(&(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"real".to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((a, "renamed".to_string()), stolen);
|
||||
repo.data.lock().await.insert(
|
||||
(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"renamed".to_string(),
|
||||
),
|
||||
stolen,
|
||||
);
|
||||
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::Corrupted));
|
||||
}
|
||||
@@ -726,7 +861,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
repo.data.lock().await.insert(
|
||||
(app, "old".to_string()),
|
||||
(
|
||||
owner_key(SecretOwner::App(app)),
|
||||
"*".to_string(),
|
||||
"old".to_string(),
|
||||
),
|
||||
StoredSecret {
|
||||
encrypted_value: ct,
|
||||
nonce: nonce.to_vec(),
|
||||
|
||||
@@ -289,7 +289,17 @@ async fn validate_trigger_target(
|
||||
.ok_or_else(|| {
|
||||
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
|
||||
})?;
|
||||
if script.app_id != app_id {
|
||||
// Phase 4: a trigger target must be invocable in this app's context —
|
||||
// owned by the app, or by a group on the app's ancestor chain (inherited).
|
||||
// App-owned is the in-memory fast path (no query); only a group-owned
|
||||
// candidate pays the chain lookup.
|
||||
let invocable = script.is_owned_by_app(app_id)
|
||||
|| (script.group_id.is_some()
|
||||
&& scripts
|
||||
.is_invocable_by_app(script_id, app_id)
|
||||
.await
|
||||
.map_err(map_script_repo_err)?);
|
||||
if !invocable {
|
||||
return Err(TriggersApiError::Invalid(format!(
|
||||
"script {script_id} does not belong to this app"
|
||||
)));
|
||||
@@ -1348,7 +1358,8 @@ mod tests {
|
||||
script_id,
|
||||
picloud_shared::Script {
|
||||
id: script_id,
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: format!(
|
||||
"{}_{}",
|
||||
match kind {
|
||||
@@ -1393,9 +1404,29 @@ mod tests {
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|s| s.app_id == app_id && s.name == name)
|
||||
.find(|s| s.app_id == Some(app_id) && s.name == name)
|
||||
.cloned())
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
// No hierarchy in this in-memory repo; inherited == own-scope.
|
||||
self.get_by_name(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, crate::repo::ScriptRepositoryError> {
|
||||
Ok(self
|
||||
.existing
|
||||
.lock()
|
||||
.await
|
||||
.get(&script_id)
|
||||
.is_some_and(|s| s.is_owned_by_app(app_id)))
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
@@ -1407,6 +1438,12 @@ mod tests {
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,8 @@ table: apps
|
||||
description: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
group_id: uuid NOT NULL
|
||||
environment: text NULL
|
||||
|
||||
table: cron_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
@@ -212,6 +214,23 @@ table: files_trigger_details
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: group_members
|
||||
group_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: groups
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
parent_id: uuid NULL
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
description: text NULL
|
||||
structure_version: bigint NOT NULL default=1
|
||||
owner_project: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: kv_entries
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -277,6 +296,7 @@ table: routes
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
@@ -295,17 +315,21 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
app_id: uuid NULL
|
||||
kind: text NOT NULL default='endpoint'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
group_id: uuid NULL
|
||||
|
||||
table: secrets
|
||||
app_id: uuid NOT NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
encrypted_value: bytea NOT NULL
|
||||
nonce: bytea NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
version: smallint NOT NULL default=0
|
||||
group_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
|
||||
table: topics
|
||||
app_id: uuid NOT NULL
|
||||
@@ -328,6 +352,18 @@ table: triggers
|
||||
registered_by_principal: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
name: text NOT NULL default=(gen_random_uuid())::text
|
||||
|
||||
table: vars
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
key: text NOT NULL
|
||||
value: jsonb NOT NULL
|
||||
is_tombstone: boolean NOT NULL default=false
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
## indexes
|
||||
|
||||
@@ -395,6 +431,7 @@ indexes on app_users:
|
||||
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
|
||||
|
||||
indexes on apps:
|
||||
apps_group_id_idx: public.apps USING btree (group_id)
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
@@ -433,6 +470,15 @@ indexes on files:
|
||||
indexes on files_trigger_details:
|
||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on group_members:
|
||||
group_members_pkey: public.group_members USING btree (group_id, user_id)
|
||||
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||
|
||||
indexes on groups:
|
||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||
groups_pkey: public.groups USING btree (id)
|
||||
groups_slug_key: public.groups USING btree (slug)
|
||||
|
||||
indexes on kv_entries:
|
||||
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
||||
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
||||
@@ -473,12 +519,16 @@ indexes on script_imports:
|
||||
indexes on scripts:
|
||||
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_app_name_uidx: public.scripts USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
scripts_group_id_idx: public.scripts USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
scripts_group_name_uidx: public.scripts USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
indexes on secrets:
|
||||
idx_secrets_app: public.secrets USING btree (app_id)
|
||||
secrets_pkey: public.secrets USING btree (app_id, name)
|
||||
idx_secrets_group: public.secrets USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
|
||||
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
|
||||
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
@@ -487,8 +537,16 @@ indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
indexes on vars:
|
||||
vars_app_id_idx: public.vars USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
vars_app_uidx: public.vars USING btree (app_id, environment_scope, key) WHERE (app_id IS NOT NULL)
|
||||
vars_group_id_idx: public.vars USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
|
||||
vars_pkey: public.vars USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on abandoned_executions:
|
||||
@@ -561,6 +619,7 @@ constraints on app_users:
|
||||
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on apps:
|
||||
[FOREIGN KEY] apps_group_id_fk: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
@@ -605,6 +664,17 @@ constraints on files_trigger_details:
|
||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on group_members:
|
||||
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
|
||||
|
||||
constraints on groups:
|
||||
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on kv_entries:
|
||||
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
||||
@@ -648,13 +718,16 @@ constraints on scripts:
|
||||
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
|
||||
[CHECK] scripts_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[FOREIGN KEY] scripts_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on secrets:
|
||||
[CHECK] secrets_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
|
||||
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
|
||||
constraints on topics:
|
||||
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
|
||||
@@ -670,6 +743,12 @@ constraints on triggers:
|
||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on vars:
|
||||
[CHECK] vars_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] vars_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
@@ -715,3 +794,9 @@ constraints on triggers:
|
||||
0042: secrets envelope version
|
||||
0043: execution logs source
|
||||
0044: delete reserved path routes
|
||||
0045: scripts routes enabled
|
||||
0046: trigger name
|
||||
0047: groups
|
||||
0048: vars
|
||||
0049: group secrets
|
||||
0050: group scripts
|
||||
|
||||
@@ -126,9 +126,16 @@ where
|
||||
if !script.enabled {
|
||||
return Err(ApiError::NotFound(id));
|
||||
}
|
||||
// The direct-execute bypass runs a script under its *owning app's*
|
||||
// context. A group-owned script (Phase 4) is a template with no single
|
||||
// app, so it cannot be invoked through this id-addressed bypass — it
|
||||
// runs only via a descendant app's route/trigger, which supplies the
|
||||
// execution-context app. Fail closed (404) rather than guess an app.
|
||||
let app_id = script.app_id.ok_or(ApiError::NotFound(id))?;
|
||||
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?;
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
req.script_owner = script.owner();
|
||||
let request_id = req.request_id;
|
||||
let request_path = req.path.clone();
|
||||
let request_headers = req.headers.clone();
|
||||
@@ -151,7 +158,7 @@ where
|
||||
// audit-visible platform — but a sink failure must not mask the
|
||||
// user-facing result, so we only log a warning if it fails.
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
app_id,
|
||||
id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -622,6 +629,10 @@ fn build_exec_request(
|
||||
// Overwritten by the handler after the script is resolved.
|
||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||
app_id,
|
||||
// Set by the handler from the resolved script's owner. The
|
||||
// id-bypass runs app-owned scripts only (group scripts 404 here),
|
||||
// so this is always `App(app_id)`; `None` falls back to that.
|
||||
script_owner: None,
|
||||
principal,
|
||||
// Direct invocations are at depth 0 with a self-referential
|
||||
// root. The triggers framework (v1.1.1) increments depth and
|
||||
|
||||
@@ -309,6 +309,33 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}/scripts` — a group's own
|
||||
/// scripts (Phase 4). Not inherited; just the group's rows.
|
||||
pub async fn group_scripts_list(&self, group_ident: &str) -> Result<Vec<Script>> {
|
||||
let g = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{g}/scripts"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{id_or_slug}/scripts` — create a
|
||||
/// group-owned script (Phase 4).
|
||||
pub async fn group_scripts_create(
|
||||
&self,
|
||||
group_ident: &str,
|
||||
body: &CreateGroupScriptBody<'_>,
|
||||
) -> Result<Script> {
|
||||
let g = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/groups/{g}/scripts"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics. `cfg` carries
|
||||
/// optional per-script runtime overrides (G3); unset fields are
|
||||
@@ -657,39 +684,138 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
|
||||
let app = seg(app);
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names +
|
||||
/// last-modified for the owner. Values never travel on this path. `env`
|
||||
/// is only meaningful for group owners (app secrets are env-agnostic).
|
||||
pub async fn secrets_list(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
env: Option<&str>,
|
||||
) -> Result<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
|
||||
.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()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path — only `app` needs encoding.
|
||||
let app = seg(app);
|
||||
/// `PUT /api/v1/admin/{apps,groups}/{id}/vars`
|
||||
pub async fn vars_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let mut body = serde_json::json!({ "key": key, "value": value, "tombstone": tombstone });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.json(&serde_json::json!({ "name": name, "value": value }))
|
||||
.request(Method::PUT, &format!("{}/vars", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}`
|
||||
pub async fn vars_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/vars/{}", owner.base_path(), seg(key));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved
|
||||
/// (group-inherited) vars plus masked secret statuses, each annotated with
|
||||
/// the owner that won and the layers it merged from (§4.6).
|
||||
pub async fn config_effective(&self, app: &str) -> Result<EffectiveConfigDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/config/effective"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- domains ----------
|
||||
@@ -1042,33 +1168,47 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
||||
/// bundle against the app's live state. Read-only.
|
||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
||||
let app = seg(app);
|
||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/plan` — diff a bundle
|
||||
/// against an app OR group node (Phase 5).
|
||||
pub async fn plan_node(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
slug: &str,
|
||||
bundle: &serde_json::Value,
|
||||
) -> Result<PlanDto> {
|
||||
let slug = seg(slug);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
|
||||
)
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
||||
/// app to the bundle in one transaction.
|
||||
pub async fn apply(
|
||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
|
||||
/// app OR group node in one transaction (Phase 5).
|
||||
pub async fn apply_node(
|
||||
&self,
|
||||
app: &str,
|
||||
kind: NodeKind,
|
||||
slug: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let slug = seg(slug);
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/{}/{slug}/apply", kind.path()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
@@ -1080,13 +1220,52 @@ impl Client {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe app changed since your last `pic plan`. Re-run \
|
||||
"{msg}\nThe live state changed since your last `pic plan`. Re-run \
|
||||
`pic plan` to review the new diff, then `pic apply` — or \
|
||||
`pic apply --force` to apply without re-reviewing."
|
||||
));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
|
||||
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/plan")
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
||||
/// transaction (Phase 5).
|
||||
pub async fn apply_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
||||
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
||||
));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -1109,6 +1288,23 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// Which kind of node a plan/apply targets (Phase 5) — selects the API path
|
||||
/// prefix (`apps` vs `groups`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NodeKind {
|
||||
App,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl NodeKind {
|
||||
fn path(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "apps",
|
||||
Self::Group => "groups",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
@@ -1122,6 +1318,8 @@ pub struct PlanDto {
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1136,6 +1334,32 @@ pub struct ChangeDto {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||
/// bound-plan token for the whole subtree.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TreePlanDto {
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<NodePlanDto>,
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NodePlanDto {
|
||||
pub kind: String,
|
||||
pub slug: String,
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
@@ -1156,6 +1380,12 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub vars_created: u32,
|
||||
#[serde(default)]
|
||||
pub vars_updated: u32,
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1394,6 +1624,37 @@ pub struct DeadLetterDto {
|
||||
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)]
|
||||
pub struct SecretListDto {
|
||||
pub secrets: Vec<SecretItemDto>,
|
||||
@@ -1405,9 +1666,72 @@ pub struct SecretListDto {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretItemDto {
|
||||
pub name: String,
|
||||
/// Env scope. Only meaningful (and populated) for group owners; the server
|
||||
/// omits it for app secrets, so default to `*` when absent.
|
||||
#[serde(default = "default_env")]
|
||||
pub env: String,
|
||||
pub updated_at: DateTime<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
|
||||
/// unset field is omitted so the server applies its own default (and the
|
||||
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
||||
@@ -1436,6 +1760,24 @@ pub struct CreateScriptBody<'a> {
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
/// Phase 4: body for `POST /groups/{id}/scripts`. Like `CreateScriptBody`
|
||||
/// minus `app_id` — the owning group comes from the path.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupScriptBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
@@ -28,36 +28,38 @@ pub async fn run(
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
let kind = if manifest.is_group() {
|
||||
NodeKind::Group
|
||||
} else {
|
||||
NodeKind::App
|
||||
};
|
||||
let slug = manifest.slug().to_string();
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
confirm_prune(&slug)?;
|
||||
}
|
||||
|
||||
// Bound-plan check: replay the token from the last `pic plan` (for this
|
||||
// app) so the server refuses if the app changed since it was reviewed.
|
||||
// node) so the server refuses if it changed since it was reviewed.
|
||||
// `--force` skips it; no recorded plan means no check (apply still works
|
||||
// standalone). The token is single-use — cleared after a successful apply.
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(base_dir)
|
||||
.filter(|l| l.app == manifest.app.slug)
|
||||
.filter(|l| l.app == slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
|
||||
crate::linkstate::clear_plan(base_dir, &slug);
|
||||
|
||||
let label = if manifest.is_group() { "group" } else { "app" };
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(label, slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
@@ -75,6 +77,80 @@ pub async fn run(
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
)
|
||||
.field(
|
||||
"vars",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
||||
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
||||
pub async fn run_tree(
|
||||
dir: &Path,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, node_count) = crate::discover::build_tree(dir, env)?;
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
||||
}
|
||||
|
||||
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(dir)
|
||||
.filter(|l| l.app == token_key)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply_tree(&bundle, prune, expected_token.as_deref())
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(dir, token_key);
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("nodes", node_count.to_string())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"routes",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.routes_created, report.routes_updated, report.routes_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
)
|
||||
.field(
|
||||
"vars",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
//! `pic config --effective` — read-only view of an app's resolved
|
||||
//! configuration, with secret values masked (§4.6).
|
||||
//!
|
||||
//! Single-app today: "config" means the app's secrets, cross-referenced
|
||||
//! against the manifest so an operator can see at a glance which declared
|
||||
//! secrets are still unset and which live secrets aren't declared. Values are
|
||||
//! never fetched or shown — only `<set>` / `<unset>`. The command is shaped to
|
||||
//! grow an `--explain` mode and inherited `vars` once groups/vars land (the
|
||||
//! Phase-3 multi-level resolution).
|
||||
//! Two sections:
|
||||
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
|
||||
//! annotated with the owner that won (kind + depth) and its scope. Pass
|
||||
//! `--explain` to also dump each var's `merged_from` provenance — the
|
||||
//! ordered (depth, scope) layers that fed the resolution.
|
||||
//! * `secrets` — masked statuses, cross-referenced against the manifest so an
|
||||
//! operator can see which declared secrets are still unset and which live
|
||||
//! secrets aren't declared. Values are never fetched or shown here; the
|
||||
//! server reports only `<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::path::Path;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, EffectiveOwnerDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`).
|
||||
fn owner_label(owner: &EffectiveOwnerDto) -> String {
|
||||
format!("{}@{}", owner.kind, owner.depth)
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
effective: bool,
|
||||
explain: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
@@ -33,25 +45,61 @@ pub async fn run(
|
||||
// masked report targets the app those commands would act on — not the
|
||||
// base slug — when an overlay re-points slug/secrets.
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
if manifest.is_group() {
|
||||
bail!(
|
||||
"`pic config --effective` resolves an APP's inherited config; \
|
||||
a [group] manifest has no single effective view"
|
||||
);
|
||||
}
|
||||
|
||||
let eff = client.config_effective(manifest.slug()).await?;
|
||||
|
||||
// --- 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> = client
|
||||
.secrets_list(&manifest.app.slug)
|
||||
.await?
|
||||
.secrets
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect();
|
||||
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
|
||||
|
||||
let mut table = Table::new(["secret", "value", "status"]);
|
||||
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
|
||||
for name in declared.union(&on_server) {
|
||||
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
||||
(true, true) => ("<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, false) => unreachable!("name came from one of the two sets"),
|
||||
};
|
||||
table.row([name.clone(), value.to_string(), status.to_string()]);
|
||||
let (owner, scope) = match eff.secrets.get(name) {
|
||||
Some(s) => (owner_label(&s.owner), s.scope.clone()),
|
||||
None => ("-".to_string(), "-".to_string()),
|
||||
};
|
||||
table.row([
|
||||
name.clone(),
|
||||
value.to_string(),
|
||||
status.to_string(),
|
||||
owner,
|
||||
scope,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
|
||||
@@ -109,11 +109,12 @@ pub fn run(
|
||||
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
|
||||
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
@@ -137,6 +138,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,4 +22,5 @@ pub mod secrets;
|
||||
pub mod topics;
|
||||
pub mod triggers;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
pub mod whoami;
|
||||
|
||||
@@ -9,11 +9,15 @@ use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::client::{ChangeDto, Client, PlanDto};
|
||||
use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single
|
||||
/// slug, so its token is stored under this reserved marker.
|
||||
pub const TREE_TOKEN_KEY: &str = "<tree>";
|
||||
|
||||
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
@@ -21,15 +25,18 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
let kind = if manifest.is_group() {
|
||||
NodeKind::Group
|
||||
} else {
|
||||
NodeKind::App
|
||||
};
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
let plan = client.plan_node(kind, manifest.slug(), &bundle).await?;
|
||||
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
||||
// app changing underneath the reviewed plan (best-effort — a read-only
|
||||
// node changing underneath the reviewed plan (best-effort — a read-only
|
||||
// plan still succeeds if the project dir isn't writable).
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) =
|
||||
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
|
||||
{
|
||||
if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) {
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +44,48 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic plan --dir <root>` (Phase 5): diff a whole project tree — every
|
||||
/// `picloud.toml` under `root` — against the live group/app subtree.
|
||||
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, _count) = crate::discover::build_tree(dir, env)?;
|
||||
let plan = client.plan_tree(&bundle).await?;
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
render_tree(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
table.row([
|
||||
node.clone(),
|
||||
rk.to_string(),
|
||||
c.op.clone(),
|
||||
c.key.clone(),
|
||||
c.detail.clone().unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
|
||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||
/// array, secrets are names only.
|
||||
@@ -95,11 +144,22 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
|
||||
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
|
||||
// wire shape matches the server's `serde_json::Value`.
|
||||
let mut vars = Map::new();
|
||||
for (k, v) in &manifest.vars {
|
||||
vars.insert(
|
||||
k.clone(),
|
||||
serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -114,11 +174,12 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -14,7 +14,7 @@ use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
@@ -45,7 +45,28 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
||||
let secrets = client.secrets_list(app_ident).await?.secrets;
|
||||
let secrets = client
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
|
||||
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
|
||||
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
|
||||
let mut manifest_vars = std::collections::BTreeMap::new();
|
||||
for v in client.vars_list(VarOwnerArg::App(app_ident)).await?.vars {
|
||||
if v.env != "*" || v.is_tombstone {
|
||||
continue;
|
||||
}
|
||||
match toml::Value::try_from(&v.value) {
|
||||
Ok(tv) => {
|
||||
manifest_vars.insert(v.key, tv);
|
||||
}
|
||||
Err(_) => eprintln!(
|
||||
"warning: skipping var `{}` — its value can't be represented in TOML",
|
||||
v.key
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
@@ -190,17 +211,19 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
@@ -209,7 +232,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("app", manifest.slug().to_string())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
|
||||
@@ -8,16 +8,33 @@ use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody, ScriptConfig};
|
||||
use crate::client::{Client, CreateGroupScriptBody, CreateScriptBody, ScriptConfig};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||
|
||||
if let Some(group_ident) = group {
|
||||
// Phase 4: a group's own (non-inherited) scripts. The owner column
|
||||
// shows the group, not an app.
|
||||
let scripts = client.group_scripts_list(group_ident).await?;
|
||||
for s in scripts {
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
format!("group:{group_ident}"),
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(ident) = app {
|
||||
let app = client.apps_get(ident).await?;
|
||||
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||
@@ -40,10 +57,15 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||
for s in scripts {
|
||||
let app_slug = slug_by_id
|
||||
.get(&s.app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
// Group-owned scripts (Phase 4) have no app; show a marker rather
|
||||
// than a slug. App-owned scripts resolve their slug as before.
|
||||
let app_slug = match s.app_id {
|
||||
Some(app_id) => slug_by_id
|
||||
.get(&app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
None => "(group)".to_string(),
|
||||
};
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
app_slug,
|
||||
@@ -59,7 +81,8 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
|
||||
pub async fn deploy(
|
||||
file: &Path,
|
||||
app_ident: &str,
|
||||
app_ident: Option<&str>,
|
||||
group_ident: Option<&str>,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
cfg: &ScriptConfig,
|
||||
@@ -84,28 +107,61 @@ pub async fn deploy(
|
||||
})?,
|
||||
};
|
||||
|
||||
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||
// gives us the canonical app_id needed for create.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
// Deploy is create-or-update by name within the chosen owner. Exactly
|
||||
// one owner — clap marks `--group` as conflicting with `--app`, so this
|
||||
// only rejects the both-absent case.
|
||||
let (script, action) = match (app_ident, group_ident) {
|
||||
(Some(app_ident), None) => {
|
||||
// Slug-or-id resolution: a single GET gives the canonical app_id.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
}
|
||||
}
|
||||
(None, Some(group_ident)) => {
|
||||
// Group scripts update through the owner-polymorphic by-id
|
||||
// endpoint; create through the group endpoint.
|
||||
let existing = client.group_scripts_list(group_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateGroupScriptBody {
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(
|
||||
client.group_scripts_create(group_ident, &body).await?,
|
||||
"created",
|
||||
)
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
return Err(anyhow!("deploy requires exactly one of --app or --group"));
|
||||
}
|
||||
};
|
||||
// Emit the script object so `--output json` callers can capture the
|
||||
// id for the follow-up routes/triggers calls.
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
//! `pic secrets` subcommands: `ls`, `set`, `rm`.
|
||||
//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets.
|
||||
//!
|
||||
//! Set reads the secret value from stdin (the only safe channel —
|
||||
//! inline values would leak into shell history). The value is sent
|
||||
//! as a JSON string; pass `--json` to interpret stdin as raw JSON
|
||||
//! (numbers, maps, …) instead.
|
||||
//! Exactly one of `--group` / `--app` selects the owner (mirroring
|
||||
//! `pic vars`). Set reads the secret value from stdin (the only safe
|
||||
//! channel — inline values would leak into shell history). The value is
|
||||
//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON
|
||||
//! (numbers, maps, …) instead. `--env` is only meaningful for group
|
||||
//! owners (app secrets are env-agnostic).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<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 client = Client::from_creds(&creds)?;
|
||||
let resp = client.secrets_list(app).await?;
|
||||
let mut table = Table::new(["name", "updated_at"]);
|
||||
let resp = client.secrets_list(owner, env).await?;
|
||||
let mut table = Table::new(["name", "env", "updated_at"]);
|
||||
for s in resp.secrets {
|
||||
table.row([s.name, s.updated_at.to_rfc3339()]);
|
||||
table.row([s.name, s.env, s.updated_at.to_rfc3339()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
pub async fn set(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
as_json: bool,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut buf = String::new();
|
||||
@@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
} else {
|
||||
serde_json::Value::String(trimmed.to_string())
|
||||
};
|
||||
client.secrets_set(app, name, value).await?;
|
||||
client.secrets_set(owner, name, value, env).await?;
|
||||
println!("Set secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, name: &str) -> Result<()> {
|
||||
pub async fn rm(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.secrets_delete(app, name).await?;
|
||||
client.secrets_delete(owner, name, env).await?;
|
||||
println!("Deleted secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic secrets read --group <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(())
|
||||
}
|
||||
75
crates/picloud-cli/src/discover.rs
Normal file
75
crates/picloud-cli/src/discover.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
|
||||
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
|
||||
//! apply}` endpoints consume. Each manifest becomes one node (app or group);
|
||||
//! the server resolves ancestry from its own group tree, so the on-disk
|
||||
//! nesting is organizational, not authoritative here.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::manifest::{Manifest, MANIFEST_FILE};
|
||||
|
||||
/// Find every `picloud.toml` under `root` (recursively), skipping the
|
||||
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
|
||||
/// overlays (`picloud.<env>.toml`) are not matched — only the base filename.
|
||||
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
walk(root, &mut out)?;
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let entries =
|
||||
std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let ft = entry.file_type()?;
|
||||
if ft.is_dir() {
|
||||
if matches!(
|
||||
name.to_str(),
|
||||
Some(".picloud" | ".git" | "scripts" | "target")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
walk(&entry.path(), out)?;
|
||||
} else if name == MANIFEST_FILE {
|
||||
out.push(entry.path());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
|
||||
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
|
||||
/// that names the same (kind, slug) twice.
|
||||
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
||||
let paths = find_manifests(root)?;
|
||||
if paths.is_empty() {
|
||||
bail!(
|
||||
"no {MANIFEST_FILE} found under {} — nothing to apply",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
let mut nodes = Vec::with_capacity(paths.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
for path in &paths {
|
||||
let manifest = Manifest::load_with_env(path, env)
|
||||
.with_context(|| format!("loading {}", path.display()))?;
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
let kind = if manifest.is_group() { "group" } else { "app" };
|
||||
let slug = manifest.slug().to_string();
|
||||
if !seen.insert((kind.to_string(), slug.clone())) {
|
||||
bail!("project tree declares {kind} `{slug}` more than once");
|
||||
}
|
||||
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
||||
}
|
||||
let count = nodes.len();
|
||||
Ok((json!({ "nodes": nodes }), count))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod discover;
|
||||
mod linkstate;
|
||||
mod manifest;
|
||||
mod output;
|
||||
@@ -144,6 +145,14 @@ enum Cmd {
|
||||
cmd: MembersCmd,
|
||||
},
|
||||
|
||||
/// Config vars (Phase 3) — set / list / delete group- or app-owned
|
||||
/// env-scoped vars. Values inherit down the group tree; an app value
|
||||
/// overrides an inherited one (proximity wins).
|
||||
Vars {
|
||||
#[command(subcommand)]
|
||||
cmd: VarsCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
@@ -194,6 +203,11 @@ struct ApplyArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Phase 5: apply a whole project TREE — every `picloud.toml` under this
|
||||
/// directory (groups + apps) reconciled in one atomic transaction.
|
||||
/// Mutually exclusive with `--file`.
|
||||
#[arg(long, conflicts_with = "file")]
|
||||
dir: Option<PathBuf>,
|
||||
/// Delete live scripts/routes/triggers absent from the manifest.
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
@@ -217,6 +231,10 @@ struct PlanArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Phase 5: plan a whole project TREE — every `picloud.toml` under this
|
||||
/// directory (groups + apps). Mutually exclusive with `--file`.
|
||||
#[arg(long, conflicts_with = "file")]
|
||||
dir: Option<PathBuf>,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before diffing.
|
||||
#[arg(long)]
|
||||
@@ -259,6 +277,10 @@ struct ConfigArgs {
|
||||
/// Show the effective (resolved) config with secrets masked.
|
||||
#[arg(long)]
|
||||
effective: bool,
|
||||
/// With `--effective`, also print each resolved var's `merged_from`
|
||||
/// provenance — the ordered (depth, scope) layers it merged from.
|
||||
#[arg(long)]
|
||||
explain: bool,
|
||||
/// Resolve against the `picloud.<env>.toml` overlay (per-env slug +
|
||||
/// secrets), matching `pic plan --env` / `pic apply --env`.
|
||||
#[arg(long)]
|
||||
@@ -517,15 +539,19 @@ enum DomainsCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; without, one
|
||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||
/// group's own scripts (Phase 4); without either, one
|
||||
/// `GET /admin/scripts` for everything the caller can see.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Phase 4: list a group's own (non-inherited) scripts.
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
|
||||
/// Upload a `.rhai` file. Patches the existing script with the
|
||||
/// matching name in `--app` if one exists, otherwise creates it.
|
||||
/// matching name in `--app`/`--group` if one exists, otherwise creates it.
|
||||
Deploy(DeployArgs),
|
||||
|
||||
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||
@@ -539,8 +565,14 @@ enum ScriptsCmd {
|
||||
#[derive(Args)]
|
||||
struct DeployArgs {
|
||||
file: PathBuf,
|
||||
/// Owning app (slug or id). Exactly one of `--app` / `--group`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
app: Option<String>,
|
||||
/// Phase 4: deploy a group-owned script (template inherited by
|
||||
/// descendant apps) instead of an app-owned one. Exactly one of
|
||||
/// `--app` / `--group`.
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -1134,31 +1166,114 @@ enum DeadLettersCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SecretsCmd {
|
||||
/// List secret names + last-modified for an app. Values never
|
||||
/// leave the server.
|
||||
/// List secret names + last-modified for the owner. Values never
|
||||
/// leave the server. `--env` filters group secrets (app secrets are
|
||||
/// env-agnostic; the `env` column shows the scope for groups).
|
||||
Ls {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<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
|
||||
/// channel — inline values would land in shell history). Pipe
|
||||
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
|
||||
/// For group owners, `--env` scopes the secret.
|
||||
Set {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
name: String,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
|
||||
/// string literal.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Delete a secret by name.
|
||||
/// Delete a secret by name (optionally env-scoped for groups).
|
||||
Rm {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<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>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1224,22 +1339,45 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => {
|
||||
cmds::apply::run(
|
||||
Cmd::Apply(args) => match &args.dir {
|
||||
Some(dir) => {
|
||||
cmds::apply::run_tree(
|
||||
dir,
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
args.env.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
cmds::apply::run(
|
||||
&args.file,
|
||||
args.env.as_deref(),
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
Cmd::Plan(args) => match &args.dir {
|
||||
Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await,
|
||||
None => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
||||
},
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
||||
Cmd::Config(args) => {
|
||||
cmds::config::run(
|
||||
&args.file,
|
||||
args.effective,
|
||||
args.explain,
|
||||
args.env.as_deref(),
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
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::Config(args) => {
|
||||
cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await
|
||||
}
|
||||
Cmd::Init(args) => cmds::init::run(
|
||||
&args.dir,
|
||||
args.slug.as_deref(),
|
||||
@@ -1363,15 +1501,16 @@ async fn main() -> ExitCode {
|
||||
},
|
||||
} => cmds::groups::members_rm(&group, &user_id).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
cmd: ScriptsCmd::Ls { app, group },
|
||||
} => cmds::scripts::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Deploy(args),
|
||||
} => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.app.as_deref(),
|
||||
args.group.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
@@ -1414,7 +1553,8 @@ async fn main() -> ExitCode {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.app.as_deref(),
|
||||
args.group.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
@@ -1741,14 +1881,39 @@ async fn main() -> ExitCode {
|
||||
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
|
||||
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Ls { app },
|
||||
} => cmds::secrets::ls(&app, mode).await,
|
||||
cmd: SecretsCmd::Ls { group, app, env },
|
||||
} => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Set { app, name, json },
|
||||
} => cmds::secrets::set(&app, &name, json).await,
|
||||
cmd:
|
||||
SecretsCmd::Set {
|
||||
group,
|
||||
app,
|
||||
name,
|
||||
env,
|
||||
json,
|
||||
},
|
||||
} => {
|
||||
cmds::secrets::set(
|
||||
group.as_deref(),
|
||||
app.as_deref(),
|
||||
&name,
|
||||
env.as_deref(),
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Rm { app, name },
|
||||
} => cmds::secrets::rm(&app, &name).await,
|
||||
cmd:
|
||||
SecretsCmd::Rm {
|
||||
group,
|
||||
app,
|
||||
name,
|
||||
env,
|
||||
},
|
||||
} => cmds::secrets::rm(group.as_deref(), app.as_deref(), &name, env.as_deref()).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Read { group, name, env },
|
||||
} => cmds::secrets::read(&group, &name, env.as_deref()).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Ls { app },
|
||||
} => cmds::members::ls(&app, mode).await,
|
||||
@@ -1761,6 +1926,41 @@ async fn main() -> ExitCode {
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Rm { app, user_id },
|
||||
} => cmds::members::rm(&app, &user_id).await,
|
||||
Cmd::Vars {
|
||||
cmd: VarsCmd::Ls { group, app },
|
||||
} => cmds::vars::ls(group.as_deref(), app.as_deref(), mode).await,
|
||||
Cmd::Vars {
|
||||
cmd:
|
||||
VarsCmd::Set {
|
||||
key,
|
||||
value,
|
||||
group,
|
||||
app,
|
||||
env,
|
||||
json,
|
||||
tombstone,
|
||||
},
|
||||
} => {
|
||||
cmds::vars::set(
|
||||
group.as_deref(),
|
||||
app.as_deref(),
|
||||
&key,
|
||||
&value,
|
||||
env.as_deref(),
|
||||
json,
|
||||
tombstone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Vars {
|
||||
cmd:
|
||||
VarsCmd::Rm {
|
||||
key,
|
||||
group,
|
||||
app,
|
||||
env,
|
||||
},
|
||||
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
||||
//! (a secret name) resolved server-side at apply.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -29,7 +30,12 @@ pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub app: ManifestApp,
|
||||
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
|
||||
/// Exactly one is present (enforced by [`Manifest::parse`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<ManifestApp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<ManifestGroup>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -38,12 +44,59 @@ pub struct Manifest {
|
||||
pub triggers: ManifestTriggers,
|
||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
||||
pub secrets: ManifestSecrets,
|
||||
/// `[vars]` — app config key → value, reconciled at app scope `*`. Values
|
||||
/// are non-secret and live inline (unlike `[secrets]`, which is name-only).
|
||||
/// The overlay merges per-env, last-write-wins by key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text.
|
||||
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing manifest TOML")
|
||||
let m: Self = toml::from_str(text).context("parsing manifest TOML")?;
|
||||
match (&m.app, &m.group) {
|
||||
(Some(_), None) | (None, Some(_)) => {}
|
||||
(Some(_), Some(_)) => {
|
||||
anyhow::bail!(
|
||||
"manifest declares both [app] and [group]; a node is one or the other"
|
||||
)
|
||||
}
|
||||
(None, None) => {
|
||||
anyhow::bail!("manifest declares neither [app] nor [group]")
|
||||
}
|
||||
}
|
||||
// A group node owns only scripts + vars (+ secret names) — routes and
|
||||
// triggers are app concerns. Reject them early with a clear message.
|
||||
if m.group.is_some() {
|
||||
if !m.routes.is_empty() {
|
||||
anyhow::bail!(
|
||||
"a [group] manifest cannot declare [[routes]] — routes bind to an app"
|
||||
);
|
||||
}
|
||||
if !m.triggers.is_empty() {
|
||||
anyhow::bail!(
|
||||
"a [group] manifest cannot declare [[triggers]] — triggers belong to an app"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// This node's slug (app or group).
|
||||
#[must_use]
|
||||
pub fn slug(&self) -> &str {
|
||||
match (&self.app, &self.group) {
|
||||
(Some(a), _) => &a.slug,
|
||||
(_, Some(g)) => &g.slug,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this manifest declares a `[group]` node (Phase 5).
|
||||
#[must_use]
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
@@ -61,9 +114,9 @@ impl Manifest {
|
||||
|
||||
/// Load the base manifest, then (if `env` is set) merge the sparse
|
||||
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
|
||||
/// where "an environment is an app". The overlay carries per-env slug +
|
||||
/// secrets; scripts/routes/triggers stay in the shared base. (Rich
|
||||
/// per-key `vars` resolution is Phase 3.)
|
||||
/// where "an environment is an app". The overlay carries per-env slug,
|
||||
/// secrets, and vars (overlay vars win per key); scripts/routes/triggers
|
||||
/// stay in the shared base.
|
||||
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
|
||||
let mut base = Self::load(base_path)?;
|
||||
if let Some(env) = env {
|
||||
@@ -84,17 +137,24 @@ impl Manifest {
|
||||
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
|
||||
/// replace the base's; overlay secret names union into the base set.
|
||||
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
|
||||
if let Some(slug) = overlay.app.slug {
|
||||
self.app.slug = slug;
|
||||
}
|
||||
if let Some(name) = overlay.app.name {
|
||||
self.app.name = name;
|
||||
if let Some(app) = &mut self.app {
|
||||
if let Some(slug) = overlay.app.slug {
|
||||
app.slug = slug;
|
||||
}
|
||||
if let Some(name) = overlay.app.name {
|
||||
app.name = name;
|
||||
}
|
||||
}
|
||||
for n in overlay.secrets.names {
|
||||
if !self.secrets.names.contains(&n) {
|
||||
self.secrets.names.push(n);
|
||||
}
|
||||
}
|
||||
// Overlay vars override base vars per key (the env-specific value of
|
||||
// an env-agnostic default); keys only in the base are kept.
|
||||
for (k, v) in overlay.vars {
|
||||
self.vars.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +173,10 @@ fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
|
||||
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
|
||||
/// that vary per environment today — slug/name and secret names.
|
||||
///
|
||||
/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`.
|
||||
/// Scripts/routes/triggers belong in the shared base manifest, so a
|
||||
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
|
||||
/// overlay is a mistake — error loudly rather than silently dropping it.
|
||||
/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`,
|
||||
/// and `[vars]`. Scripts/routes/triggers belong in the shared base manifest,
|
||||
/// so a `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in
|
||||
/// an overlay is a mistake — error loudly rather than silently dropping it.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestOverlay {
|
||||
@@ -124,6 +184,8 @@ pub struct ManifestOverlay {
|
||||
pub app: OverlayApp,
|
||||
#[serde(default)]
|
||||
pub secrets: ManifestSecrets,
|
||||
#[serde(default)]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
@@ -143,6 +205,18 @@ pub struct ManifestApp {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
||||
/// and `[vars]`. The group must already exist on the server (created with
|
||||
/// `pic groups create`); the manifest reconciles its content, not the tree
|
||||
/// shape. In a nested project the parent is inferred from the directory tree.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestGroup {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
@@ -348,11 +422,12 @@ mod tests {
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
@@ -409,6 +484,10 @@ mod tests {
|
||||
secrets: ManifestSecrets {
|
||||
names: vec!["STRIPE_KEY".into()],
|
||||
},
|
||||
vars: BTreeMap::from([
|
||||
("region".to_string(), toml::Value::String("eu".into())),
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,11 +533,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
m.apply_overlay(overlay);
|
||||
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
|
||||
assert_eq!(
|
||||
m.app.name, "My Blog",
|
||||
"base name kept when overlay omits it"
|
||||
);
|
||||
let app = m.app.as_ref().unwrap();
|
||||
assert_eq!(app.slug, "blog-staging", "overlay slug wins");
|
||||
assert_eq!(app.name, "My Blog", "base name kept when overlay omits it");
|
||||
assert_eq!(
|
||||
m.secrets.names,
|
||||
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
|
||||
@@ -503,21 +580,67 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_optional_sections_omitted() {
|
||||
let m = Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
assert!(!text.contains("vars"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n",
|
||||
)
|
||||
.expect("group manifest parses");
|
||||
assert!(m.is_group());
|
||||
assert_eq!(m.slug(), "acme");
|
||||
assert_eq!(m.scripts.len(), 1);
|
||||
|
||||
// A group cannot carry routes/triggers.
|
||||
let err = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
|
||||
)
|
||||
.expect_err("group with routes is rejected");
|
||||
assert!(err.to_string().contains("routes"), "got: {err}");
|
||||
|
||||
// Neither / both is rejected.
|
||||
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
||||
Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
|
||||
.expect_err("both [app] and [group]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vars_override_base_per_key() {
|
||||
let mut base = sample();
|
||||
base.vars
|
||||
.insert("region".into(), toml::Value::String("eu".into()));
|
||||
base.vars
|
||||
.insert("tier".into(), toml::Value::String("base".into()));
|
||||
let overlay: ManifestOverlay =
|
||||
toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap();
|
||||
base.apply_overlay(overlay);
|
||||
// overlay wins for `region`, base-only `tier` survives, overlay adds `extra`.
|
||||
assert_eq!(base.vars["region"], toml::Value::String("us".into()));
|
||||
assert_eq!(base.vars["tier"], toml::Value::String("base".into()));
|
||||
assert_eq!(base.vars["extra"], toml::Value::Boolean(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,3 +151,105 @@ fn apply_rejects_bad_bundle_atomically() {
|
||||
"a failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_reconciles_app_vars() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply-vars");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
// The script returns the resolved `region` var verbatim, so an invoke
|
||||
// reflects exactly what `apply` wrote.
|
||||
fs::write(
|
||||
dir.path().join("scripts/read.rhai"),
|
||||
"vars::get(\"region\")",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = |vars_block: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Vars Test\"\n\n\
|
||||
[[scripts]]\nname = \"read\"\nfile = \"scripts/read.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"read\"\nmethod = \"POST\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/read\"\n\n\
|
||||
{vars_block}"
|
||||
)
|
||||
};
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// 1. Apply with `region = "eu"` → the var is created and injected.
|
||||
fs::write(&manifest_path, manifest("[vars]\nregion = \"eu\"\n")).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let id = script_id(&env, &slug);
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("eu"),
|
||||
"var applied"
|
||||
);
|
||||
|
||||
// 2. Change the value → Update (not Create).
|
||||
fs::write(&manifest_path, manifest("[vars]\nregion = \"us\"\n")).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("us"),
|
||||
"var updated"
|
||||
);
|
||||
|
||||
// 3. Drop the var + `--prune` → Delete; `vars::get` now returns `()`.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::Value::Null,
|
||||
"var pruned → unresolved"
|
||||
);
|
||||
}
|
||||
|
||||
fn script_id(env: &common::TestEnv, slug: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("scripts ls should produce one row")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod group_modules;
|
||||
mod group_scripts;
|
||||
mod group_secrets;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
@@ -36,4 +39,6 @@ mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod tree;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
|
||||
@@ -38,6 +38,36 @@ impl Drop for AppGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a script by id on drop (best-effort). Phase 4: a group-owned
|
||||
/// script blocks its group's deletion (`ON DELETE RESTRICT`), so register the
|
||||
/// `ScriptGuard` *after* the owning `GroupGuard` — the script drops (deletes)
|
||||
/// first, leaving the group removable.
|
||||
pub struct ScriptGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl ScriptGuard {
|
||||
pub fn new(url: &str, token: &str, id: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!("{}/api/v1/admin/scripts/{}", self.url, self.id))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UserGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
|
||||
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")
|
||||
218
crates/picloud-cli/tests/group_modules.rs
Normal file
218
crates/picloud-cli/tests/group_modules.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! Phase 4b group-owned modules + the lexical import resolver (§5.5), e2e via `pic`:
|
||||
//! * a group owns a `module` and an endpoint that imports it; an app under the
|
||||
//! group inherits the endpoint and resolves the module down the chain,
|
||||
//! * **trust boundary** — the inheriting app defines a same-named module of
|
||||
//! its own; the inherited group endpoint's import must STILL bind the
|
||||
//! group's module (a leaf cannot shadow it),
|
||||
//! * **CoW / app origin** — an app-owned endpoint importing that name resolves
|
||||
//! the app's module,
|
||||
//! * a manifest whose script imports a non-existent module is a `plan` error.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_module_is_lexically_sealed_under_inheritance() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gm-grp");
|
||||
let child = common::unique_slug("gm-child");
|
||||
|
||||
// GroupGuard first so it drops LAST — after the app + group scripts.
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
|
||||
// Group module `util` and a group endpoint `welcome` that imports it.
|
||||
fs::write(
|
||||
dir.path().join("scripts/util.rhai"),
|
||||
r#"fn greet(n) { "group:" + n }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/util.rhai"))
|
||||
.args(["--group", &group, "--name", "util", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("scripts/welcome.rhai"),
|
||||
r#"import "util" as u; u::greet("x")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/welcome.rhai"))
|
||||
.args(["--group", &group, "--name", "welcome"])
|
||||
.assert()
|
||||
.success();
|
||||
// Group scripts block group deletion (ON DELETE RESTRICT) — guard both.
|
||||
let _gu = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group, "util"));
|
||||
let _gw = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "welcome"),
|
||||
);
|
||||
|
||||
// App under the group; a `caller` invokes the inherited `welcome`.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
r#"invoke("welcome", #{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &child, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let caller_id = app_script_id(&env, &child, "caller");
|
||||
|
||||
// Inheritance: welcome resolves `util` from the group → "group:x".
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("group:x"),
|
||||
"inherited endpoint must resolve the group's module"
|
||||
);
|
||||
|
||||
// TRUST BOUNDARY: the app defines its OWN `util` module. The group
|
||||
// endpoint's import must STILL bind the GROUP's util (sealed from below).
|
||||
fs::write(
|
||||
dir.path().join("scripts/apputil.rhai"),
|
||||
r#"fn greet(n) { "app:" + n }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/apputil.rhai"))
|
||||
.args(["--app", &child, "--name", "util", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("group:x"),
|
||||
"a leaf's same-named module must NOT shadow an inherited group endpoint's import"
|
||||
);
|
||||
|
||||
// CoW / app origin: an app-owned endpoint importing `util` gets the APP's.
|
||||
fs::write(
|
||||
dir.path().join("scripts/appcaller.rhai"),
|
||||
r#"import "util" as u; u::greet("y")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/appcaller.rhai"))
|
||||
.args(["--app", &child, "--name", "appcaller"])
|
||||
.assert()
|
||||
.success();
|
||||
let appcaller_id = app_script_id(&env, &child, "appcaller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &appcaller_id),
|
||||
serde_json::json!("app:y"),
|
||||
"an app-owned script's import must resolve the app's own module"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn dangling_import_is_rejected_by_plan() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("gm-dangle");
|
||||
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A manifest whose endpoint imports a module that exists nowhere on the
|
||||
// chain. `pic plan` must refuse it (the §5.5 dangling-import check) rather
|
||||
// than apply a script that 404s its import at runtime.
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/broken.rhai"),
|
||||
r#"import "ghost" as g; g::run()"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Dangle\"\n\n\
|
||||
[[scripts]]\nname = \"broken\"\nfile = \"scripts/broken.rhai\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("plan");
|
||||
assert!(!out.status.success(), "plan must reject a dangling import");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
stderr.contains("ghost") || stderr.contains("unknown module") || stderr.contains("import"),
|
||||
"plan error should name the missing module:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
||||
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--group", group], name)
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--app", app], name)
|
||||
}
|
||||
|
||||
/// Run a `scripts ls` and pick the id of the row whose name column matches.
|
||||
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
||||
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
// Rows are `id\t<owner_slug>\tname\tversion\tupdated_at`.
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
320
crates/picloud-cli/tests/group_scripts.rs
Normal file
320
crates/picloud-cli/tests/group_scripts.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! Phase 4-lite group-owned scripts, end to end via `pic`:
|
||||
//! * a group owns an endpoint script; an app *under* the group inherits it,
|
||||
//! resolving by name down the chain (`invoke`),
|
||||
//! * an app's own script of the same name shadows the inherited one (CoW),
|
||||
//! * an app NOT under the group cannot resolve it (isolation),
|
||||
//! * a manifest binds a route to the inherited script declaratively, and
|
||||
//! re-applying is an idempotent no-op.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_script_is_inherited_with_cow_and_isolation() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gs-grp");
|
||||
let child = common::unique_slug("gs-child");
|
||||
let orphan = common::unique_slug("gs-orphan");
|
||||
|
||||
// Group with an endpoint script `shared` returning a marker.
|
||||
// GroupGuard first so it drops LAST — after the app + group script below.
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), "\"from-group\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/shared.rhai"))
|
||||
.args(["--group", &group, "--name", "shared"])
|
||||
.assert()
|
||||
.success();
|
||||
// The group script blocks group deletion (ON DELETE RESTRICT) — guard it
|
||||
// so it drops before the GroupGuard.
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
// An app under the group inherits `shared`. Its `caller` script invokes it.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
"invoke(\"shared\", #{})",
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &child, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let caller_id = app_script_id(&env, &child, "caller");
|
||||
|
||||
// Inheritance: caller resolves `shared` down its chain → the group's value.
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("from-group"),
|
||||
"inherited group script should resolve by name"
|
||||
);
|
||||
|
||||
// CoW: the app defines its OWN `shared`; the same caller now sees it.
|
||||
fs::write(dir.path().join("scripts/own.rhai"), "\"from-app\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/own.rhai"))
|
||||
.args(["--app", &child, "--name", "shared"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("from-app"),
|
||||
"an app's own script must shadow the inherited group one (CoW)"
|
||||
);
|
||||
|
||||
// Isolation: an app NOT under the group cannot resolve `shared`.
|
||||
let _orphan = AppGuard::new(&env.url, &env.token, &orphan);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &orphan])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &orphan, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let orphan_caller = app_script_id(&env, &orphan, "caller");
|
||||
// `shared` is not in the orphan's chain → invoke fails (the script errors).
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &orphan_caller])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an app outside the group must NOT reach the group's `shared`"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn manifest_binds_route_to_inherited_group_script_idempotently() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gsr-grp");
|
||||
let child = common::unique_slug("gsr-child");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/greet.rhai"), "\"hi\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/greet.rhai"))
|
||||
.args(["--group", &group, "--name", "greet"])
|
||||
.assert()
|
||||
.success();
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Manifest declares NO `greet` script — only a route binding to it. The
|
||||
// apply engine must resolve `greet` to the inherited group endpoint.
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{child}\"\nname = \"GSR\"\n\n\
|
||||
[[routes]]\nscript = \"greet\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// Plan: a route create that binds to `greet`, and NO script create.
|
||||
let plan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.contains("greet") && plan.contains("route"),
|
||||
"plan should bind a route to the inherited `greet`:\n{plan}"
|
||||
);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Re-plan is a clean no-op — the diff resolved the group-bound route's id
|
||||
// back to `greet`, so it is not a perpetual rebind.
|
||||
let replan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!replan.contains("create") && !replan.contains("update"),
|
||||
"re-plan after binding an inherited route must be a no-op:\n{replan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn inherited_bound_trigger_is_pruned_when_dropped() {
|
||||
// Regression: a trigger bound to an inherited group script must still
|
||||
// resolve its identity in the diff/prune path even after the manifest
|
||||
// stops referencing it — otherwise `--prune` silently orphans it.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gst-grp");
|
||||
let child = common::unique_slug("gst-child");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/work.rhai"), "\"ok\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/work.rhai"))
|
||||
.args(["--group", &group, "--name", "work"])
|
||||
.assert()
|
||||
.success();
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
// 1. Manifest binds a cron trigger to the INHERITED `work`.
|
||||
let with_trigger = format!(
|
||||
"[app]\nslug = \"{child}\"\nname = \"GST\"\n\n\
|
||||
[[triggers.cron]]\nscript = \"work\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &with_trigger).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
let listed = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &child])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
listed.contains("cron"),
|
||||
"cron trigger bound to inherited `work` should exist:\n{listed}"
|
||||
);
|
||||
|
||||
// 2. Drop the trigger + `--prune` → it must be removed (not orphaned).
|
||||
let empty = format!("[app]\nslug = \"{child}\"\nname = \"GST\"\n");
|
||||
fs::write(&manifest_path, &empty).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
let after = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &child])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!after.contains("cron"),
|
||||
"prune must remove the inherited-bound cron trigger:\n{after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// First script id from `pic scripts ls --group <g>`.
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
.output()
|
||||
.expect("scripts ls --group");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("group should have one script")
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", app])
|
||||
.output()
|
||||
.expect("scripts ls --app");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
// Rows are `id\tapp_slug\tname\tversion\tupdated_at`; pick the wanted name.
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found in app `{app}`:\n{table}"))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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;
|
||||
|
||||
/// Pick out the data rows from `pic logs` TSV output — the header line
|
||||
/// (`created_at\tstatus\tsummary`) is now always present, so the old
|
||||
/// (`created_at\tsource\tstatus\tsummary`) is now always present, so the old
|
||||
/// "no non-empty lines means no logs" check needs to skip it.
|
||||
fn data_rows(stdout: &str) -> Vec<&str> {
|
||||
stdout
|
||||
@@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() {
|
||||
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
||||
assert_eq!(
|
||||
cols.len(),
|
||||
3,
|
||||
"row should be 3 tab-delimited cells: {rows:?}"
|
||||
4,
|
||||
"row should be 4 tab-delimited cells (created_at, source, status, summary): {rows:?}"
|
||||
);
|
||||
assert_eq!(cols[1], "success");
|
||||
assert_eq!(cols[2], "success");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
@@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() {
|
||||
.next()
|
||||
.expect("at least one data row");
|
||||
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
|
||||
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
|
||||
assert_eq!(cols[2], "error", "expected error status, got row: {row}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
@@ -166,7 +166,7 @@ fn logs_truncates_long_summary() {
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("at least one data row");
|
||||
let summary = row.split('\t').nth(2).expect("summary column");
|
||||
let summary = row.split('\t').nth(3).expect("summary column");
|
||||
assert!(
|
||||
summary.ends_with('…'),
|
||||
"summary should be truncated with `…`, got: {summary}"
|
||||
|
||||
@@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() {
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created hello v1"));
|
||||
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
|
||||
@@ -7,6 +7,16 @@ use predicates::prelude::*;
|
||||
use crate::common;
|
||||
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"]
|
||||
#[test]
|
||||
fn deploy_against_unknown_app_errors() {
|
||||
@@ -48,7 +58,7 @@ fn deploy_with_name_override() {
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
common::pic_as(&env)
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -58,11 +68,14 @@ fn deploy_with_name_override() {
|
||||
"--name",
|
||||
"custom-name",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created custom-name v1"));
|
||||
.output()
|
||||
.expect("deploy v1");
|
||||
let v1 = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(kv_field(&v1, "name"), Some("custom-name"), "v1 name: {v1}");
|
||||
assert_eq!(kv_field(&v1, "version"), Some("1"), "v1 version: {v1}");
|
||||
assert_eq!(kv_field(&v1, "action"), Some("created"), "v1 action: {v1}");
|
||||
|
||||
common::pic_as(&env)
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -72,9 +85,11 @@ fn deploy_with_name_override() {
|
||||
"--name",
|
||||
"custom-name",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Updated custom-name v2"));
|
||||
.output()
|
||||
.expect("deploy v2");
|
||||
let v2 = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(kv_field(&v2, "version"), Some("2"), "v2 version: {v2}");
|
||||
assert_eq!(kv_field(&v2, "action"), Some("updated"), "v2 action: {v2}");
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
@@ -106,8 +121,8 @@ fn deploy_bumps_version_each_redeploy() {
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
|
||||
common::pic_as(&env)
|
||||
for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] {
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() {
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(expected));
|
||||
.output()
|
||||
.expect("deploy");
|
||||
assert!(out.status.success(), "deploy v{version} failed: {out:?}");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(
|
||||
kv_field(&stdout, "version"),
|
||||
Some(version),
|
||||
"version: {stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
kv_field(&stdout, "action"),
|
||||
Some(action),
|
||||
"action: {stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() {
|
||||
.expect("secrets ls");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let header = stdout.lines().next().expect("header");
|
||||
assert_eq!(common::cells(header), vec!["name", "updated_at"]);
|
||||
assert_eq!(common::cells(header), vec!["name", "env", "updated_at"]);
|
||||
assert!(
|
||||
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
|
||||
"api_key missing from ls: {stdout}"
|
||||
|
||||
254
crates/picloud-cli/tests/tree.rs
Normal file
254
crates/picloud-cli/tests/tree.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
//! Phase 5 project-tree apply, end to end via `pic plan/apply --dir`:
|
||||
//! * a group + a nested app under it apply atomically as one tree; the app
|
||||
//! binds a route to the group's inherited script (created in the SAME
|
||||
//! apply); re-plan is a no-op,
|
||||
//! * one invalid node aborts the whole tree (nothing written),
|
||||
//! * a `pic groups reparent` between plan and apply trips the bound-plan
|
||||
//! (StateMoved) check via the tree's structure version.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
/// A project tree on disk: root group manifest + a nested app manifest that
|
||||
/// binds `GET /greet` to the group's (inherited) `shared` script.
|
||||
fn tree_dir(group: &str, app: &str, group_script_src: &str, app_extra: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::create_dir_all(dir.path().join(app).join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), group_script_src).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Tree Group\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(app).join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Tree App\"\n\n\
|
||||
[[routes]]\nscript = \"shared\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n{app_extra}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_applies_group_and_app_atomically_then_noop() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tr-grp");
|
||||
let app = common::unique_slug("tr-app");
|
||||
|
||||
// Group + app must pre-exist (the manifest owns content, not tree shape).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = tree_dir(&group, &app, "\"hi from the group\"", "");
|
||||
|
||||
// Plan the whole tree: a group script+var create and an app route create.
|
||||
let plan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.contains("shared") && plan.contains("region") && plan.contains("/greet"),
|
||||
"tree plan should cover both nodes:\n{plan}"
|
||||
);
|
||||
|
||||
// Apply the whole tree in one transaction.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
// The group node created its `shared` script (it must drop before its
|
||||
// group at teardown — ON DELETE RESTRICT).
|
||||
let listed = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
listed.contains("shared"),
|
||||
"the group node's script should exist after the tree apply:\n{listed}"
|
||||
);
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
// Re-plan is a clean no-op (idempotent across both nodes — incl. the app's
|
||||
// route bound to the inherited group script).
|
||||
let replan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!replan.contains("create") && !replan.contains("update"),
|
||||
"re-plan of the tree must be a no-op:\n{replan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_apply_is_atomic_on_an_invalid_node() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tra-grp");
|
||||
let app = common::unique_slug("tra-app");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The app node carries an INVALID Rhai script → the whole tree must abort.
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::create_dir_all(dir.path().join(&app).join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), "\"ok\"").unwrap();
|
||||
fs::write(dir.path().join(&app).join("scripts/bad.rhai"), "let x = ;").unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&app).join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"A\"\n\n\
|
||||
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.failure();
|
||||
|
||||
// Atomic: the valid group node's `shared` must NOT have been created.
|
||||
let listed = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!listed.contains("shared"),
|
||||
"a failed tree apply must leave nothing behind:\n{listed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_reparent_between_plan_and_apply_trips_state_moved() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("trp-par");
|
||||
let group = common::unique_slug("trp-grp");
|
||||
let app = common::unique_slug("trp-app");
|
||||
|
||||
// parent → group → app.
|
||||
let _p = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group, "--parent", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = tree_dir(&group, &app, "\"x\"", "");
|
||||
|
||||
// Record a bound plan for the whole tree.
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Reparent the group to the instance root — bumps its structure version.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "reparent", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply the recorded plan: the structure moved underneath it → refuse.
|
||||
// (The apply aborts before any write, so no group script is created and the
|
||||
// GroupGuards tear down cleanly.)
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(
|
||||
predicate::str::contains("changed")
|
||||
.or(predicate::str::contains("plan"))
|
||||
.or(predicate::str::contains("409")),
|
||||
);
|
||||
}
|
||||
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
.output()
|
||||
.expect("scripts ls --group");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("group should have one script")
|
||||
}
|
||||
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,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
|
||||
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
||||
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState,
|
||||
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository,
|
||||
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState,
|
||||
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl,
|
||||
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl,
|
||||
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
|
||||
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
||||
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository,
|
||||
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
|
||||
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
|
||||
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
|
||||
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
|
||||
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
|
||||
TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
|
||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
|
||||
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
|
||||
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
|
||||
HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
|
||||
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
|
||||
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
|
||||
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
|
||||
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
|
||||
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
|
||||
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
|
||||
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
|
||||
UsersServiceImpl, VarsApiState, VarsServiceImpl,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -322,12 +323,14 @@ pub async fn build_app(
|
||||
// under script-as-gate semantics.
|
||||
.with_authz(authz.clone()),
|
||||
);
|
||||
let vars: Arc<dyn picloud_shared::VarsService> =
|
||||
Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone()));
|
||||
let services = Services::new(
|
||||
kv,
|
||||
docs,
|
||||
dl_service.clone(),
|
||||
events,
|
||||
modules,
|
||||
modules.clone(),
|
||||
http,
|
||||
files,
|
||||
pubsub,
|
||||
@@ -336,6 +339,7 @@ pub async fn build_app(
|
||||
users.clone(),
|
||||
queue,
|
||||
invoke,
|
||||
vars,
|
||||
);
|
||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||
// trigger-depth bound (same counter under the hood).
|
||||
@@ -467,7 +471,10 @@ pub async fn build_app(
|
||||
routes: route_repo.clone(),
|
||||
triggers: trigger_repo.clone(),
|
||||
secrets: secrets_repo.clone(),
|
||||
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
|
||||
apps: apps_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
modules: modules.clone(),
|
||||
domains: domains_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
@@ -509,6 +516,7 @@ pub async fn build_app(
|
||||
let secrets_state = SecretsState {
|
||||
repo: secrets_repo,
|
||||
apps: apps_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
master_key,
|
||||
max_value_bytes: secrets_max_value_bytes,
|
||||
@@ -566,6 +574,24 @@ pub async fn build_app(
|
||||
users: auth.users.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let vars_state = VarsApiState {
|
||||
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
|
||||
apps: apps_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let group_scripts_state = picloud_manager_core::GroupScriptsState {
|
||||
scripts: script_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let config_state = picloud_manager_core::ConfigApiState {
|
||||
pool: pool.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||
apps: apps_state.apps.clone(),
|
||||
authz: authz.clone(),
|
||||
@@ -589,6 +615,11 @@ pub async fn build_app(
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(groups_router(groups_state))
|
||||
.merge(picloud_manager_core::group_scripts_router(
|
||||
group_scripts_state,
|
||||
))
|
||||
.merge(vars_router(vars_state))
|
||||
.merge(picloud_manager_core::config_router(config_state))
|
||||
.merge(picloud_manager_core::app_users_router(
|
||||
app_users_admin_state,
|
||||
))
|
||||
|
||||
@@ -18,7 +18,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AppId, ExecutionId, ScriptId, SdkCallCx};
|
||||
use crate::{AppId, ExecutionId, ScriptId, ScriptOwner, SdkCallCx};
|
||||
|
||||
/// Identifies the script to invoke. Accepted by `invoke()` and
|
||||
/// `invoke_async()` script-side.
|
||||
@@ -52,6 +52,12 @@ impl InvokeTarget {
|
||||
pub struct ResolvedScript {
|
||||
pub script_id: ScriptId,
|
||||
pub app_id: AppId,
|
||||
/// The resolved script's **defining node** (Phase 4b) — the lexical
|
||||
/// origin its `import`s resolve against (§5.5). For an inherited group
|
||||
/// script this is the **group**, even though `app_id` (the execution
|
||||
/// boundary) is the caller's app. `None` only for a corrupt owner row;
|
||||
/// the executor falls back to `App(app_id)`.
|
||||
pub owner: Option<ScriptOwner>,
|
||||
pub source: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Script.name — surfaced to the callee as `ctx.script_name`.
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod subscriber_token;
|
||||
pub mod trigger_event;
|
||||
pub mod users;
|
||||
pub mod validator;
|
||||
pub mod vars;
|
||||
pub mod version;
|
||||
|
||||
/// serde `default` for `enabled`-style boolean fields that should default to
|
||||
@@ -85,7 +86,7 @@ pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaste
|
||||
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
|
||||
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
pub use script::{Script, ScriptKind};
|
||||
pub use script::{Script, ScriptKind, ScriptOwner};
|
||||
pub use sdk_cx::SdkCallCx;
|
||||
pub use secrets::{
|
||||
validate_secret_name, NoopSecretsService, SecretsError, SecretsListPage, SecretsService,
|
||||
@@ -101,4 +102,5 @@ pub use users::{
|
||||
UsersService,
|
||||
};
|
||||
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
||||
pub use vars::{NoopVarsService, VarsError, VarsService};
|
||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||
|
||||
@@ -1,51 +1,88 @@
|
||||
//! `ModuleSource` — the v1.1.3 Rhai module-loading contract.
|
||||
//! `ModuleSource` — the Rhai module-loading contract (v1.1.3; made
|
||||
//! origin-aware in v1.2 Phase 4b).
|
||||
//!
|
||||
//! The executor-core `PicloudModuleResolver` calls into this trait to
|
||||
//! load `kind = 'module'` scripts referenced by `import "<name>" as <alias>;`
|
||||
//! statements. The Postgres impl in `manager-core` reads from the
|
||||
//! `scripts` table; tests pin in-memory fakes.
|
||||
//!
|
||||
//! Implementations MUST derive `app_id` from `cx.app_id` and pass it
|
||||
//! to every backend query. The `name` argument carries only the
|
||||
//! script's name (the literal between the import quotes); the trait
|
||||
//! has no way to express a cross-app lookup. That asymmetry is the
|
||||
//! load-bearing cross-app isolation boundary — see `docs/sdk-shape.md`.
|
||||
//! **Resolution is lexical (§5.5).** A module is resolved against the
|
||||
//! module set visible at the **importing script's own defining node**
|
||||
//! (`origin`), walking up the group chain from there — *not* the
|
||||
//! inheriting app's effective view. So `resolve` is keyed by a
|
||||
//! [`ScriptOwner`] origin, not by `cx.app_id`. This deliberately differs
|
||||
//! from the SDK-call isolation boundary: a group script's `import`
|
||||
//! resolves from the **group** (sealed from below — a leaf app cannot
|
||||
//! shadow it), while every SDK call *inside* that module still scopes to
|
||||
//! the inheriting app via `cx.app_id`. The `origin` argument is a trusted
|
||||
//! dispatch-derived value (the resolved script's owner), never a
|
||||
//! script-passed argument, so the cross-app isolation boundary holds.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AppId, ScriptId, SdkCallCx};
|
||||
use crate::{AppId, GroupId, ScriptId, ScriptOwner};
|
||||
|
||||
/// A module script as returned by `ModuleSource::lookup`. Carries only
|
||||
/// the fields the resolver needs: the id (for diagnostics), the source
|
||||
/// (to compile), and `updated_at` (the cache-staleness comparator).
|
||||
/// A module script as returned by `ModuleSource::resolve`. Carries the
|
||||
/// fields the resolver needs: the id (cache key + diagnostics), the
|
||||
/// resolved module's **owner** (so nested imports inside it resolve from
|
||||
/// *its* defining node), the source (to compile), and `updated_at` (the
|
||||
/// cache-staleness comparator).
|
||||
///
|
||||
/// Ownership is polymorphic (Phase 4): exactly one of `app_id` / `group_id`
|
||||
/// is set — mirroring [`crate::Script`] and the DB CHECK.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleScript {
|
||||
pub script_id: ScriptId,
|
||||
pub app_id: AppId,
|
||||
/// Owning app, when app-owned. Mutually exclusive with `group_id`.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// Owning group, when group-owned (Phase 4b group modules).
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `lookup` MUST
|
||||
/// scope by `cx.app_id`; cross-app reads must be unreachable.
|
||||
impl ModuleScript {
|
||||
/// The resolved module's defining node, reconstructed from the
|
||||
/// polymorphic columns. Used by the resolver to seal *this* module's
|
||||
/// own nested imports to its node (lexical chaining). Prefers the app
|
||||
/// if a corrupt row sets both, so the hot path never panics.
|
||||
#[must_use]
|
||||
pub fn owner(&self) -> Option<ScriptOwner> {
|
||||
match (self.app_id, self.group_id) {
|
||||
(Some(a), _) => Some(ScriptOwner::App(a)),
|
||||
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
|
||||
/// module set up the chain rooted at `origin` (the importing script's
|
||||
/// defining node), nearest-owner-wins. The `origin` is trusted
|
||||
/// (dispatch-derived); the `name` is the literal between the import
|
||||
/// quotes. See the module docs for why this is lexical, not `cx`-scoped.
|
||||
#[async_trait]
|
||||
pub trait ModuleSource: Send + Sync {
|
||||
/// Resolve a module script by `(cx.app_id, name)`. Returns `None`
|
||||
/// when no row exists, or when a row exists but its `kind` is
|
||||
/// `'endpoint'` (endpoints are never importable). The resolver
|
||||
/// surfaces `None` as `ErrorModuleNotFound` to Rhai.
|
||||
async fn lookup(
|
||||
/// Resolve a `kind = 'module'` script named `name`, visible from the
|
||||
/// `origin` node walking up its group chain (nearest depth wins).
|
||||
/// Returns `None` when no module of that name exists anywhere on the
|
||||
/// chain (or a row exists but its `kind` is `'endpoint'` — endpoints
|
||||
/// are never importable). The resolver surfaces `None` as
|
||||
/// `ErrorModuleNotFound` to Rhai.
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
}
|
||||
|
||||
/// Failure modes surfaced from `ModuleSource::lookup`. "Not found" is
|
||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||
/// not exceptional — it's `Ok(None)`.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ModuleSourceError {
|
||||
@@ -57,7 +94,7 @@ pub enum ModuleSourceError {
|
||||
}
|
||||
|
||||
/// Stub used by the executor-core test harness so engine integration
|
||||
/// tests don't need a real DB-backed source. Every lookup returns
|
||||
/// tests don't need a real DB-backed source. Every resolve returns
|
||||
/// `Ok(None)` — `import "x"` always errors as "module not found"
|
||||
/// under this impl.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
@@ -65,9 +102,9 @@ pub struct NoopModuleSource;
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for NoopModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
|
||||
|
||||
/// Semantic role of a script (v1.1.3).
|
||||
///
|
||||
@@ -94,10 +94,26 @@ mod kind_tests {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: ScriptId,
|
||||
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||
/// semantics — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
/// Owning app, when app-owned. Set on create, immutable thereafter — a
|
||||
/// "move to another app" is a copy+delete, not an in-place edit
|
||||
/// (snapshot semantics — see blueprint §11.5).
|
||||
///
|
||||
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
|
||||
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
|
||||
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
|
||||
/// inherited by every descendant app — it has no single app, so the
|
||||
/// **execution context** app is supplied by the route/trigger/caller that
|
||||
/// invoked it, never read off the script. Reading `app_id` to mean "the
|
||||
/// app this runs under" is therefore a bug for group scripts; use the
|
||||
/// invoking surface's app_id instead.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
|
||||
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
|
||||
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
|
||||
/// the same name shadows the inherited one).
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
@@ -131,3 +147,36 @@ pub struct Script {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
|
||||
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
|
||||
/// in-memory witness of that invariant so call sites can `match` exhaustively
|
||||
/// instead of juggling two `Option`s.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ScriptOwner {
|
||||
App(AppId),
|
||||
Group(GroupId),
|
||||
}
|
||||
|
||||
impl Script {
|
||||
/// The script's owner, reconstructed from the polymorphic columns.
|
||||
/// A row that violates the exactly-one invariant (both/neither set —
|
||||
/// impossible under the CHECK) is reported as whichever is present,
|
||||
/// preferring the app, so a corrupt row never panics on the hot path.
|
||||
#[must_use]
|
||||
pub fn owner(&self) -> Option<ScriptOwner> {
|
||||
match (self.app_id, self.group_id) {
|
||||
(Some(a), _) => Some(ScriptOwner::App(a)),
|
||||
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this script is owned by `app` directly (not via a group).
|
||||
/// The cheap, app-owned-only ownership check — group inheritance is
|
||||
/// resolved separately (chain-membership), never by this method.
|
||||
#[must_use]
|
||||
pub fn is_owned_by_app(&self, app: AppId) -> bool {
|
||||
self.app_id == Some(app)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ use crate::{
|
||||
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
||||
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
||||
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
|
||||
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService,
|
||||
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
||||
UsersService, VarsService,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -107,6 +108,12 @@ pub struct Services {
|
||||
/// and `invoke_async()` (fire-and-forget through the outbox).
|
||||
/// Cross-app invokes are rejected at the service entry point.
|
||||
pub invoke: Arc<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 {
|
||||
@@ -129,6 +136,7 @@ impl Services {
|
||||
users: Arc<dyn UsersService>,
|
||||
queue: Arc<dyn QueueService>,
|
||||
invoke: Arc<dyn InvokeService>,
|
||||
vars: Arc<dyn VarsService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kv,
|
||||
@@ -144,6 +152,7 @@ impl Services {
|
||||
users,
|
||||
queue,
|
||||
invoke,
|
||||
vars,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +177,7 @@ impl Services {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
Arc::new(NoopInvokeService),
|
||||
Arc::new(NoopVarsService),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
> **Status:** Draft — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions
|
||||
> **Status:** Active — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions
|
||||
> grounded in precedent (GitLab, Kustomize, Helm, Terraform, Kubernetes Server-Side Apply, Pulumi)
|
||||
> and corrected against the codebase after three independent review passes (consistency, gaps,
|
||||
> feasibility). Not yet scheduled into a phase.
|
||||
> feasibility). **Scheduled as the *Hierarchies* track of v1.2** (blueprint §12 Phase 5). §11 Phases
|
||||
> 1–3 (project tool, groups + hierarchy RBAC, group-inherited config) are **shipped**; Phases 4–6
|
||||
> remain. NB: §11's own Phase 1–6 numbering is local to this initiative and is **distinct from the
|
||||
> blueprint product-phase numbering** — "Phase 3" here = group-inherited config, not the blueprint's
|
||||
> admin-auth phase.
|
||||
>
|
||||
> **Scope:** turns the imperative `pic` CLI into a declarative, file-based project tool, and
|
||||
> introduces a server-side **groups** hierarchy so apps can share and inherit config/scripts
|
||||
> without duplication.
|
||||
>
|
||||
> **Blueprint impact:** this reverses the §11.5 *snapshot-copy, not live-link* stance — top-down
|
||||
> hierarchical inheritance (group → app) is now in scope, implemented as a *materialized, auto-
|
||||
> invalidated* view (§5.1). Fold the outcome back into the blueprint before it drifts.
|
||||
> hierarchical inheritance (group → app) is now in scope. *Originally* designed as a *materialized,
|
||||
> auto-invalidated* view (§5.1); **Phase 3 shipped it as live, resolve-on-read inheritance** (one
|
||||
> recursive CTE per call, no cache), with materialization deferred to a future optimization (see the
|
||||
> Phase 3 status note in §11.3). Fold the outcome back into the blueprint before it drifts.
|
||||
|
||||
---
|
||||
|
||||
@@ -425,6 +431,19 @@ Two distinct constraints:
|
||||
- **Runtime model: a materialized effective view + versioned cache (not per-request live-resolve).**
|
||||
An earlier draft said "live-resolve," which is underspecified and would fight the existing cache.
|
||||
The real model:
|
||||
|
||||
> **⚠️ Superseded for config by what Phase 3 actually shipped (§11.3): live resolution.** For
|
||||
> **vars + secrets**, PiCloud resolves on-demand via one recursive CTE per call — *no* materialized
|
||||
> view, generation counter, or invalidation fan-out. There was no pre-existing config cache to
|
||||
> "fight" (config is net-new, §3), and the org tree is small, so resolve-on-read is cheap and
|
||||
> sidesteps the invalidation SLA below. The materialized model in this bullet stands only as the
|
||||
> **deferred plan for *script-body* inheritance (Phase 4)** — where a hot per-request path may
|
||||
> justify a cache — and as a future optimization for config if a deep tree ever demands it. Read the
|
||||
> rest of §5.1 as "the materialization design, if/when we need it," not as Phase 3's runtime.
|
||||
> **Update — Phase 4-lite (§11.4 status) also shipped live, not materialized:** group scripts are
|
||||
> referenced by id on the runtime hot path and resolved by name only at bind/invoke time, so even
|
||||
> script inheritance needed no materialized view or invalidation fan-out. The materialization design
|
||||
> now stands purely as a future optimization, gated on a *measured* cost.
|
||||
- manager-core **resolves-at-write into a materialized per-app effective view** (§3 rule applied:
|
||||
sparse merge, env filter, proximity, CoW, `enabled`).
|
||||
- The orchestrator/executor serve from that view, **keyed by `app_id` + a generation/version**. The
|
||||
@@ -526,11 +545,16 @@ trust inversion). The rule:
|
||||
extension point with no provider in a given app is an error for that app (a hard failure, joining
|
||||
§4.7).
|
||||
|
||||
> **Residual (verified):** executor-core's `PicloudModuleResolver` is app-scoped today and ignores the
|
||||
> importing script's origin (`module_resolver.rs` passes `_source` unused). Rhai *does* expose that
|
||||
> origin, so the lexical-vs-dynamic split is expressible — but it requires re-keying the resolver cache
|
||||
> by owner identity and adding per-import policy (sealed vs. extension point), i.e. a real
|
||||
> resolver+cache redesign, not a parameter tweak. Lands with phasing step 4.
|
||||
> **Resolved (Phase 4b ✅).** The **lexical (sealed-by-default)** core shipped: `ModuleScript` carries
|
||||
> a polymorphic owner; `ModuleSource::resolve(origin, name)` walks the chain rooted at the importing
|
||||
> node (app-rooted `CHAIN_LEVELS_CTE` or the new group-rooted CTE); the resolved script's owner threads
|
||||
> through `ExecRequest.script_owner` (the executor's `default_origin`) and every dispatch + `invoke()`
|
||||
> site; the `PicloudModuleResolver` reads the importing node from Rhai's `_source` (set to each module's
|
||||
> owner via `AST::set_source(encode(owner))` before `eval_ast_as_new` — the lexical chaining), and the
|
||||
> cache is re-keyed by resolved `ScriptId`. Group modules + group-script imports are now allowed
|
||||
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check. **Opt-in
|
||||
> extension points** (the dynamic-resolution branch + `[extension_points]` manifest declaration) remain
|
||||
> the one deferred piece of §5.5 — a clean additive follow-up on top of the now-origin-aware resolver.
|
||||
|
||||
### 5.6 Tree lifecycle: delete, reparent, rename
|
||||
|
||||
@@ -800,7 +824,10 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
|
||||
- **Effective-view invalidation SLA (§5.1)** — the security-staleness guarantee at fan-out to many
|
||||
descendants is unsolved; synchronous-for-security + eventual-elsewhere is the proposed shape, not a
|
||||
proven one. Highest-risk open item.
|
||||
proven one. *(Was the highest-risk open item.)* **Moot for config as of Phase 3:** vars/secrets ship
|
||||
with **live resolution** (no cache → no staleness window; a disable/rotation is visible on the next
|
||||
call). The SLA only re-arises if **script-body** materialization is built (Phase 4) or if config is
|
||||
ever materialized for performance; until then there is nothing to invalidate.
|
||||
- **Conflict bit vs. operational lock (§4.2)** — *decided:* phase 1 ships the `enabled` / secret-
|
||||
reference **conflict bit**; the operational-lock flag is the documented fallback if the bit proves
|
||||
too heavy. (Was listed as undecided; resolved here to match §11 phase 1.)
|
||||
@@ -810,8 +837,11 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
precondition to adopting the rule.
|
||||
- **Inherited-membership revocation lag (§5.3)** — revoking a group admin must drop implicit admin on
|
||||
every descendant app, but §5.1's synchronous-invalidation subset covers only `enabled`/secrets, not
|
||||
**role revocation** — leaving an unbounded window. New residual risk; should get the same
|
||||
synchronous-for-security bound, lands with phase 3's authz.
|
||||
**role revocation** — leaving an unbounded window. ~~New residual risk; should get the same
|
||||
synchronous-for-security bound, lands with phase 3's authz.~~ **Resolved — never materialized:**
|
||||
Phase 2 RBAC and Phase 3 config both resolve roles/config **live** (no inherited-role or
|
||||
effective-view cache), so a revoked group grant is gone on the next request. The lag only exists if a
|
||||
cache is introduced.
|
||||
- **External execution cancel (§4.2 kill-switch)** — the executor has **no external-cancel path
|
||||
today** (`spawn_blocking` + self-checked deadline, verified); the kill-switch is a net-new capability
|
||||
(a cancel flag polled in `on_progress`), deferred past phase 1. Until it exists, the strongest stop
|
||||
@@ -879,21 +909,164 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
|
||||
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
|
||||
requirement, since multi-level resolution first ships here).
|
||||
|
||||
> **Status (Phase 3): ✅ shipped — with one deliberate architecture change: LIVE resolution, not
|
||||
> materialization.** §5.1 / this bullet / §12 prescribed a *materialized* per-app effective view
|
||||
> with a generation counter and fan-out invalidation. We shipped **live, on-demand resolution
|
||||
> instead**: one depth-bounded recursive CTE (`config_resolver::CHAIN_LEVELS_CTE`, walking
|
||||
> `apps.group_id → groups.parent_id → root`) runs per `vars::get`/`secrets::get`/`config/effective`,
|
||||
> and the §3 merge/proximity/tombstone semantics run in a pure, unit-tested `resolve()` pass on the
|
||||
> fetched rows. No materialized store, no generation, no invalidation protocol. Rationale: the group
|
||||
> tree is small org structure, resolve-on-read is cheap, and it sidesteps §10's unsolved
|
||||
> fan-out-invalidation SLA entirely. **Consequence:** §10's "effective-view invalidation SLA" and
|
||||
> "inherited-membership revocation lag" are **moot for config** — with no cache, a security disable
|
||||
> or secret rotation propagates immediately (same posture as Phase 2's live RBAC). Materialization
|
||||
> (and a per-execution memo keyed by `cx.execution_id`) is re-scoped to a **future optimization**,
|
||||
> to revisit only if a deep tree + hot path ever makes live resolution measurably costly.
|
||||
>
|
||||
> Shipped surface:
|
||||
> - **Schema:** `0048_vars.sql` (`vars` polymorphic owner [`group_id` XOR `app_id`],
|
||||
> `environment_scope`, `is_tombstone`, partial-unique indexes) + **`apps.environment TEXT NULL`**
|
||||
> (the explicit realization of "an environment is an app", §2 — set at app-create, read by the
|
||||
> resolver, never entering `SdkCallCx`). `0049_group_secrets.sql` reshapes the live `secrets`
|
||||
> table to the **same** polymorphic-owner + `environment_scope` contract (PK → two partial-unique
|
||||
> indexes); existing app rows backfill to `app_id` + scope `'*'` and decrypt byte-for-byte (the v1
|
||||
> AAD excludes the scope).
|
||||
> - **§3 resolver:** env-filter in SQL (`scope IN ('*', app_env)`), then proximity-first + per-key
|
||||
> deep-merge / replace / tombstone in Rust. A same-level `@E` value **suppresses** the same-level
|
||||
> `*` (env is eligibility, not a merge tier — §3.1), incl. the map-vs-map case.
|
||||
> - **Vars:** `VarsService` + `vars::get`/`vars::all` SDK + `App/GroupVars{Read,Write}` caps +
|
||||
> `{apps,groups}/{id}/vars` admin CRUD + `pic vars`.
|
||||
> - **Group secrets:** owner-discriminated AAD (`secret:group:{group_id}:{name}`; app AAD
|
||||
> unchanged); inherited `secrets::get` (resolves app-own + ancestor-group, decrypts under the
|
||||
> resolved owner's AAD, anchored to `cx.app_id`); the **two orthogonal gates** (§5.3): runtime
|
||||
> injection (no human-read check) vs. the group-gated value endpoint
|
||||
> `GET /groups/{id}/secrets/{name}/value` (`GroupSecretsRead` = group_admin). `GroupSecretsWrite`
|
||||
> sits at `script:write` scope (matches its editor role tier).
|
||||
> - **Effective view (masked):** `GET /apps/{id}/config/effective` → resolved vars (with
|
||||
> provenance) + secrets masked to `{status, owner, scope}` (value never returned). `pic config
|
||||
> --effective [--explain]` renders it.
|
||||
>
|
||||
> Deferred (unchanged): group-*owned scripts/modules* (Phase 4), the manifest `[vars]`/`[secrets]`
|
||||
> apply reconciliation (Phase 5 — Phase 3 ships CRUD API + CLI), and materialization (above).
|
||||
4. **Group-inherited scripts/modules.** CoW overrides; the **scope-aware module/import resolver +
|
||||
extension points** (§5.5); cache-invalidation fan-out hardening; versioning/pinning if needed.
|
||||
|
||||
> **Status (Phase 4-lite): ✅ shipped — group-owned ENDPOINT scripts, live-resolved (no
|
||||
> materialization).** Like Phase 3's config, group scripts resolve **live**, not via a cache: a
|
||||
> script is referenced by id on the runtime hot path (routes/triggers store the resolved id; the
|
||||
> orchestrator's per-app route trie already scopes dispatch to one app and never reads
|
||||
> `script.app_id`), and by **name** down the chain only at bind/invoke time. No materialized
|
||||
> per-app body view, no generation counter, no fan-out invalidation — so §11.4's
|
||||
> "cache-invalidation fan-out hardening" is **moot for Phase 4-lite** (revisit only if a measured
|
||||
> hot-path cost appears).
|
||||
>
|
||||
> Shipped surface:
|
||||
> - **Schema:** `0050_group_scripts.sql` makes `scripts` polymorphic-owned (`group_id` XOR
|
||||
> `app_id`, `scripts_owner_exactly_one` CHECK, per-owner partial-unique `LOWER(name)` indexes),
|
||||
> `ON DELETE RESTRICT` (code is not data — a group can't be deleted out from under its scripts).
|
||||
> `Script.app_id` became `Option<AppId>` + `group_id`; the cross-app isolation backstops
|
||||
> (dispatcher, invoke, trigger-bind, orchestrator `/execute/{id}`) generalized from
|
||||
> `app_id == row.app_id` to **chain-membership**, fail-closed for app-owned (zero-cost in-memory
|
||||
> fast path; only a group candidate pays a chain query).
|
||||
> - **Resolver (reuses `CHAIN_LEVELS_CTE`):** `get_by_name_inherited(app_id, name)` (nearest-owner
|
||||
> wins; app's own script shadows the inherited group one — CoW) and `is_invocable_by_app(script_id,
|
||||
> app_id)` (the isolation predicate lifted to the hierarchy; sibling apps' *own* scripts are NOT
|
||||
> cross-invocable — only group scripts inherit).
|
||||
> - **Admin + CLI:** `GroupScripts{Read,Write}` caps (viewer+/editor+ on the group, resolved via the
|
||||
> group-ancestor walk); `POST/GET /groups/{id}/scripts` (endpoint-only, self-contained — modules +
|
||||
> imports rejected); the by-id `/scripts/{id}` handlers are owner-polymorphic; `pic scripts ls
|
||||
> --group` / `deploy --group` (create-or-update; `--app`/`--group` mutually exclusive).
|
||||
> - **`invoke("name", …)`** resolves inherited (a script runs a shared group endpoint; it always
|
||||
> executes under the *caller's* app context, never the owner's).
|
||||
> - **Declarative apply:** a manifest route/trigger `script = "name"` resolves to an inherited group
|
||||
> endpoint (`resolve_inherited_targets` → `name_to_id`), with the diff resolving the group-bound
|
||||
> route's id → name so **re-apply is idempotent**.
|
||||
>
|
||||
> **Phase 4b: ✅ shipped — group modules + the lexical (sealed-by-default) import resolver (§5.5).**
|
||||
> Group `kind = module` scripts and group-script imports are now allowed; `import` resolves lexically
|
||||
> against the importing script's **own defining node** (the group for an inherited script — a leaf
|
||||
> can't shadow it; verified e2e). Mechanism: owner-polymorphic `ModuleScript`, origin-rooted
|
||||
> `ModuleSource::resolve`, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site,
|
||||
> `_source`-driven lexical chaining in the resolver (cache re-keyed by `ScriptId`), and the
|
||||
> single-node dangling-import `plan` check. Still deferred: **opt-in extension points** (the only
|
||||
> remaining §5.5 piece) and **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
|
||||
> is **redefine-in-app + re-apply** (no live auto-rebinding). Sharp edge to track: `routes.script_id`
|
||||
> is `ON DELETE CASCADE`, so deleting a
|
||||
> group script removes descendant apps' bound routes (within group-editor authority; the *group*
|
||||
> delete itself stays `RESTRICT`).
|
||||
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
|
||||
tree plan, per-env approval gating.
|
||||
|
||||
> **Status (Phase 5): ✅ shipped — single-repo nested tree apply, atomic.** A directory tree of
|
||||
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
|
||||
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
|
||||
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
|
||||
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
|
||||
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
|
||||
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
|
||||
> add).
|
||||
>
|
||||
> Shipped surface:
|
||||
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
|
||||
> (a group node has only scripts + vars; routes/triggers/secret-values stay app-only). The in-tx
|
||||
> reconcile is `reconcile_node_tx`, shared by single-node and tree apply. `apply_tree` locks every
|
||||
> node key (sorted), reconciles **groups first** (recording each group's `name → id`), then apps —
|
||||
> so an app route/trigger can bind a group script **created in the same transaction** (resolved
|
||||
> nearest-ancestor-wins across the in-tree index + pre-existing ancestors; the pool resolver can't
|
||||
> see uncommitted rows). One commit, one post-commit route refresh.
|
||||
> - **Bound plan covers structure:** the tree token folds every node's `state_token` **plus every
|
||||
> in-scope group's `structure_version`**, so a reparent (or content edit) between plan and apply
|
||||
> trips `StateMoved` — the §4.2 "content **and** tree-structure version" check.
|
||||
> - **API:** `POST /api/v1/admin/{groups/{id},tree}/{plan,apply}`; per-node capability gating (the
|
||||
> actor must hold the read/write caps for **every** node touched).
|
||||
> - **CLI:** a `[group]` manifest kind; `pic plan/apply` on one node (app or group); `pic
|
||||
> plan/apply --dir <root>` discovers + applies the whole tree, with a single `<tree>` bound token.
|
||||
>
|
||||
> Live- and journey-validated: a group + nested app apply atomically (app route binds the group's
|
||||
> same-apply script); re-plan is all-noop; one invalid node aborts the whole tree (nothing written);
|
||||
> a `pic groups reparent` between plan and apply is refused. The §5.1 materialized effective-view +
|
||||
> fan-out invalidation was **not** built — group config/scripts resolve **live** (Phase 3/4),
|
||||
> so the tree apply needs no cache-invalidation protocol.
|
||||
6. **(Much later) group-level collections/topics** — the v1.3 cross-app data-sharing problem, with a
|
||||
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
||||
demands.
|
||||
|
||||
### 11.1 Re-sequencing review (post-Phase-3)
|
||||
|
||||
A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions
|
||||
to take, not yet taken:
|
||||
|
||||
- **Phase 4 should resolve scripts *live*, not materialize.** §11.4 carries "cache-invalidation
|
||||
fan-out hardening" as a deliverable, a holdover from the materialized-view model. Phase 3 proved
|
||||
**live resolution** (recursive CTE per call) is sufficient and sidesteps the §10 invalidation SLA,
|
||||
and script *bodies are already live-resolved per request today* (§5.1). So Phase 4 should resolve
|
||||
group-owned scripts live too and **drop the fan-out-invalidation sub-project** unless a *measured*
|
||||
hot-path cost appears. This removes the scariest item from Phase 4; the real remaining cost is the
|
||||
**module/import resolver redesign** (§5.5), which is unaffected by this and stays the gating risk.
|
||||
- **Consider a "Phase 5-lite" *before* Phase 4.** The declarative project tool already applies app
|
||||
scripts (Phase 1) and Phase 3 shipped the group vars/secrets CRUD, so a project tool that declares
|
||||
**group vars/secrets + app scripts** needs *nothing* from Phase 4 (group-owned scripts). The
|
||||
highest-leverage day-to-day workflow (declarative config apply) can ship ahead of the hard
|
||||
module-resolver work. Phase 4 then adds group-owned *scripts* to an already-working project tool.
|
||||
- **Two deferred decisions to force, not default:**
|
||||
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
|
||||
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
|
||||
Decide against **actual** target tenant counts before defaulting it late.
|
||||
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
|
||||
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
|
||||
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
|
||||
before building the ownership layer.
|
||||
|
||||
---
|
||||
|
||||
## 12. Contracts still to draft
|
||||
|
||||
- The **apply bundle / plan artifact / change-report** wire contract (what the CLI ships, what the
|
||||
server persists and returns), including the conflict and blast-radius shapes.
|
||||
- The **effective-view resolver** (the read primitive) — the §3 rule made executable, plus the
|
||||
materialization + invalidation protocol (§5.1).
|
||||
- The **effective-view resolver** (the read primitive) — the §3 rule made executable. *(Phase 3:
|
||||
shipped as a **live** recursive-CTE resolver, `config_resolver`/`secrets_repo::resolve`; the
|
||||
materialization + invalidation protocol of §5.1 was **not** built — see the Phase 3 status note in
|
||||
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
|
||||
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
|
||||
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
|
||||
|
||||
@@ -1256,6 +1256,28 @@ Released in patch steps (v1.1.0 → v1.1.8), each landing one focused capability
|
||||
---
|
||||
|
||||
### Phase 5: v1.2 (Advanced Workflows & Hierarchies)
|
||||
|
||||
Two tracks under the same minor line. The **Hierarchies** track is in active development; the
|
||||
**Workflows** track follows it (or runs in parallel — sequencing is an open call, see the design doc's
|
||||
§11 re-sequencing note).
|
||||
|
||||
**Hierarchies — groups + the declarative project tool** (the detailed design and 6-phase breakdown
|
||||
live in [docs/design/groups-and-project-tool.md](../docs/design/groups-and-project-tool.md) §11; this
|
||||
absorbs the original one-line "Hierarchies" bullet into a full initiative):
|
||||
- A single-parent **groups** tree above apps, with hierarchy-aware RBAC (inherited membership). *(§11
|
||||
Phase 2 — shipped: `0047_groups.sql`, `effective_app_role`/`effective_group_role`.)*
|
||||
- **Group-inherited, environment-scoped config** — `vars` + group secrets, resolved **live** (recursive
|
||||
CTE, no materialized cache) with the §3 proximity/merge/tombstone rule; the group-secret AAD scheme;
|
||||
masked secrets (an app runs with config its own devs cannot read). *(§11 Phase 3 — shipped:
|
||||
`0048_vars.sql`, `0049_group_secrets.sql`.)*
|
||||
- A **declarative, file-based project tool** (`pic plan`/`apply`/`prune`, env overlays, the three-state
|
||||
`enabled` lifecycle, bound-plan staleness check). *(§11 Phase 1 — shipped.)*
|
||||
- Remaining: group-owned **scripts/modules** with the scope-aware import resolver (§11 Phase 4); the
|
||||
project tool **mapping onto the group tree** (nested manifests, attach points, ownership — §11 Phase
|
||||
5). Group-level shared *data* (collections/topics) stays in v1.3 (the cross-app-sharing problem
|
||||
below).
|
||||
|
||||
**Workflows:**
|
||||
- Function workflows (DAG execution, conditional branching, error handling)
|
||||
- Nested workflows
|
||||
- Call graph visualization + execution tracing
|
||||
|
||||
Reference in New Issue
Block a user