Compare commits

..

42 Commits

Author SHA1 Message Date
MechaCat02
52da8a8704 test(cli): group-module lexical-resolution journeys + Phase 4b docs (C5)
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 17m58s
CI / Dashboard — check (push) Successful in 9m45s
Two journeys against a live server + Postgres:
- `group_module_is_lexically_sealed_under_inheritance`: a group module +
  a group endpoint that imports it, inherited by an app — proves the §5.5
  trust boundary (a leaf's same-named module does NOT shadow the inherited
  endpoint's import) and app-origin CoW (an app endpoint's import resolves
  the app's module).
- `dangling_import_is_rejected_by_plan`: a manifest script importing a
  non-existent module is a `pic plan` error.

Docs: mark §5.5 residual resolved + §11 Phase 4b  in the design doc;
update CLAUDE.md current-focus (extension points are the remaining §5.5
piece).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:39:08 +02:00
MechaCat02
c8acac1d20 feat(modules): enable group modules + dangling-import plan check (Phase 4b C4)
Lift the Phase-4-lite restrictions now that import resolution is lexical:

- `create_group_script` (group_scripts_api): allow `kind = module` and
  group scripts with `import`s; record the import edges. Module-shape and
  sandbox-ceiling validation stay.
- The declarative apply path already reconciles group modules generically
  (`BundleScript.kind` + `owner.script_owner()` flow through
  `reconcile_node_tx`); no change needed beyond stale-comment fixes.

Adds the §5.5 dangling-import plan check (single-node plan/apply):
`check_imports_resolve` rejects an `import "<m>"` that resolves to neither a
bundle-local module nor a module reachable lexically up the node's chain —
caught at plan time instead of as a runtime 404. Roots resolution at the
node's owner (an app node reaches ancestor group modules; a group node walks
its own ancestry). ApplyService gains an injected `ModuleSource`. The tree
path opts out (a node may import a module created by another node in the same
uncommitted tx); the runtime check is the backstop there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:29:18 +02:00
MechaCat02
b53eabb786 feat(modules): lexical import resolver — seal imports to defining node (Phase 4b C3)
Make `import` resolution lexical (§5.5): an inherited group script's
imports resolve against the module set at its OWN defining node, walking
up from there — a leaf app can't shadow them, and a group script behaves
identically under every inheriting app.

Mechanism — Rhai's `_source` carries the importing AST's origin tag:
- The entry script has no source tag → resolve from `default_origin` (its
  defining node, threaded in via C2).
- Each resolved module's AST source is set to `encode(module.owner())`
  before `eval_ast_as_new`, so its nested `import`s carry the module's own
  node as `_source` and resolve from there — lexical chaining.
- `encode_origin`/`decode_origin` map a `ScriptOwner` to/from `app:<uuid>`
  / `group:<uuid>`.

Cache rekeyed from `(app_id, name)` to the resolved `ScriptId`: a compiled
module is lexically self-contained, so its body is a pure function of
`(script_id, updated_at)` — this also dedupes a shared group module across
inheriting apps and keeps same-named cross-app modules distinct.

Adds two executor-core unit tests (origin-keyed fake) proving the trust
boundary: a group module's import binds the group's module even when an
app-owned module of the same name exists; `ScriptOwner` gains `Hash`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:25 +02:00
MechaCat02
1844bef132 feat(executor): thread the defining node into ExecRequest (Phase 4b C2)
Carry the resolved script's owner (its defining node) from dispatch into
the executor so `import` resolution can be lexical (§5.5). The executing
app (`app_id`) stays the SDK isolation boundary; `script_owner` is a
separate axis — the lexical origin for imports.

- `ExecRequest.script_owner: Option<ScriptOwner>` (serde default; `None`
  falls back to `App(app_id)` in the engine for old payloads / cluster wire).
- `ScriptOwner` gains `Serialize`/`Deserialize` for the wire.
- The engine computes `default_origin` and hands it to the resolver
  (used fully in C3; the resolve() lookup already uses it).
- Every dispatch site sets it from the resolved `Script.owner()`:
  the 4 dispatcher arms (queue/trigger/http/invoke_async, via a new
  `ResolvedTrigger.script_owner`), the orchestrator id-bypass, and the
  `invoke()` SDK path (new `ResolvedScript.owner`, so a group script
  invoked by an app resolves imports from the group).

Behaviour-preserving: app scripts still resolve `App(app_id)`; group
scripts can't yet carry imports (lifted in C4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:17:33 +02:00
MechaCat02
7fef098b48 feat(modules): owner-aware module lookup — lexical chain walk (Phase 4b C1)
Make the module-loading contract origin-aware so group-owned modules
become resolvable and imports can resolve lexically (§5.5).

- `ModuleScript` gains a polymorphic owner (`app_id`/`group_id` + `owner()`),
  mirroring `Script`.
- `ModuleSource::lookup(&cx, name)` -> `resolve(origin: ScriptOwner, name)`:
  resolution walks the group chain rooted at the importing script's
  defining node (nearest-owner-wins), not the inheriting app's view.
- `PostgresModuleSource::resolve` branches on origin: app-rooted
  (`CHAIN_LEVELS_CTE`) or a new group-rooted `GROUP_CHAIN_LEVELS_CTE`,
  joining `kind='module'` rows.

The executor resolver passes a temporary `App(cx.app_id)` origin (behaviour-
preserving for app scripts; true lexical origin lands in C3). Group scripts
still can't carry imports until C4, so no trust-inversion window opens here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:07:04 +02:00
MechaCat02
3ef3038eb7 test(cli): project-tree journeys + Phase 5 docs
Phase 5 C5. Three `pic ... --dir` tree journeys:
  * a group + a nested app apply atomically as one tree (the app's route binds
    the group's same-apply `shared` script); the group node's script is
    created; re-plan is all-noop (idempotent),
  * one invalid node aborts the whole tree — the valid group node's script is
    NOT created (true all-or-nothing),
  * a `pic groups reparent` between `pic plan --dir` and `pic apply --dir` trips
    the bound-plan (StateMoved) check via the tree's structure version.

Docs: `groups-and-project-tool.md` §11.5 status ( shipped — single-repo
nested tree apply; multi-repo single-owner + per-env approval gating deferred
per §11.1) + CLAUDE.md current-focus.

Gates: fmt, clippy -D warnings, cargo test --workspace, check-versioning (50
migrations — Phase 5 added none), schema snapshot unchanged, full journey suite
108/108. (Pre-existing flake `queue_e2e::queue_receive_acks_on_success` — a
racy immediate `count==0` after the async ack; flaky at the pre-Phase-5
baseline too, unrelated to this work.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:43:20 +02:00
MechaCat02
1662d7806a feat(cli): nested-manifest discovery + pic plan/apply --dir tree mode (Phase 5 C4)
Phase 5 C4. `pic plan --dir <root>` / `pic apply --dir <root>` discover every
`picloud.toml` under a directory (groups + apps), assemble the wire tree
bundle, and drive the C3 `/tree/{plan,apply}` endpoints — a whole project
subtree reconciled and reviewed as one unit.

  * New `discover` module: recursive walk (skips `.picloud`/`.git`/`scripts`/
    `target`), one node per manifest, app/group inferred from the manifest;
    rejects a duplicate (kind, slug). On-disk nesting is organizational — the
    server resolves ancestry from its own group tree.
  * `client`: `plan_tree`/`apply_tree` + `TreePlanDto`/`NodePlanDto`. `pic plan
    --dir` renders a per-node diff table and records ONE tree bound-token under
    a `<tree>` linkstate marker; `pic apply --dir` replays it (force/`--yes`/
    prune apply tree-wide). `--dir` conflicts with `--file`.

Live-validated: a nested tree (root group dir + nested app dir, app route bound
to the group's inherited script) → `pic plan --dir .` shows both nodes' changes
→ `pic apply --dir .` applies all in one tx → re-plan all-noop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:25:29 +02:00
MechaCat02
8a559ea178 feat(apply): atomic project-tree apply with cross-node inherited binding (Phase 5 C3)
Phase 5 C3 — the headline. A whole project subtree (group + app nodes) is
reconciled in ONE Postgres transaction, all-or-nothing.

`POST /api/v1/admin/tree/{plan,apply}` take a `TreeBundle { nodes: [{kind,
slug, bundle}] }`. The actor must hold the relevant read/write caps for EVERY
node (per-kind, widened on prune) — `authz_tree` + reusable
`require_{app,group}_node_writes` (now shared with the single-node handlers).

Engine:
  * The in-tx reconcile body of `apply_owner` is extracted to `reconcile_node_tx`
    (returns the node's post-create `name → script_id`), so single-node and
    tree apply share one implementation — behaviour-preserving (391 unit tests
    + app journeys green).
  * `prepare_tree` resolves each slug→owner, validates (an app's route/trigger
    target may bind to an in-tree ancestor group's script), computes each
    node's plan, and folds a combined bound-plan token = every node's
    `state_token` + every in-scope group's `structure_version` (so a reparent
    or content edit between plan and apply trips StateMoved).
  * `apply_tree`: lock every node key (sorted, deadlock-free); reconcile GROUPS
    first, recording each group's `name → id` in an in-memory index; then APPS,
    resolving inherited route/trigger targets nearest-ancestor-wins across the
    in-tree index (incl. scripts created earlier in THIS tx, invisible to the
    pool resolver) and pre-existing out-of-tree ancestors. One commit, one
    post-commit route refresh.

Live-validated against the dev DB:
  * Headline: a group node creates `shared`; an app node in the SAME apply
    binds `GET /greet` to it — the route ends up bound to the group-owned
    script, and re-plan is all-noop (idempotent).
  * Atomicity: one invalid node → 422 and ZERO rows written (the valid group
    node's script is not created).

CLI tree discovery + `pic plan/apply` tree mode is C4; journeys + docs are C5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:18:07 +02:00
MechaCat02
da03fda1d6 feat(cli): [group] manifest + pic plan/apply for a group node (Phase 5 C2)
Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.

  * `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
    exactly-one check in `parse`, plus a group-node guard that rejects
    `[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
    `is_group()` replace the hardcoded `manifest.app.slug`.
  * `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
    selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
    are gone (callers pass the kind).
  * `pic plan`/`apply` detect the node kind from the manifest and label output
    `group`/`app`; `pic config --effective` cleanly rejects a group manifest
    (effective config is an app's inherited view).

Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:57:58 +02:00
MechaCat02
65049a6262 feat(apply): owner-generic apply engine — group-node plan/apply (Phase 5 C1)
Phase 5 C1. Generalize the reconcile engine from app-only to an `ApplyOwner
{ App | Group }`, so a GROUP's own config + scripts can be declaratively
reconciled — the foundation for the tree-spanning project tool.

A group node is a subset of an app node (scripts + vars only; no routes /
triggers / secrets-values), so the diff, script/route/trigger/var CRUD, prune,
idempotency, and the Phase-4 inherited-target resolution are all reused
unchanged. Only the owner-varying bits switch on `ApplyOwner`:
  * `load_current` — `list_for_group` + `VarOwner::Group` for a group (empty
    routes/triggers/secrets); `list_for_app` for an app, exactly as before.
  * script create owner (`NewScript{app_id|group_id}`), var writes (new
    `set_group_var_tx`/`delete_group_var_tx` mirroring the app variants at
    scope `*`), and the advisory lock (owner-tagged).
  * `validate_bundle_for` rejects routes/triggers on a group bundle (422);
    route-host validation, inherited-target resolution, the post-commit route
    refresh, and the unreachable-endpoint warning are app-only.

`apply`/`plan` are unchanged thin wrappers over the new `apply_owner`/
`plan_owner`. New endpoints `POST /groups/{id}/{plan,apply}` gated by
`GroupScripts{Read,Write}` / `GroupVarsWrite`. `ApplyService` gains a `groups`
repo (the tree apply in C3 needs it too).

Live-validated: group plan → apply (group-owned script + var) → idempotent
re-plan noop → routes rejected 422 → DB confirms group ownership. App apply
unchanged: 391 manager-core unit tests + the apply/prune/staleness/overlay
journeys all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:46:52 +02:00
MechaCat02
99e7100f0b fix(apply): resolve inherited-bound triggers in diff, state-token, and prune
Adversarial review of Phase 4-lite found two real gaps, both rooted in the
apply engine resolving script `id → name` from `current.scripts` (app-own)
only — so a route/trigger bound to an inherited GROUP script (whose id is not
app-own) couldn't be named:

  * **Prune gap (reachable via pure declarative apply):** a trigger bound to an
    inherited group script, once dropped from the manifest, never diffed to a
    Delete (the diff couldn't resolve its identity once the bundle stopped
    referencing the name), so `apply --prune` silently orphaned it.
  * **Bound-plan false-negative:** `state_token` excluded such a trigger from
    its fingerprint, so the StateMoved check missed a concurrent edit to that
    binding class. (Routes were already safe — they hash the raw `script_id`.)

Fix: `resolve_current_target_names` resolves the names of group scripts the
app's CURRENT routes/triggers are bound to (by id, independent of the bundle),
and that map now feeds `compute_diff_with_names`, `state_token_with_names`, and
the prune trigger-identity map. App-owned bindings are unaffected (their names
are already in `current.scripts`). Also fixes a stale "in this app" conflict
message for group-owned scripts.

New journey `inherited_bound_trigger_is_pruned_when_dropped` proves prune now
removes an inherited-bound cron trigger. Full journey suite 105/105.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:11:59 +02:00
MechaCat02
9598db149b docs: record Phase 4-lite (group-owned scripts) as shipped, live-resolved
Mark §11.4  shipped with the live-resolution decision (no body
materialization / invalidation fan-out, mirroring Phase 3's config), the
shipped surface, and the deliberate Phase-4b deferrals (group modules + lexical
import resolver, invoke-by-id, live auto-rebinding). Note the sharp edge:
routes.script_id ON DELETE CASCADE means deleting a group script drops
descendant routes. Update the §5.1 materialization box and CLAUDE.md
(current-focus + the data-model invariant now covers group-owned scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:44:51 +02:00
MechaCat02
c29e4b9c53 test(cli): journey for group-script inheritance, CoW, isolation + apply binding
Phase 4-lite C5. Two `pic` journeys:
  * `group_script_is_inherited_with_cow_and_isolation` — a group endpoint
    `shared` is invoked by name from an app under the group; the app's own
    `shared` then shadows it (CoW); an app outside the group cannot reach it.
  * `manifest_binds_route_to_inherited_group_script_idempotently` — a manifest
    that declares no `greet` script binds a route to the inherited group
    `greet`; plan shows a route create (no script create), apply succeeds, and
    re-plan is a clean no-op.

Adds a `ScriptGuard` (deletes a script by id on drop) since a group-owned
script blocks its group's deletion (ON DELETE RESTRICT) — it must drop before
the GroupGuard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:42:35 +02:00
MechaCat02
701ec90b56 feat(apply): declaratively bind routes/triggers to inherited group scripts
Phase 4-lite C4 — the headline declarative path. An app's manifest can now bind
a `[[routes]]` / `[[triggers]]` to a script it does NOT declare, resolving the
name to an inherited group-owned endpoint on the app's chain (nearest-owner
wins; an app's own script of the same name still shadows it — CoW).

  * `resolve_inherited_targets(app_id, bundle)` resolves every route/trigger
    target name that isn't an app-own manifest script to a group endpoint via
    `get_by_name_inherited`, returning `name → script_id`. Run once before the
    apply transaction (group scripts pre-exist, committed).
  * `validate_bundle` takes the inherited endpoint names so "binds to unknown
    script" / "is a module" checks pass for inherited targets.
  * The apply transaction merges the inherited targets into `name_to_id`
    (app-own keeps precedence), so route/trigger inserts bind to the group
    script's id.
  * `compute_diff_with_inherited` adds the inherited ids → names to the diff's
    name map, so a route bound to a group script resolves its name and
    re-apply is idempotent (without this it read as a perpetual rebind).

Live-validated: an app under group G with a manifest binding `GET /greet` to
the group's inherited `greet` plans as `route create → greet` (no script
create), applies (+1 route, +0 scripts), and re-plans as `noop` (idempotent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:32:24 +02:00
MechaCat02
719a17432f feat(scripts): inherited resolution for invoke() + chain-aware dispatch guards
Phase 4-lite C3. A descendant app can now resolve and run a group-owned script
inherited down its ownership chain — the core of group-script inheritance.

Resolver (repo, reusing config_resolver's CHAIN_LEVELS_CTE):
  * `get_by_name_inherited(app_id, name)` — walk app → ancestor groups
    nearest-first, return the nearest script of that name (an app's own script
    shadows an inherited group one: CoW).
  * `is_invocable_by_app(script_id, app_id)` — EXISTS over the same chain; the
    cross-app isolation predicate generalized to the hierarchy.

invoke(): `invoke("name", …)` resolves via `get_by_name_inherited`, so a script
can call a shared group endpoint by name. A group script always runs under the
*caller's* app context, never its owner.

Dispatch guards (the isolation backstops touched in C1) are now chain-aware via
a `Dispatcher::script_invocable` helper and the chain-aware
`validate_trigger_target`: app-owned scripts keep the in-memory fast path (no
query, behavior byte-identical to the pre-Phase-4 same-app guard); only a
group-owned candidate pays one chain query, and a non-member / query error
fails closed (dead-letter or reject — never run under another app's SdkCallCx).

The orchestrator HTTP route path needs no change: its per-app route trie
already scopes routes to one app, and it dispatches under the route's app
without reading script.app_id — a route bound to a group script just works.

Live-validated against the dev DB: an app under group G invoking the group's
`shared` returns its output; an app NOT under G cannot resolve it (isolation);
and after the app deploys its own `shared`, the same call returns the app's
version (CoW shadowing). Route/trigger binding of inherited scripts via the
declarative engine is C4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:23:07 +02:00
MechaCat02
43ed4e8e7f feat(scripts): group-owned script admin API + pic scripts … --group
Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.

Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.

API:
  * New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
    group-owned endpoint script and list a group's own (non-inherited) rows.
    Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
    `import` are rejected (group modules + the lexical resolver are Phase 4b).
    Owner resolved first (slug-or-uuid); capability bound to the resolved id.
  * The by-id `/scripts/{id}` get/update/delete/logs handlers are now
    owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
    `App*` exactly as before; group-owned on `GroupScripts*`. This is what
    makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
    group script be deleted by id. (C1 had these fail closed for groups.)

Repo: `list_for_group(group_id)` (the group's own rows).

CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.

Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:10:20 +02:00
MechaCat02
48178c5f60 feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).

Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.

Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.

Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).

Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:53:05 +02:00
MechaCat02
27dc04819f test(cli): journey for declarative app-var reconciliation
`apply_reconciles_app_vars` drives the full cycle through `pic` against a
real server: a manifest `[vars] region = "eu"` is applied (var created +
injected — a script `vars::get("region")` returns "eu"), the value is
changed and re-applied (Update, returns "us"), then the var is dropped and
`apply --prune` removes it (`vars::get` resolves to `()` / JSON null).
Exercises the Bundle.vars wire, diff_vars create/update/delete, the in-tx
writes, and the runtime resolver end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:06:44 +02:00
MechaCat02
181622a84a feat(cli): pic pull exports an app's own vars into [vars]
Completes the vars round-trip: pull now fetches the app's OWN env-agnostic
vars (`vars_list` filtered to scope `*`, non-tombstone) and writes them
into the manifest `[vars]` block, alongside scripts/routes/secrets.
Inherited group vars stay out (pull exports own rows only, §4.6); a value
TOML can't represent (e.g. JSON null) is skipped with a warning. So
`pic pull` → edit → `pic apply` is now lossless for app config vars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:03:26 +02:00
MechaCat02
34dcae98a2 feat(cli): declare app vars in the manifest + plan/apply them
Adds a `[vars]` block to `picloud.toml` (key → TOML value), reconciled by
`pic plan`/`pic apply` alongside scripts/routes/triggers/secrets. Unlike
`[secrets]` (name-only), var values live inline since they aren't secret.
The overlay (`picloud.<env>.toml`) may carry `[vars]`; overlay keys win
per key (the env-specific value of an env-agnostic default).

`build_bundle` serializes the vars to JSON for the wire (TOML round-trips
via serde); `pic plan` shows a `var` row group and `pic apply` reports
`vars +c ~u -d`. The `PlanDto`/`ApplyReportDto` gain the matching fields.
`pic pull` carries an empty `[vars]` for now (export lands next). Adds a
round-trip + overlay-precedence unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:40:36 +02:00
MechaCat02
e517f6dc8c feat(apply): reconcile app-owned vars in the declarative engine
Extends the Phase-1 apply engine to manage app `vars` declaratively,
alongside scripts/routes/triggers/secrets. The `Bundle` gains a `vars`
map (key → JSON value; values are non-secret, so they live inline, unlike
secret names). `diff_vars` is value-sensitive: a changed value is an
Update, a live var the manifest dropped is a Delete (applied only under
`--prune` — vars are prunable config, unlike never-pruned secrets).

Writes go through `set_app_var_tx`/`delete_app_var_tx` inside the apply
transaction (mirroring `PostgresVarsRepo::set` for `VarOwner::App`, scope
`*`), so they commit atomically with the rest of the apply and roll back
together on failure. `CurrentState` loads the app's OWN scope-`*`,
non-tombstone vars (inherited group vars and tombstones are out of scope
for the manifest); `state_token` folds them in so the bound-plan check
catches an out-of-band `pic vars set` between plan and apply. Keys are
validated against the same kebab rule as the vars admin API, and the
apply handler now requires `AppVarsWrite` when vars are touched or
`--prune` is set.

Scope: app-owned vars at scope `*` (an app *is* an environment). Group
vars via manifest and tombstone-via-manifest wait for the nested-manifest
model. Unit tests cover the create/update/noop/delete diff and the
state-token value sensitivity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:35:12 +02:00
MechaCat02
b2822c8435 Merge: groups Phase 3 — group-inherited config (vars + group secrets)
Adds the net-new env-scoped `vars` table + the §3 resolution engine
(env pre-filter, proximity-first, per-key map merge, explicit tombstones),
group-owned environment-scoped secrets with owner-discriminated AAD
(secret:group:{group_id}:{name}, disjoint from the unchanged app AAD so
existing ciphertexts still decrypt), masked group-secret reads (app devs
see exists, never plaintext; only owning-group principals read values;
runtime injection bypasses the human gate), the vars:: SDK, and
pic vars / pic secrets --group / config --effective. Migrations 0048
(vars) and 0049 (group secrets reshape).

Verified before merge: fmt/clippy(-D warnings) clean; manager-core +
executor-core 523 unit tests pass (incl. AAD cross-owner-swap rejection
and the pure §3 resolver semantics); check-versioning OK (49 migrations);
all 101 CLI journeys pass against a live DB, including masked-read
(group_secret_value_is_masked_from_app_devs), runtime injection +
override, and var inheritance. Commit 79f8c9d also fixes the 6
long-standing stale logs/scripts/roles journey assertions, so the suite
is now fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:19:08 +02:00
MechaCat02
b46667c309 docs: reconcile the groups/project-tool initiative into the roadmap
The groups/project-tool work had no home in the blueprint's version
scheme, two "Phase N" numbering schemes overlapped, and CLAUDE.md's
"current focus" was stale (claimed v1.1.0 SDK foundation; we're at v1.1.9
with the SDK line complete). Reconcile all three:

* blueprint §12 Phase 5 (v1.2, already titled "Advanced Workflows &
  Hierarchies") — split into a Hierarchies track (the groups + project
  tool initiative, with §11 Phases 1–3 marked shipped) and a Workflows
  track, with sequencing flagged as an open call. This gives the work an
  explicit version home: the Hierarchies half of v1.2.
* design doc header — Status Draft → Active, "scheduled as the Hierarchies
  track of v1.2", and an explicit warning that §11's local Phase 1–6
  numbering is distinct from the blueprint product-phase numbering.
* design doc §11.1 — a post-Phase-3 re-sequencing review: resolve Phase 4
  scripts LIVE (drop the fan-out-invalidation sub-project, per the Phase 3
  result); consider a "Phase 5-lite" (declarative apply of vars/secrets +
  app scripts) before the hard group-scripts work; and force the two
  deferred decisions (trigger/route templates vs tenant cardinality;
  multi-repo ownership vs a single-repo start).
* CLAUDE.md — refresh "current focus" to v1.2 Hierarchies, and correct the
  now-broken "every v1.1+ table is app_id NOT NULL" invariant: the new
  group-inheritable config tables (vars, secrets) use a polymorphic owner
  (group_id XOR app_id), not app_id NOT NULL.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:12:25 +02:00
MechaCat02
ca360d84cb docs(design): record Phase 3 shipped + the live-resolution decision
Phase 3 (group-inherited config) is implemented, so fold the outcome back
into the design doc (per its own "before it drifts" note) — and flag the
one deliberate divergence from the plan.

§5.1 / §11.3 / §12 prescribed a *materialized* per-app effective view with
a generation counter and fan-out invalidation. We shipped **live,
resolve-on-read inheritance** instead: one recursive CTE per
`vars::get`/`secrets::get`/`config/effective`, no cache. The org tree is
small and there was no pre-existing config cache to fight, so this is
cheap and sidesteps §10's unsolved invalidation SLA.

Records, mirroring the Phase 1/2 status blocks:
* a "Status (Phase 3):  shipped" block in §11.3 with the full surface
  (0048/0049 schema incl. `apps.environment`, the §3 resolver, vars SDK +
  CRUD, group-secret AAD + inherited read, the two secret gates, masked
  `config/effective`, `--explain`) and the live-vs-materialized rationale;
* §5.1 runtime-model bullet annotated as the deferred (Phase-4 script
  inheritance) design, not Phase-3 runtime;
* §10 "invalidation SLA" and "inherited-membership revocation lag" marked
  moot/resolved — no cache means immediate propagation;
* §12 resolver contract + the header framing updated to live resolution.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:53:58 +02:00
MechaCat02
79f8c9d420 test(cli): refresh stale deploy + logs journey assertions
Six journeys asserted against output formats that changed in earlier
commits and had been failing regardless of branch:

* `pic scripts deploy` prints a `name`/`version`/`action` KvBlock, not a
  prose "Created X vN" line. The name-override, version-bump, and the
  editor-can-deploy role check now parse the KvBlock fields (asserting the
  exact version + created/updated action) via a small `kv_field` helper.
* `pic logs` gained a `source` column (now `created_at, source, status,
  summary`); the success/error-status and summary-truncation checks now
  index the status at col 2 and the summary at col 3.

No production code touched — the deploys/logs always succeeded; only the
assertions were stale. Full `--test cli --include-ignored` suite is now
green (101 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:37:36 +02:00
MechaCat02
bb68e5e50a fix(secrets): composite keyset for group-secret admin list pagination
`list_meta` orders by `(name, environment_scope)` — a group secret name
can carry several env-scoped rows — but the cursor encoded only `name`
and paginated with `name > $cursor`. When a name's scopes straddled a
page boundary, the remaining scope rows were silently skipped from the
admin listing. Switch to a composite `(name, environment_scope)` keyset
(base64url of `name \x1f scope`) and a Postgres row-comparison predicate.
App secrets (single `*` scope per name) were unaffected; group secrets
with multi-scope names now page completely. Found in final branch review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:13:00 +02:00
MechaCat02
11ac168839 test(secrets): group-secret inheritance + masked-read journeys
Two end-to-end journeys via `pic` against a real server:

* `group_secret_is_injected_then_app_value_overrides` — a group-owned
  secret is injected into a descendant app's script through `secrets::get`
  (decrypted under the group AAD), then an app-owned secret of the same
  name shadows it (decrypted under the app AAD). Drives the resolver + both
  owner-AAD open paths against Postgres.
* `group_secret_value_is_masked_from_app_devs` — the §5.3 boundary: an app
  dev (editor on the app, no group role) is denied the secret VALUE (403)
  yet still sees it EXISTS, masked, in `config/effective` (status set,
  owner group, no value); a group_admin reads the value. Proves an app
  runs with config its own devs cannot read.

Also updates the existing app-secrets `ls` journey for the new `env`
column (group secrets are env-scoped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:09:14 +02:00
MechaCat02
30441549d5 feat(cli): pic secrets --group, value read, and effective vars/config
Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an
`--app`/`--group` owner selector (exactly-one) with `--env` for group
secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only
command that reveals a secret value, hitting the group-gated value
endpoint. `pic config --effective` now folds in the resolved vars section
(value + owner + scope) and an `--explain` provenance view, alongside the
existing masked-secrets cross-reference, via `GET /config/effective`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
c914758c09 feat(secrets): group-secrets admin API, masked read, config/effective
Adds the Phase-3 admin surface on top of the group-secrets storage:

* `secrets_api` gains group routes under `/groups/{id}/secrets`
  (set/list/delete, env-scoped) gated `GroupSecretsWrite` (editor+), plus
  the ONE plaintext endpoint `GET /groups/{id}/secrets/{name}/value` gated
  `GroupSecretsRead` (group_admin only). That is the masked-secret
  boundary: a descendant app's dev sees a group secret EXISTS and consumes
  it at runtime via `secrets::get`, but only a reader at the OWNING group
  gets the value. App secrets stay env-agnostic (a stray `env` is rejected).
  The owner is resolved first, then the capability binds to the resolved
  id — never a path param.

* `config_api`: `GET /apps/{id}/config/effective` (gated `AppVarsRead`)
  returns the resolved view a dev would get — every inherited var with its
  value + provenance, and every inherited secret MASKED (name/owner/scope,
  never the value). Backed by a new `fetch_effective_secret_meta`
  (DISTINCT-ON nearest-wins, same ordering as the per-name resolver).

* authz: `GroupSecretsWrite` moves from `app:admin` to `script:write`
  scope so its API-key scope matches its editor role tier (closing the
  latent scope/role mismatch the checkpoint review flagged); the value
  read `GroupSecretsRead` stays at `app:admin`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
e6b4792389 feat(secrets): group-owned, env-scoped secrets + inherited resolution
Migration 0049 reshapes `secrets` to the same polymorphic-owner contract
as `vars` (0048): a secret is owned by an app XOR an ancestor group,
carries an `environment_scope`, and the old PK `(app_id, name)` becomes
two partial-unique indexes `(owner, environment_scope, name)`. Existing
rows backfill to `app_id` + scope `'*'`; the v1 AAD does NOT include the
scope, so every current ciphertext keeps decrypting byte-for-byte.

`SecretsRepo` is generalised to be owner+scope keyed (`SecretOwner` moves
down from `secrets_service` and is re-exported for path stability). The
SDK read path now goes through `SecretsRepo::resolve`, which reuses the
shared `CHAIN_LEVELS_CTE` to walk app→ancestor-group→root, env-filters,
and takes the nearest level (`@E` beating `*` within a level) — returning
the winning owner so the value is decrypted under the AAD it was sealed
with. Runtime injection stays anchored to `cx.app_id`: an app only ever
resolves its own and its ancestors' secrets.

All callers updated to owner+scope (`SecretOwner::App(_)`, scope `'*'`):
the app-secrets admin API, the SDK service, and the apply email-secret
path. The 21 secrets unit tests (incl. the app/group AAD-disjointness and
cross-row swap proofs) stay green; the chain-walk was live-verified
against Postgres (nearest-wins + env-filter). Admin API for group secrets
and the masked-read endpoint land next (Step E).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:48:33 +02:00
MechaCat02
b588fc9d35 fix(config): same-level @E config suppresses * instead of merging it
§3 step 1 treats an environment scope as *eligibility*, not a merge tier:
within a single level a value scoped `@E` shadows the same level's `*`
fallback — it never layers on top of it. The resolver's map-run loop
collected every leading object row regardless of (depth, scope), so a
level holding both an `@E` map and a `*` map would deep-merge the two
(and report a bogus `*` layer in `merged_from`) rather than letting the
`@E` map win alone.

Dedup the sorted candidates by depth before the map run: each level is a
single owner (single-parent tree) with at most one `@E` and one `*` row
per key after env-filtering, and `@E` sorts first, so keeping the first
row per level yields the level's winner. Scalar/tombstone cases already
went through the boundary path and were unaffected; only same-level
map-vs-map mis-merged.

Adds two regression tests (same-level suppression; suppressed `*` stays
invisible to cross-level merge). Found in checkpoint review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:32:15 +02:00
MechaCat02
6bd0f4699d feat(secrets): owner-discriminated AAD (SecretOwner) for group secrets
The crypto foundation for group-owned secrets (the §5.3 'single hardest
correctness detail'), done first and proven before the storage/resolution
layer.

- SecretOwner{App(AppId)|Group(GroupId)}; secret_aad/seal/open take the
  owner. The app AAD is byte-identical to the pre-Phase-3
  'secret:{app_id}:{name}', so every existing v1 row decrypts unchanged;
  group secrets use a disjoint 'secret:group:{group_id}:{name}' namespace
  (the 'group:' infix separates app/group AAD even for equal UUIDs).
- All callers (SDK get/set, secrets_api, apply email-secret read) wrapped
  in SecretOwner::App — behavior identical for app secrets.
- New audit test aad_distinguishes_app_and_group_owner proves the two
  namespaces don't collide (both directions, even reusing the UUID); the
  existing cross-app/cross-name swap audit tests stay green.

16 secrets_service tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:17:18 +02:00
MechaCat02
9ee85993d8 feat(vars): admin CRUD API + pic vars CLI
Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
  SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
  resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
  error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
  vars::get(), and an app-level value overrides it (proximity) — green.

386 manager-core lib tests + the vars journey pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:11:46 +02:00
MechaCat02
343f6d3b4d feat(vars): VarsService + vars:: SDK + config capabilities
Scripts can now read group-inherited config via vars::get(key) / vars::all().

- shared: VarsService trait (read-only get/all) + NoopVarsService; added as
  the 14th field on the Services bundle.
- manager-core: VarsServiceImpl resolves the calling app's config via
  config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for
  authed principals, script-as-gate for anon).
- executor-core: vars:: Rhai bridge (get/all), registered in the SDK.
- authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write
  capabilities (group caps resolve via the Phase-2 ancestor walk; the
  GroupSecretsRead human-read gate is group_admin-only, distinct from an
  app's runtime injection).
- wired VarsServiceImpl into the picloud binary; updated the executor-core
  test Services::new call sites.

386 manager-core lib tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:59:23 +02:00
MechaCat02
35dbd9f368 feat(config): vars schema + the §3 resolution engine
Phase 3 foundation (docs §3): env-filtered, proximity-first config
inheritance down the group tree.

- Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs
  + CHECK exactly-one, env scope, JSONB value, explicit tombstone) +
  `apps.environment` (the env marker the resolver filters on — 'an
  environment is an app').
- config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that
  walks app → ancestor groups, env-filters in SQL, plus a pure Rust
  resolution pass implementing the §3 semantics SQL can't — per-key
  proximity-first, @E-over-* within a level, map deep-merge, tombstone
  suppression, and provenance for --explain.

Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call,
deep-merge, tombstone, boundary) + the candidate-fetch CTE against live
Postgres (env-filter drops a non-matching @production value; nearer level
shadows farther).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:43:16 +02:00
MechaCat02
6db057fb08 Merge: groups Phase 2 — org/RBAC/UI container (migration 0047)
Adds the GitLab-style single-parent group tree above apps: migration
0047 (groups + group_members + apps.group_id NOT NULL/RESTRICT with
single-root backfill), tree repos with an ancestor-walk cycle guard
under a coarse structural advisory lock, slug-freeze and structure_version,
hierarchy-aware RBAC (effective role = MAX across app membership + every
ancestor group, resolved live so revocation is immediate), new
Group{Read,Write,Admin}/InstanceCreateGroup caps that bound API keys
cannot obtain, plus `pic groups` and a dashboard group tree/detail/members.

Verified before merge: fmt/clippy(-D warnings)/check clean; manager-core
378 unit tests pass (incl. hierarchy authz + group repos); dashboard
npm run check 0 errors; check-versioning OK (47 migrations sequential).
New CLI groups journeys pass against a live DB — cycle guard
(reparent-into-descendant rejected), delete=RESTRICT, and
inherited-group-admin deploy + live revoke. The 6 failing logs/scripts/roles
journeys are the same pre-existing format drift on main, not regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:26:21 +02:00
MechaCat02
49c4fb41ce docs(design): mark Phase 2 (groups) shipped + record decisions/deferrals
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:16:29 +02:00
MechaCat02
8725939172 feat(dashboard): group tree, detail page, and members tab
SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns:
- api.ts: Group/GroupDetail/GroupMember types + an api.groups client
  (list/get/create/update/reparent/delete + nested members CRUD); optional
  group selector wired into app create.
- routes/groups: a collapsible tree overview (assembled from parent_id)
  with a New-group form, and a detail page with breadcrumb, subgroups/apps
  lists, a rename form (no slug field — slug is frozen), a reparent
  control, a delete button that surfaces the 409 RESTRICT message, and a
  Members tab reusing RoleChip/ConfirmModal/ActionMenu.
- +layout.svelte: a Groups nav entry beside Apps.

npm run check: 0 errors; npm run build: success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:15:44 +02:00
MechaCat02
eea1d8984e feat(cli): pic groups (tree/create/rename/reparent/rm/members)
Adds the `pic groups` command family over /api/v1/admin/groups:
ls/tree, create (--parent), show (path + subgroups + apps), rename
(name/description; slug frozen), reparent (--to), rm (--recursive,
leaf-first; never auto-deletes apps), and members add/set/rm/ls. Also
`pic apps create --group <slug>` to place an app under a group.

End-to-end journey tests (tests/groups.rs, DB-gated) cover tree CRUD,
delete=RESTRICT (409 on a non-empty group), reparent cycle rejection, and
the headline invariant: a group_admin on an ancestor group can deploy to
an app it is NOT a direct member of (inherited membership), and loses that
access immediately on revoke — all green against a live server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:15:33 +02:00
MechaCat02
a432091191 feat(groups): tree repos, hierarchy-aware RBAC, admin API
Server-side foundation for Phase-2 groups (no group-owned resources yet):

Shared types:
- GroupId, Group; App gains group_id; AppRole::{precedence,max} for
  folding the highest effective role across the membership chain.

Repos:
- group_repo: tree CRUD with reparent (ancestor-walk cycle guard under a
  coarse instance-wide structural advisory lock; slug frozen; bumps
  structure_version) and delete=RESTRICT (refuses non-empty groups).
- group_members_repo: per-(user, group) role grants, mirroring app_members.

Hierarchy-aware authz (§5.3):
- AuthzRepo gains effective_app_role / effective_group_role (default to
  direct membership / none, so the ~18 existing test stubs are untouched);
  the Postgres impl resolves each via one depth-bounded recursive CTE that
  MAXes the app's own row with every ancestor group_members row.
- can(): the Member path now folds inherited group roles, so a group_admin
  on any ancestor is implicitly app_admin beneath it. New Capability
  variants InstanceCreateGroup / Group{Read,Write,Admin}; group caps carry
  no app_id (bound API keys can't manage groups). 8 new unit tests.

Admin API:
- groups_api: group CRUD + reparent (admin at both source and destination
  parent, §5.6) + per-group members, all capability-gated.
- apps: POST /apps takes an optional parent group (default root); app
  responses carry group_id; my_role now reflects the effective role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:59:00 +02:00
MechaCat02
2b27012f56 feat(groups): schema + root-group backfill (migration 0047)
Phase 2 (blueprint §5): groups form a single-parent org tree ABOVE apps.
This migration adds the tree + membership tables and gives every app a
parent (§9 adoption):

- groups: id, parent_id (self-FK ON DELETE RESTRICT), slug (instance-
  global UNIQUE, frozen at creation), name, description, structure_version
  (bumped on structural mutation), owner_project (inert §7 seam).
- group_members: (group_id, user_id, role) with the SAME three role
  literals as app_members so AppRole round-trips and the authz rank table
  covers both.
- apps.group_id: nullable add → backfill every app under a seeded 'root'
  group → promote to NOT NULL + FK RESTRICT.

No group-owned resources yet (scripts/secrets stay app-owned — Phase 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:58:48 +02:00
MechaCat02
c900ca5bbf Merge: declarative project-tool foundation (Phase 1) + enabled lifecycle
Adds the declarative `pic` project tool (init/pull/plan/apply/prune,
env-scoped overlays, config --effective), the three-state `enabled`
lifecycle on scripts/routes with dispatcher fire-time re-checks, the
trigger `name` column + backfill, and cross-app/disabled-execution
backstops on every async dispatch path. Design captured in
docs/design/groups-and-project-tool.md.

Verified before merge: cargo fmt/clippy(-D warnings)/check clean;
manager-core 370+ unit tests pass (incl. dispatcher enabled-gates and
cross-app isolation); all new CLI journey tests pass against a live DB.
The 6 failing logs/scripts/roles journey tests are pre-existing format
drift on main, not regressions from this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:17:36 +02:00
105 changed files with 11803 additions and 740 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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());
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
-- Phase 2: groups as a pure org / RBAC / UI container — see
-- docs/design/groups-and-project-tool.md §5, §9.
--
-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds
-- the tree, hierarchy-aware membership, and structural-mutation safety —
-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned;
-- that is Phase 3). The only data-plane touch is apps gaining a parent
-- pointer.
--
-- Adoption (§9): every existing app must have a parent from day one so
-- resolution always terminates. This migration seeds a single instance
-- root group and reparents every app under it, then promotes
-- apps.group_id to NOT NULL.
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Single parent keeps inheritance acyclic and resolution
-- deterministic. NULL parent = a root node. RESTRICT (never CASCADE):
-- deleting a non-empty group is refused so descendant apps and their
-- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk
-- cycle guard that keeps this acyclic lives in manager-core (a SQL
-- CHECK can't express it).
parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT,
-- Instance-global identifier, frozen at creation. A rename/reparent
-- updates the display name/path but NEVER rewrites the slug, so the
-- deployment key stays stable and external references don't break.
-- Format validation lives in Rust handlers (same rule as app slugs).
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
-- Per-subtree structure version (§6): bumped on every structural
-- mutation of this node (reparent/rename/delete) so a future CLI/
-- orchestrator can detect structural drift. NOT an authz input —
-- authorization is resolved live every request.
structure_version BIGINT NOT NULL DEFAULT 1,
-- §7 ownership seam — the project-root that manages this node. Inert
-- in Phase 2 (no projects table yet); nullable, no FK.
owner_project UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX groups_parent_id_idx ON groups (parent_id);
-- Per-(user, group) explicit grant, mirroring app_members. Inherited
-- membership (GitLab-style) is resolved in code by walking ancestors: a
-- group_admin on any ancestor is implicitly app_admin on every app and
-- subgroup beneath it. Roles reuse the SAME three literals as app_members
-- so AppRole round-trips with zero mapping and the authz rank table
-- covers both tables.
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, user_id)
);
-- Hot path is the authz ancestor walk "what groups does this user have a
-- role on?" plus the per-group member list.
CREATE INDEX group_members_user_id_idx ON group_members (user_id);
-- Add the parent pointer to apps (nullable for the backfill window).
ALTER TABLE apps ADD COLUMN group_id UUID;
-- Seed a single instance root group and reparent every existing app under
-- it. App slugs are already instance-global, so no slug rewrite is needed
-- — the parent pointer is new metadata layered on top.
WITH root_group AS (
INSERT INTO groups (slug, name, description)
VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.')
RETURNING id
)
UPDATE apps SET group_id = (SELECT id FROM root_group);
-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a
-- group with apps can't be deleted out from under them (§5.6).
ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL;
ALTER TABLE apps
ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT;
CREATE INDEX apps_group_id_idx ON apps (group_id);

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

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

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

View File

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

View File

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

View File

@@ -8,11 +8,17 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo};
/// SQL fragment ranking the three role literals by authority so a CTE can
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
/// both effective-role queries.
const ROLE_RANK_CTE: &str =
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
#[derive(Debug, thiserror::Error)]
pub enum AppMembersRepositoryError {
#[error("database error: {0}")]
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
impl AuthzRepo for PostgresAppMembersRepository {
async fn membership(
&self,
user_id: AdminUserId,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
self.find(user_id, app_id)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))
}
/// Highest effective role on `app_id`: one recursive CTE walks the
/// app's group chain (app.group_id → groups.parent_id → … → root,
/// depth-bounded), then `MAX`es the app's own `app_members` row with
/// every ancestor `group_members` row by authority rank. A single
/// round-trip regardless of tree depth. `None` = no grant anywhere.
async fn effective_app_role(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
let row: Option<(String,)> = sqlx::query_as(&format!(
"WITH RECURSIVE ancestors AS (
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
UNION ALL
SELECT g.parent_id, anc.depth + 1
FROM groups g JOIN ancestors anc ON g.id = anc.gid
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
),
{ROLE_RANK_CTE}
SELECT eff.role
FROM (
SELECT am.role FROM app_members am
WHERE am.user_id = $1 AND am.app_id = $2
UNION ALL
SELECT gm.role FROM group_members gm
JOIN ancestors anc ON anc.gid = gm.group_id
WHERE gm.user_id = $1
) eff
JOIN role_rank rr ON rr.role = eff.role
ORDER BY rr.rank DESC
LIMIT 1"
))
.bind(user_id.into_inner())
.bind(app_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))?;
row.map(|(role,)| {
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
"invalid role {role:?} in members table"
)))
})
.transpose()
}
/// Highest effective role on a *group* node — walks the group's own
/// ancestor chain over `group_members`. Gates group management.
async fn effective_group_role(
&self,
user_id: UserId,
group_id: GroupId,
) -> Result<Option<AppRole>, AuthzError> {
let row: Option<(String,)> = sqlx::query_as(&format!(
"WITH RECURSIVE ancestors AS (
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
UNION ALL
SELECT g.id, g.parent_id, anc.depth + 1
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
WHERE anc.depth < 64
),
{ROLE_RANK_CTE}
SELECT gm.role
FROM group_members gm
JOIN ancestors anc ON anc.gid = gm.group_id
JOIN role_rank rr ON rr.role = gm.role
WHERE gm.user_id = $1
ORDER BY rr.rank DESC
LIMIT 1"
))
.bind(user_id.into_inner())
.bind(group_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))?;
row.map(|(role,)| {
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
"invalid role {role:?} in group_members table"
)))
})
.transpose()
}
}
#[derive(sqlx::FromRow)]

View File

@@ -6,7 +6,7 @@
//! that writes the history row in the same transaction.
use async_trait::async_trait;
use picloud_shared::{AdminUserId, App, AppId};
use picloud_shared::{AdminUserId, App, AppId, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
/// Only apps the user has an `app_members` row for. Drives the
/// membership-filtered `GET /admin/apps` for `member` callers.
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
/// Apps whose parent is `group_id`. Drives the group detail view.
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history(
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
slug: &str,
name: &str,
description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError>;
/// Create that also consumes a matching `app_slug_history` row, if
/// any. Used after the operator has confirmed they want to break old
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
slug: &str,
name: &str,
description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError>;
async fn update(
&self,
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
impl AppRepository for PostgresAppRepository {
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
"SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps ORDER BY name",
)
.fetch_all(&self.pool)
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
FROM apps a \
JOIN app_members m ON m.app_id = a.id \
WHERE m.user_id = $1 \
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps 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 get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
"SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE id = $1",
)
.bind(id.into_inner())
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
"SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE slug = $1",
)
.bind(slug)
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
FROM app_slug_history h \
JOIN apps a ON a.id = h.current_app_id \
WHERE h.slug = $1",
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
slug: &str,
name: &str,
description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError> {
let res = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \
VALUES ($1, $2, $3) \
RETURNING id, slug, name, description, created_at, updated_at",
"INSERT INTO apps (slug, name, description, group_id) \
VALUES ($1, $2, $3, $4) \
RETURNING id, slug, name, description, group_id, created_at, updated_at",
)
.bind(slug)
.bind(name)
.bind(description)
.bind(group_id.into_inner())
.fetch_one(&self.pool)
.await;
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
slug: &str,
name: &str,
description: Option<&str>,
group_id: GroupId,
) -> Result<App, ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
.execute(&mut *tx)
.await?;
let row = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \
VALUES ($1, $2, $3) \
RETURNING id, slug, name, description, created_at, updated_at",
"INSERT INTO apps (slug, name, description, group_id) \
VALUES ($1, $2, $3, $4) \
RETURNING id, slug, name, description, group_id, created_at, updated_at",
)
.bind(slug)
.bind(name)
.bind(description)
.bind(group_id.into_inner())
.fetch_one(&mut *tx)
.await;
let row = match row {
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at",
RETURNING id, slug, name, description, group_id, created_at, updated_at",
)
.bind(id.into_inner())
.bind(name)
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
if current_slug == new_slug {
// No-op rename; just return the row.
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
"SELECT id, slug, name, description, group_id, created_at, updated_at \
FROM apps WHERE id = $1",
)
.bind(id.into_inner())
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
let row = sqlx::query_as::<_, AppRow>(
"UPDATE apps SET slug = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at",
RETURNING id, slug, name, description, group_id, created_at, updated_at",
)
.bind(id.into_inner())
.bind(new_slug)
@@ -432,6 +451,7 @@ struct AppRow {
slug: String,
name: String,
description: Option<String>,
group_id: uuid::Uuid,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
slug: r.slug,
name: r.name,
description: r.description,
group_id: r.group_id.into(),
created_at: r.created_at,
updated_at: r.updated_at,
}

View File

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

View File

@@ -25,6 +25,7 @@ use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;
@@ -44,6 +45,9 @@ pub struct AppsState {
pub domain_table: Arc<AppDomainTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
/// Group tree — resolves an app's parent group at create time
/// (defaults to the instance root).
pub groups: Arc<dyn GroupRepository>,
}
pub fn apps_router(state: AppsState) -> Router {
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Parent group (slug or id). Defaults to the instance root group when
/// omitted — every app has a parent from day one (§9).
#[serde(default)]
pub group: Option<String>,
/// Set to `true` to consume an existing `app_slug_history` row for
/// the requested slug (breaking old redirects).
#[serde(default)]
@@ -176,23 +184,46 @@ async fn create_app(
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
validate_slug(&input.slug)?;
// Resolve the parent group: an explicit `group` (slug or id) or the
// instance root by default. Placing an app under a specific group
// additionally requires group-write there.
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
if input.group.is_some() {
require(
s.authz.as_ref(),
&principal,
Capability::GroupWrite(parent.id),
)
.await?;
}
// Historical-slug check before insert: if the slug is in history
// and the caller hasn't asked to force takeover, surface a clean
// 409 so the dashboard can present a "this will break old links"
// confirmation.
if !input.force_takeover {
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
return Err(AppsApiError::SlugInHistory(current));
return Err(AppsApiError::SlugInHistory(Box::new(current)));
}
}
let created = if input.force_takeover {
s.apps
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
.create_with_takeover(
&input.slug,
&input.name,
input.description.as_deref(),
parent.id,
)
.await?
} else {
s.apps
.create(&input.slug, &input.name, input.description.as_deref())
.create(
&input.slug,
&input.name,
input.description.as_deref(),
parent.id,
)
.await?
};
Ok((StatusCode::CREATED, Json(created)))
@@ -235,7 +266,31 @@ async fn compute_my_role(
) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role {
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
// Effective role: folds in inherited group memberships so the
// dashboard badge reflects what the caller can actually do.
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
}
}
/// Resolve an optional group identifier (slug or UUID) to a group,
/// defaulting to the instance root group when `None`.
async fn resolve_group(
groups: &dyn GroupRepository,
ident: Option<&str>,
) -> Result<picloud_shared::Group, AppsApiError> {
match ident {
None => groups
.get_by_slug(ROOT_GROUP_SLUG)
.await?
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
Some(ident) => {
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
groups.get_by_id(uuid.into()).await?
} else {
groups.get_by_slug(ident).await?
};
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
}
}
}
@@ -279,7 +334,7 @@ async fn patch_app(
Ok(app) => app,
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
return Err(AppsApiError::SlugInHistory(current));
return Err(AppsApiError::SlugInHistory(Box::new(current)));
}
return Err(AppsApiError::Conflict(msg));
}
@@ -521,14 +576,19 @@ pub enum AppsApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("group not found: {0}")]
GroupNotFound(String),
#[error("domain not found: {0}")]
DomainNotFound(Uuid),
#[error("invalid slug: {0}")]
InvalidSlug(String),
// Boxed: `App` is large enough to trip clippy::result_large_err on
// every handler returning `Result<_, AppsApiError>`.
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
SlugInHistory(App),
SlugInHistory(Box<App>),
#[error("app still contains {0} script(s); delete or move them first")]
HasScripts(i64),
@@ -567,10 +627,22 @@ impl From<AuthzError> for AppsApiError {
}
}
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
use crate::group_repo::GroupRepositoryError as G;
match e {
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
G::Conflict(msg) => Self::Conflict(msg),
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
}
}
}
impl IntoResponse for AppsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::GroupNotFound(_)
| Self::DomainNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))

View File

@@ -27,7 +27,7 @@
//! external user-facing label.
use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
/// Things a caller can attempt to do. Each app-scoped variant carries
/// the `AppId` of the resource the action targets — handlers compute
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
pub enum Capability {
/// Create a new app. Owner / admin only.
InstanceCreateApp,
/// Create a new group (root-level). Owner / admin only — a Member
/// creates subgroups under a group they group-admin (gated by
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
InstanceCreateGroup,
/// Read group metadata + list its subgroups/apps. Viewer+ on the
/// group (inherited from any ancestor); implicit for admin / owner.
GroupRead(GroupId),
/// Rename / edit group metadata, move apps into it. Editor+ on the
/// group.
GroupWrite(GroupId),
/// Group settings: delete, reparent, manage group members. group_admin
/// on the group (inherited from any ancestor).
GroupAdmin(GroupId),
/// Create / update / delete admin_users rows (other than self
/// password change, which is a separate flow). Owner / admin.
InstanceManageUsers,
@@ -103,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`+.
@@ -154,9 +193,22 @@ impl Capability {
#[must_use]
pub const fn app_id(self) -> Option<AppId> {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
None
}
Self::InstanceCreateApp
| Self::InstanceManageUsers
| Self::InstanceManageSettings
| Self::InstanceCreateGroup
// Group-scoped caps carry a GroupId, not an AppId. They return
// None here so a bound API key (which can only target its one
// app) is denied group management at the binding layer.
| Self::GroupRead(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_)
| Self::GroupVarsRead(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsRead(_)
| Self::GroupScriptsWrite(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -174,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)
@@ -193,15 +247,20 @@ impl Capability {
#[must_use]
pub const fn required_scope(self) -> Scope {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin
}
Self::InstanceCreateApp
| Self::InstanceManageUsers
| Self::InstanceManageSettings
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
Self::AppRead(_)
| Self::AppKvRead(_)
| Self::AppDocsRead(_)
| Self::AppFilesRead(_)
| Self::AppSecretsRead(_)
| Self::AppUsersRead(_) => Scope::ScriptRead,
| Self::AppUsersRead(_)
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_)
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -213,13 +272,24 @@ 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,
Self::AppAdmin(_)
| Self::AppManageTriggers(_)
| Self::AppDeadLetterManage(_)
| Self::AppTopicManage(_) => Scope::AppAdmin,
| Self::AppTopicManage(_)
| Self::GroupWrite(_)
| Self::GroupAdmin(_)
| Self::GroupVarsWrite(_)
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead,
}
}
@@ -230,11 +300,41 @@ impl Capability {
/// means unit tests can stub it.
#[async_trait]
pub trait AuthzRepo: Send + Sync {
/// Direct `app_members` row for (user, app). The single-row lookup
/// used by member-management surfaces and as the fallback below.
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError>;
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
/// the app's own `app_members` row folded with every `group_members`
/// row on any ancestor group, max-by-authority. This is what `can()`
/// consults so a `group_admin` on an ancestor is implicitly app_admin
/// on the app.
///
/// Default = direct membership only (no inheritance), so the many test
/// stubs that model no group tree keep their existing behavior; the
/// Postgres repo overrides this with an ancestor-walking CTE.
async fn effective_app_role(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
self.membership(user_id, app_id).await
}
/// Highest effective role on a *group* node — the group's own
/// ancestor walk over `group_members`. Gates the group-management
/// capabilities. Default = no grant; the Postgres repo overrides it.
async fn effective_group_role(
&self,
_user_id: UserId,
_group_id: GroupId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
/// Repo errors surface here so handlers can map them to 500 without
@@ -353,7 +453,28 @@ async fn role_grants(
match principal.instance_role {
InstanceRole::Owner => Ok(true),
InstanceRole::Admin => Ok(admin_grants(cap)),
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
InstanceRole::Member => match cap {
// Group-management caps resolve against the group ancestor
// 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::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
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
// the handler, which routes through the arm above.)
Capability::InstanceCreateGroup => Ok(false),
_ => member_grants(repo, principal.user_id, cap).await,
},
}
}
@@ -377,12 +498,54 @@ async fn member_grants(
let Some(app_id) = cap.app_id() else {
return Ok(false);
};
let Some(role) = repo.membership(user_id, app_id).await? else {
// Effective (inherited) role: the app's own membership folded with any
// ancestor group membership. A group_admin on an ancestor group is
// implicitly app_admin here.
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
return Ok(false);
};
Ok(role_satisfies(role, cap))
}
/// Member-path resolution for the group-management capabilities. Resolves
/// the caller's effective role on the group (ancestor walk over
/// `group_members`) and checks it covers the requested group action.
async fn group_member_grants(
repo: &dyn AuthzRepo,
user_id: UserId,
cap: Capability,
group_id: GroupId,
) -> Result<bool, AuthzError> {
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
return Ok(false);
};
Ok(group_role_satisfies(role, cap))
}
/// Does the effective group `AppRole` cover the group capability?
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
match cap {
// 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,
}
}
/// Does the per-app `AppRole` cover the capability? Viewer can read;
/// Editor adds script/route/log mutations; AppAdmin adds settings,
/// domain claims, and delete. Roles form a strict subset chain, so
@@ -397,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!(
@@ -412,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
@@ -471,16 +636,61 @@ mod tests {
use std::collections::HashMap;
use tokio::sync::Mutex;
/// In-memory `AuthzRepo` so the unit tests don't need a database.
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
/// direct app memberships PLUS a group tree (app→group, group→parent)
/// and group memberships, so the hierarchy-aware resolution can be
/// exercised without Postgres — mirroring the recursive-CTE behavior.
#[derive(Default)]
struct InMemoryAuthzRepo {
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
app_group: Mutex<HashMap<AppId, GroupId>>,
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
}
impl InMemoryAuthzRepo {
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
self.memberships.lock().await.insert((user, app), role);
}
/// Register a group node and its parent (`None` = root).
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
self.group_parent.lock().await.insert(group, parent);
}
/// Place an app under a group.
async fn put_app(&self, app: AppId, group: GroupId) {
self.app_group.lock().await.insert(app, group);
}
/// Grant a group-level role.
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
self.group_memberships
.lock()
.await
.insert((user, group), role);
}
/// Fold every ancestor group membership starting at `group`,
/// max-by-authority, into `acc`.
async fn fold_group_chain(
&self,
user_id: UserId,
mut group: Option<GroupId>,
mut acc: Option<AppRole>,
) -> Option<AppRole> {
let memberships = self.group_memberships.lock().await;
let parents = self.group_parent.lock().await;
let mut hops = 0u32;
while let Some(g) = group {
if let Some(r) = memberships.get(&(user_id, g)).copied() {
acc = Some(acc.map_or(r, |a| a.max(r)));
}
hops += 1;
if hops > 64 {
break;
}
group = parents.get(&g).copied().flatten();
}
acc
}
}
#[async_trait]
@@ -497,6 +707,29 @@ mod tests {
.get(&(user_id, app_id))
.copied())
}
async fn effective_app_role(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
let direct = self
.memberships
.lock()
.await
.get(&(user_id, app_id))
.copied();
let start = self.app_group.lock().await.get(&app_id).copied();
Ok(self.fold_group_chain(user_id, start, direct).await)
}
async fn effective_group_role(
&self,
user_id: UserId,
group_id: GroupId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
}
}
fn principal(role: InstanceRole) -> Principal {
@@ -857,12 +1090,183 @@ mod tests {
);
}
// ------------------------------------------------------------------
// Hierarchy-aware RBAC (Phase 2 groups)
// ------------------------------------------------------------------
#[tokio::test]
async fn group_admin_on_ancestor_is_implicit_app_admin() {
let repo = InMemoryAuthzRepo::default();
let acme = GroupId::new();
let app = AppId::new();
repo.add_group(acme, None).await;
repo.put_app(app, acme).await;
let p = principal(InstanceRole::Member);
// No app_members row — authority comes purely from the group.
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppAdmin(app),
] {
assert!(
can(&repo, &p, cap).await.unwrap().is_allow(),
"inherited group_admin denied {cap:?}"
);
}
}
#[tokio::test]
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
let repo = InMemoryAuthzRepo::default();
let acme = GroupId::new();
let app = AppId::new();
repo.add_group(acme, None).await;
repo.put_app(app, acme).await;
let p = principal(InstanceRole::Member);
// Direct viewer on the app, app_admin via the ancestor group:
// the higher (app_admin) wins.
repo.grant(p.user_id, app, AppRole::Viewer).await;
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
assert!(can(&repo, &p, Capability::AppAdmin(app))
.await
.unwrap()
.is_allow());
}
#[tokio::test]
async fn group_role_inherits_down_a_multi_level_tree() {
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
let app = AppId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
repo.put_app(app, team).await;
// Editor two levels up flows down to the app as editor.
let p = principal(InstanceRole::Member);
repo.grant_group(p.user_id, root, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
"editor must not get app_admin"
);
}
#[tokio::test]
async fn group_membership_grants_no_instance_capabilities() {
let repo = InMemoryAuthzRepo::default();
let acme = GroupId::new();
repo.add_group(acme, None).await;
let p = principal(InstanceRole::Member);
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceCreateGroup,
Capability::InstanceManageUsers,
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Deny,
"group_admin must not grant instance cap {cap:?}"
);
}
}
#[tokio::test]
async fn group_admin_walks_ancestors_for_group_caps() {
let repo = InMemoryAuthzRepo::default();
let root = GroupId::new();
let team = GroupId::new();
repo.add_group(root, None).await;
repo.add_group(team, Some(root)).await;
let p = principal(InstanceRole::Member);
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
// group_admin at root ⇒ admin of the descendant group.
assert!(can(&repo, &p, Capability::GroupAdmin(team))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::GroupWrite(team))
.await
.unwrap()
.is_allow());
// An unrelated member gets nothing.
let outsider = principal(InstanceRole::Member);
assert_eq!(
can(&repo, &outsider, Capability::GroupRead(team))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn admin_implicitly_manages_the_whole_group_tree() {
let repo = InMemoryAuthzRepo::default();
let g = GroupId::new();
let p = principal(InstanceRole::Admin);
for cap in [
Capability::InstanceCreateGroup,
Capability::GroupRead(g),
Capability::GroupWrite(g),
Capability::GroupAdmin(g),
] {
assert!(
can(&repo, &p, cap).await.unwrap().is_allow(),
"admin denied group cap {cap:?}"
);
}
}
#[tokio::test]
async fn bound_key_cannot_manage_groups() {
let repo = InMemoryAuthzRepo::default();
let g = GroupId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::AppAdmin]),
app_binding: Some(AppId::new()),
};
// Group caps carry no app_id, so a bound key is denied at the
// binding layer regardless of role/scope.
assert_eq!(
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
Decision::Deny
);
}
#[test]
fn app_role_max_is_authority_ordered() {
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
}
#[test]
fn capability_app_id_extraction() {
let app = AppId::new();
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
// Group caps are not app-scoped.
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
}
#[test]

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

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

View File

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

View File

@@ -0,0 +1,205 @@
//! CRUD over the `group_members` table — explicit per-(user, group) role
//! grants. A `group_admin` here is implicitly app_admin on every app and
//! subgroup beneath the group; that inheritance is resolved by the authz
//! ancestor walk (`app_members_repo::effective_app_role`), not here.
//!
//! Mirrors `app_members_repo` — same three role literals, same shapes.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppRole, GroupId, InstanceRole};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum GroupMembersRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("invalid app_role stored in DB: {0}")]
InvalidRole(String),
}
#[derive(Debug, Clone)]
pub struct GroupMembershipRow {
pub group_id: GroupId,
pub user_id: AdminUserId,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
/// `group_members` row joined with `admin_users` for the dashboard's
/// per-group Members tab.
#[derive(Debug, Clone)]
pub struct GroupMembershipDetail {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[async_trait]
pub trait GroupMembersRepository: Send + Sync {
/// Atomic insert. `None` if a membership already exists (handler → 409).
async fn try_insert(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
/// Atomic role update. `None` if no row exists (handler → 404).
async fn update_role(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
/// Remove a membership. No-op when the row doesn't exist.
async fn remove(
&self,
group_id: GroupId,
user_id: AdminUserId,
) -> Result<(), GroupMembersRepositoryError>;
/// Per-group member list joined with `admin_users`, ordered by username.
async fn list_for_group_enriched(
&self,
group_id: GroupId,
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError>;
}
pub struct PostgresGroupMembersRepository {
pool: PgPool,
}
impl PostgresGroupMembersRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl GroupMembersRepository for PostgresGroupMembersRepository {
async fn try_insert(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
let row = sqlx::query_as::<_, GroupMembershipRecord>(
"INSERT INTO group_members (group_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (group_id, user_id) DO NOTHING \
RETURNING group_id, user_id, role, created_at",
)
.bind(group_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn update_role(
&self,
group_id: GroupId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
let row = sqlx::query_as::<_, GroupMembershipRecord>(
"UPDATE group_members SET role = $1 \
WHERE group_id = $2 AND user_id = $3 \
RETURNING group_id, user_id, role, created_at",
)
.bind(role.as_str())
.bind(group_id.into_inner())
.bind(user_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn remove(
&self,
group_id: GroupId,
user_id: AdminUserId,
) -> Result<(), GroupMembersRepositoryError> {
sqlx::query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2")
.bind(group_id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_for_group_enriched(
&self,
group_id: GroupId,
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError> {
let rows = sqlx::query_as::<_, GroupMembershipDetailRecord>(
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
gm.role, gm.created_at \
FROM group_members gm \
JOIN admin_users au ON au.id = gm.user_id \
WHERE gm.group_id = $1 \
ORDER BY au.username",
)
.bind(group_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
#[derive(sqlx::FromRow)]
struct GroupMembershipRecord {
group_id: uuid::Uuid,
user_id: uuid::Uuid,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<GroupMembershipRecord> for GroupMembershipRow {
type Error = GroupMembersRepositoryError;
fn try_from(r: GroupMembershipRecord) -> Result<Self, Self::Error> {
Ok(Self {
group_id: r.group_id.into(),
user_id: r.user_id.into(),
role: AppRole::from_db_str(&r.role)
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct GroupMembershipDetailRecord {
id: uuid::Uuid,
username: String,
email: Option<String>,
instance_role: String,
is_active: bool,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<GroupMembershipDetailRecord> for GroupMembershipDetail {
type Error = GroupMembersRepositoryError;
fn try_from(r: GroupMembershipDetailRecord) -> Result<Self, Self::Error> {
Ok(Self {
user_id: r.id.into(),
username: r.username,
email: r.email,
instance_role: InstanceRole::from_db_str(&r.instance_role)
.ok_or(GroupMembersRepositoryError::InvalidRole(r.instance_role))?,
is_active: r.is_active,
role: AppRole::from_db_str(&r.role)
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}

View File

@@ -0,0 +1,367 @@
//! CRUD over the `groups` tree (Phase 2).
//!
//! Groups form a single-parent org tree above apps. Structural mutations
//! (reparent/rename/delete) must keep the tree acyclic and non-orphaning:
//!
//! - **delete = RESTRICT** — refused if the group has child groups or apps
//! (the DB FKs enforce this; we surface a clean conflict).
//! - **slug-freeze** — `rename` edits name/description only; the slug is
//! set once at creation and never rewritten.
//! - **cycle guard** — `reparent` walks the destination's ancestors under a
//! coarse instance-wide advisory lock and refuses a move that would make
//! a node its own ancestor. A SQL `CHECK` can't express this.
//! - **structure_version** — bumped on every structural mutation so a
//! future CLI/orchestrator can detect structural drift (§6).
use async_trait::async_trait;
use picloud_shared::{Group, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
/// Instance-wide advisory-lock key for structural group mutations. Coarse
/// on purpose: reparent/rename/delete all take it so the ancestor-walk
/// cycle guard and the `parent_id` write run serialized — two concurrent
/// reparents can't race into a cycle. Distinct from the per-app
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
/// Well-known slug of the instance root group seeded by migration 0047.
pub const ROOT_GROUP_SLUG: &str = "root";
#[derive(Debug, thiserror::Error)]
pub enum GroupRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(GroupId),
#[error("conflict: {0}")]
Conflict(String),
}
/// Counts of a group's direct children — used to enforce delete=RESTRICT
/// with an actionable message and to render the tree.
#[derive(Debug, Clone, Copy)]
pub struct GroupChildCounts {
pub subgroups: i64,
pub apps: i64,
}
impl GroupChildCounts {
#[must_use]
pub fn is_empty(self) -> bool {
self.subgroups == 0 && self.apps == 0
}
}
#[async_trait]
pub trait GroupRepository: Send + Sync {
/// Every group on the instance, ordered by name. The tree is small
/// (org structure), so callers assemble the hierarchy in memory.
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
/// Direct children groups of `parent`.
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
/// The node plus its ancestors up to the root, nearest-first. Used for
/// path display and as the reparent cycle-guard input.
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError>;
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError>;
/// Edit display fields only — the slug is frozen at creation. Bumps
/// `structure_version`.
async fn rename(
&self,
id: GroupId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<Group, GroupRepositoryError>;
/// Move `id` under `new_parent` (or to root if `None`). Runs the
/// ancestor-walk cycle guard under a coarse structural lock and bumps
/// `structure_version`. Refuses a move that would create a cycle.
async fn reparent(
&self,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError>;
/// Delete an empty group (delete = RESTRICT). Refused with a clean
/// conflict if it still has child groups or apps.
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError>;
}
pub struct PostgresGroupRepository {
pool: PgPool,
}
impl PostgresGroupRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const GROUP_COLS: &str =
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
#[async_trait]
impl GroupRepository for PostgresGroupRepository {
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError> {
let rows = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups ORDER BY name"
))
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
let row = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE id = $1"
))
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError> {
let row = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE slug = $1"
))
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
let rows = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE parent_id = $1 ORDER BY name"
))
.bind(parent.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
// Recursive walk node → root, nearest-first. Depth-bounded as a
// runaway guard (the cycle guard already prevents cycles).
let rows = sqlx::query_as::<_, GroupRow>(&format!(
"WITH RECURSIVE chain AS (
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
UNION ALL
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
FROM groups g JOIN chain c ON g.id = c.parent_id \
WHERE c.depth < 64
)
SELECT {GROUP_COLS} FROM chain ORDER BY depth"
))
.bind(id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError> {
let row: (i64, i64) = sqlx::query_as(
"SELECT \
(SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
)
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(GroupChildCounts {
subgroups: row.0,
apps: row.1,
})
}
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id) \
VALUES ($1, $2, $3, $4) \
RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
}
async fn rename(
&self,
id: GroupId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<Group, GroupRepositoryError> {
// Slug is intentionally absent from the SET list — it is frozen.
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(name)
.bind(description.is_some())
.bind(description.and_then(|d| d))
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
async fn reparent(
&self,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
// Coarse structural lock: serialize all structural mutations so the
// cycle guard + parent write can't interleave with a concurrent
// reparent and race into a cycle.
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut *tx)
.await?;
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we
// reach `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut *tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
// Destination parent doesn't exist.
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(GroupRepositoryError::NotFound(id));
};
tx.commit().await?;
Ok(row.into())
}
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
// Pre-check for a clean message; the FK RESTRICT is the real guard.
let counts = self.child_counts(id).await?;
if !counts.is_empty() {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.subgroups, counts.apps
)));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
// Lost a race with a concurrent child insert.
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
}
Err(e) => Err(e.into()),
}
}
}
#[derive(sqlx::FromRow)]
struct GroupRow {
id: Uuid,
parent_id: Option<Uuid>,
slug: String,
name: String,
description: Option<String>,
structure_version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<GroupRow> for Group {
fn from(r: GroupRow) -> Self {
Self {
id: r.id.into(),
parent_id: r.parent_id.map(Into::into),
slug: r.slug,
name: r.name,
description: r.description,
structure_version: r.structure_version,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

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

View File

@@ -0,0 +1,577 @@
//! `/api/v1/admin/groups/*` — CRUD over the group tree + per-group
//! membership (Phase 2, blueprint §5).
//!
//! Group capabilities resolve by walking the group's ancestor chain
//! (`authz::effective_group_role`): a `group_admin` on any ancestor is
//! implicitly admin of every descendant group. Structural mutations
//! (reparent/delete) are gated on `GroupAdmin`; reparent additionally
//! requires admin at BOTH the source and destination parent (§5.6).
//!
//! Slug is frozen at creation — PATCH edits name/description only.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, patch, post};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, App, AppRole, Group, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_members_repo::{
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
};
use crate::group_repo::{GroupRepository, GroupRepositoryError};
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", "groups",
];
#[derive(Clone)]
pub struct GroupsState {
pub groups: Arc<dyn GroupRepository>,
pub group_members: Arc<dyn GroupMembersRepository>,
pub apps: Arc<dyn AppRepository>,
pub users: Arc<dyn AdminUserRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn groups_router(state: GroupsState) -> Router {
Router::new()
.route("/groups", get(list_groups).post(create_group))
.route(
"/groups/{id_or_slug}",
get(get_group).patch(patch_group).delete(delete_group),
)
.route("/groups/{id_or_slug}/reparent", post(reparent_group))
.route(
"/groups/{id_or_slug}/members",
get(list_members).post(grant_member),
)
.route(
"/groups/{id_or_slug}/members/{user_id}",
patch(patch_member).delete(remove_member),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct GroupDetailDto {
#[serde(flatten)]
pub group: Group,
/// Root → … → this group (nearest-last), for breadcrumb display.
pub path: Vec<Group>,
pub subgroups: Vec<Group>,
pub apps: Vec<App>,
}
#[derive(Debug, Deserialize)]
pub struct CreateGroupRequest {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Parent group (slug or id). Omitted ⇒ a root-level group
/// (owner/admin only).
#[serde(default)]
pub parent: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PatchGroupRequest {
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ReparentRequest {
/// New parent (slug or id). Omitted/null ⇒ move to root.
#[serde(default)]
pub parent: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GroupMemberDto {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
impl From<GroupMembershipDetail> for GroupMemberDto {
fn from(d: GroupMembershipDetail) -> Self {
Self {
user_id: d.user_id,
username: d.username,
email: d.email,
instance_role: d.instance_role,
is_active: d.is_active,
role: d.role,
created_at: d.created_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct GrantMemberRequest {
pub user_id: AdminUserId,
pub role: AppRole,
}
#[derive(Debug, Deserialize)]
pub struct PatchMemberRequest {
pub role: AppRole,
}
// ----------------------------------------------------------------------------
// Group CRUD handlers
// ----------------------------------------------------------------------------
/// List the whole tree. Phase-2 simplification: group names/structure are
/// low-sensitivity org metadata (groups own no resources/secrets yet), so
/// any authenticated admin sees the full tree; per-action authz still
/// gates every mutation and all app access. Tighten in Phase 3 when groups
/// carry inheritable config.
async fn list_groups(
State(s): State<GroupsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<Group>>, GroupsApiError> {
Ok(Json(s.groups.list().await?))
}
async fn create_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateGroupRequest>,
) -> Result<(StatusCode, Json<Group>), GroupsApiError> {
validate_slug(&input.slug)?;
let parent_id = match input.parent.as_deref() {
// Root-level group — an instance act.
None => {
require(
s.authz.as_ref(),
&principal,
Capability::InstanceCreateGroup,
)
.await?;
None
}
// Subgroup — requires group-admin at the parent.
Some(ident) => {
let parent = resolve_group(&*s.groups, ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(parent.id),
)
.await?;
Some(parent.id)
}
};
let created = s
.groups
.create(
&input.slug,
&input.name,
input.description.as_deref(),
parent_id,
)
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn get_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<GroupDetailDto>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupRead(group.id),
)
.await?;
// ancestors() is nearest-first incl. the node; reverse for a
// root→…→node breadcrumb.
let mut path = s.groups.ancestors(group.id).await?;
path.reverse();
let subgroups = s.groups.list_children(group.id).await?;
let apps = s.apps.list_for_group(group.id).await?;
Ok(Json(GroupDetailDto {
group,
path,
subgroups,
apps,
}))
}
async fn patch_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<PatchGroupRequest>,
) -> Result<Json<Group>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupWrite(group.id),
)
.await?;
let updated = s
.groups
.rename(
group.id,
input.name.as_deref(),
input.description.as_ref().map(|d| Some(d.as_str())),
)
.await?;
Ok(Json(updated))
}
async fn reparent_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<ReparentRequest>,
) -> Result<Json<Group>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
// Admin of the node being moved.
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
// Admin at the SOURCE parent (you're removing it from that domain).
if let Some(src) = group.parent_id {
require(s.authz.as_ref(), &principal, Capability::GroupAdmin(src)).await?;
}
// Resolve + require admin at the DESTINATION parent.
let new_parent = match input.parent.as_deref() {
None => None,
Some(ident) => {
let dest = resolve_group(&*s.groups, ident).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(dest.id),
)
.await?;
Some(dest.id)
}
};
let moved = s.groups.reparent(group.id, new_parent).await?;
Ok(Json(moved))
}
async fn delete_group(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<StatusCode, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
s.groups.delete(group.id).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Member handlers — gated on GroupAdmin(group)
// ----------------------------------------------------------------------------
async fn list_members(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<GroupMemberDto>>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
let rows = s.group_members.list_for_group_enriched(group.id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn grant_member(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<GroupMemberDto>), GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
let user = s
.users
.get(input.user_id)
.await?
.ok_or(GroupsApiError::UserNotFound(input.user_id))?;
validate_grant_target(&user)?;
let row = s
.group_members
.try_insert(group.id, user.id, input.role)
.await?
.ok_or_else(|| GroupsApiError::AlreadyMember {
username: user.username.clone(),
})?;
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}
async fn patch_member(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
Json(input): Json<PatchMemberRequest>,
) -> Result<Json<GroupMemberDto>, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
let user_id = AdminUserId::from(user_id);
let user = s
.users
.get(user_id)
.await?
.ok_or(GroupsApiError::UserNotFound(user_id))?;
let row = s
.group_members
.update_role(group.id, user_id, input.role)
.await?
.ok_or(GroupsApiError::MembershipNotFound)?;
Ok(Json(compose_dto(user, row)))
}
async fn remove_member(
State(s): State<GroupsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, GroupsApiError> {
let group = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupAdmin(group.id),
)
.await?;
s.group_members
.remove(group.id, AdminUserId::from(user_id))
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation + helpers
// ----------------------------------------------------------------------------
/// Resolve a group identifier (slug or UUID) to a `Group`.
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Group, GroupsApiError> {
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
groups.get_by_id(uuid.into()).await?
} else {
groups.get_by_slug(ident).await?
};
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
}
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
if slug.is_empty() || slug.len() > SLUG_MAX {
return Err(invalid("must be 163 characters"));
}
let mut chars = slug.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(invalid("must start with a lowercase letter or digit"));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(invalid(
"may contain only lowercase letters, digits, and hyphens",
));
}
if RESERVED_SLUGS.contains(&slug) {
return Err(invalid("is a reserved word"));
}
Ok(())
}
fn validate_grant_target(user: &AdminUserRow) -> Result<(), GroupsApiError> {
if !user.is_active {
return Err(GroupsApiError::TargetInactive {
username: user.username.clone(),
});
}
if user.instance_role != InstanceRole::Member {
return Err(GroupsApiError::TargetNotMember {
username: user.username.clone(),
instance_role: user.instance_role,
});
}
Ok(())
}
fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
GroupMemberDto {
user_id: user.id,
username: user.username,
email: user.email,
instance_role: user.instance_role,
is_active: user.is_active,
role: membership.role,
created_at: membership.created_at,
}
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum GroupsApiError {
#[error("group not found: {0}")]
GroupNotFound(String),
#[error("user not found: {0}")]
UserNotFound(AdminUserId),
#[error("{username} is already a member of this group")]
AlreadyMember { username: String },
#[error("membership not found")]
MembershipNotFound,
#[error("{username} is deactivated")]
TargetInactive { username: String },
#[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
TargetNotMember {
username: String,
instance_role: InstanceRole,
},
#[error("invalid slug: {0}")]
InvalidSlug(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("group repository error: {0}")]
Repo(#[from] GroupRepositoryError),
#[error("member repository error: {0}")]
MembersRepo(#[from] GroupMembersRepositoryError),
#[error("user repository error: {0}")]
UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
#[error("app repository error: {0}")]
AppsRepo(#[from] crate::repo::ScriptRepositoryError),
}
impl From<AuthzDenied> for GroupsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for GroupsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for GroupsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::GroupNotFound(_)
| Self::UserNotFound(_)
| Self::MembershipNotFound
| Self::Repo(GroupRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::InvalidSlug(_) | Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::AlreadyMember { .. } | Self::Conflict(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Repo(GroupRepositoryError::Conflict(msg)) => {
(StatusCode::CONFLICT, json!({ "error": msg }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "groups authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Repo(GroupRepositoryError::Db(e)) => {
tracing::error!(error = %e, "groups api db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::MembersRepo(e) => {
tracing::error!(error = %e, "group members repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::UsersRepo(e) => {
tracing::error!(error = %e, "groups api user repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::AppsRepo(e) => {
tracing::error!(error = %e, "groups api app repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

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

View File

@@ -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;
@@ -47,6 +49,10 @@ pub mod files_repo;
pub mod files_service;
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;
pub mod kv_api;
@@ -81,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,
@@ -143,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,
@@ -165,6 +175,16 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
pub use files_service::FilesServiceImpl;
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
pub use group_members_repo::{
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
PostgresGroupMembersRepository,
};
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};
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
@@ -188,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};
@@ -207,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;

View File

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

View File

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

View File

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

View File

@@ -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 163 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(_) => (

View File

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

View File

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

View File

@@ -383,6 +383,7 @@ mod tests {
slug: self.slug.clone(),
name: "test".into(),
description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now,
updated_at: now,
}
@@ -396,6 +397,12 @@ mod tests {
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_: picloud_shared::GroupId,
) -> Result<Vec<App>, ScriptRepositoryError> {
unimplemented!()
}
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
if id != self.id {
return Ok(None);
@@ -436,6 +443,7 @@ mod tests {
_: &str,
_: &str,
_: Option<&str>,
_: picloud_shared::GroupId,
) -> Result<App, ScriptRepositoryError> {
unimplemented!()
}
@@ -444,6 +452,7 @@ mod tests {
_: &str,
_: &str,
_: Option<&str>,
_: picloud_shared::GroupId,
) -> Result<App, ScriptRepositoryError> {
unimplemented!()
}

View File

@@ -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"
)));
@@ -1209,6 +1219,7 @@ mod tests {
slug: "test".into(),
name: "test".into(),
description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now,
updated_at: now,
},
@@ -1226,6 +1237,7 @@ mod tests {
_slug: &str,
_name: &str,
_description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
@@ -1234,6 +1246,7 @@ mod tests {
_slug: &str,
_name: &str,
_description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
@@ -1252,6 +1265,12 @@ mod tests {
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn list_for_group(
&self,
_group_id: picloud_shared::GroupId,
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
async fn get_by_id(
&self,
id: AppId,
@@ -1339,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 {
@@ -1384,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> {
@@ -1398,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,
@@ -1725,6 +1771,7 @@ mod tests {
slug: "a".into(),
name: "a".into(),
description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now,
updated_at: now,
},
@@ -1736,6 +1783,7 @@ mod tests {
slug: "b".into(),
name: "b".into(),
description: None,
group_id: picloud_shared::GroupId::new(),
created_at: now,
updated_at: now,
},

View 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 1128 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 163 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()
}
}

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

View 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
}
}

View File

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

View File

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

View File

@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use picloud_shared::{
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
ScriptSandbox,
};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
@@ -149,6 +150,144 @@ impl Client {
decode_status(resp).await
}
// --- Groups (Phase 2) -------------------------------------------------
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
/// client-side from `parent_id`).
pub async fn groups_list(&self) -> Result<Vec<Group>> {
let resp = self
.request(Method::GET, "/api/v1/admin/groups")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
let ident = seg(ident);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups`
pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result<Group> {
let resp = self
.request(Method::POST, "/api/v1/admin/groups")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only
/// (the slug is frozen).
pub async fn groups_rename(
&self,
ident: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<Group> {
let ident = seg(ident);
let body = serde_json::json!({ "name": name, "description": description });
let resp = self
.request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a
/// slug/id, or `None` to move to root.
pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result<Group> {
let ident = seg(ident);
let body = serde_json::json!({ "parent": parent });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/groups/{ident}/reparent"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty.
pub async fn groups_delete(&self, ident: &str) -> Result<()> {
let ident = seg(ident);
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}"))
.send()
.await?;
decode_status(resp).await
}
pub async fn group_members_list(&self, group: &str) -> Result<Vec<AppMemberDto>> {
let group = seg(group);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/groups/{group}/members"),
)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_grant(
&self,
group: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let group = seg(group);
let body = serde_json::json!({ "user_id": user_id, "role": role });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/groups/{group}/members"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_set_role(
&self,
group: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let (group, user_id) = (seg(group), seg(user_id));
let body = serde_json::json!({ "role": role });
let resp = self
.request(
Method::PATCH,
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> {
let (group, user_id) = (seg(group), seg(user_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
)
.send()
.await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
@@ -170,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
@@ -518,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 ----------
@@ -903,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?;
@@ -941,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
@@ -970,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.
@@ -983,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)]
@@ -997,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 {
@@ -1017,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>,
}
@@ -1049,6 +1418,31 @@ pub struct CreateAppBody<'a> {
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
/// Parent group (slug or id); omit for the instance root.
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<&'a str>,
}
#[derive(Debug, Serialize)]
pub struct CreateGroupBody<'a> {
pub slug: &'a str,
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
/// Parent group (slug or id); omit for a root-level group.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<&'a str>,
}
/// `GET /groups/{id}` response — the group plus its breadcrumb path and
/// direct children.
#[derive(Debug, Deserialize)]
pub struct GroupDetailDto {
#[serde(flatten)]
pub group: Group,
pub path: Vec<Group>,
pub subgroups: Vec<Group>,
pub apps: Vec<App>,
}
#[derive(Debug, Serialize)]
@@ -1230,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>,
@@ -1241,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).
@@ -1272,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,

View File

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

View File

@@ -31,6 +31,7 @@ pub async fn create(
slug: &str,
name: Option<&str>,
description: Option<&str>,
group: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
@@ -39,6 +40,7 @@ pub async fn create(
slug,
name: name.unwrap_or(slug),
description,
group,
};
let app = client.apps_create(&body).await?;
// Emit the created object so `--output json` callers can capture the

View File

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

View File

@@ -0,0 +1,257 @@
//! `pic groups` — manage the org-tree groups (Phase 2).
//!
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
//! server-side: create/reparent/delete need group-admin (reparent at both
//! source and destination parent); the slug is frozen at creation.
use std::collections::BTreeMap;
use anyhow::Result;
use picloud_shared::{AppRole, Group};
use crate::client::{Client, CreateGroupBody};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let groups = client.groups_list().await?;
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
for g in &groups {
let parent = g
.parent_id
.and_then(|p| by_id.get(&p).cloned())
.unwrap_or_else(|| "-".into());
table.row([
g.slug.clone(),
g.name.clone(),
parent,
g.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
/// `pic groups tree` — render the hierarchy as an indented tree (text
/// mode); falls back to the flat list for `--output json`.
pub async fn tree(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let groups = client.groups_list().await?;
if matches!(mode, OutputMode::Json) {
// Machine consumers get the flat list; the shape carries parent_id.
println!("{}", serde_json::to_string_pretty(&groups)?);
return Ok(());
}
// children-by-parent, then DFS from the roots.
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
for g in &groups {
children.entry(g.parent_id).or_default().push(g);
}
for kids in children.values_mut() {
kids.sort_by(|a, b| a.name.cmp(&b.name));
}
print_subtree(&children, None, 0);
Ok(())
}
fn print_subtree(
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
parent: Option<picloud_shared::GroupId>,
depth: usize,
) {
let Some(kids) = children.get(&parent) else {
return;
};
for g in kids {
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
print_subtree(children, Some(g.id), depth + 1);
}
}
pub async fn create(
slug: &str,
name: Option<&str>,
description: Option<&str>,
parent: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let body = CreateGroupBody {
slug,
name: name.unwrap_or(slug),
description,
parent,
};
let group = client.groups_create(&body).await?;
print_group(&group, mode);
Ok(())
}
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let detail = client.groups_get(ident).await?;
let path = detail
.path
.iter()
.map(|g| g.slug.as_str())
.collect::<Vec<_>>()
.join(" / ");
let mut block = KvBlock::new();
block
.field("id", detail.group.id.to_string())
.field("slug", detail.group.slug.clone())
.field("name", detail.group.name.clone())
.field("path", if path.is_empty() { "-".into() } else { path })
.field(
"subgroups",
detail
.subgroups
.iter()
.map(|g| g.slug.as_str())
.collect::<Vec<_>>()
.join(", "),
)
.field(
"apps",
detail
.apps
.iter()
.map(|a| a.slug.as_str())
.collect::<Vec<_>>()
.join(", "),
);
block.print(mode);
Ok(())
}
pub async fn rename(
ident: &str,
name: Option<&str>,
description: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let group = client.groups_rename(ident, name, description).await?;
print_group(&group, mode);
Ok(())
}
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let group = client.groups_reparent(ident, to).await?;
print_group(&group, mode);
Ok(())
}
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
/// non-empty group); `--recursive` expands the delete into ordered,
/// leaf-first child deletions (groups + apps) the operator opted into.
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
if !recursive {
client.groups_delete(ident).await?;
println!("Deleted group {ident}");
return Ok(());
}
// Recursive: delete the subtree leaf-first so each DB delete sees an
// empty node (the FK stays RESTRICT — we never cascade implicitly).
let detail = client.groups_get(ident).await?;
if let Some(app) = detail.apps.first() {
anyhow::bail!(
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
(apps are never auto-deleted)",
app.slug
);
}
for sub in &detail.subgroups {
Box::pin(rm(&sub.slug, true)).await?;
}
client.groups_delete(ident).await?;
println!("Deleted group {ident}");
Ok(())
}
// --- members ---------------------------------------------------------------
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let members = client.group_members_list(group).await?;
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
for m in members {
table.row([
m.user_id.to_string(),
m.username,
m.role.as_str().to_string(),
format!("{:?}", m.instance_role).to_lowercase(),
m.is_active.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.group_members_grant(group, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.group_members_set_role(group, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.group_members_remove(group, user_id).await?;
println!("Removed {user_id} from {group}");
Ok(())
}
fn print_group(g: &Group, mode: OutputMode) {
let mut block = KvBlock::new();
block
.field("id", g.id.to_string())
.field("slug", g.slug.clone())
.field("name", g.name.clone())
.field(
"parent_id",
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
)
.field("created_at", g.created_at.to_rfc3339());
block.print(mode);
}
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
let mut block = KvBlock::new();
block
.field("user_id", m.user_id.to_string())
.field("username", m.username.clone())
.field("role", m.role.as_str().to_string());
block.print(mode);
}
fn parse_role(role: &str) -> Result<AppRole> {
AppRole::from_db_str(role)
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
}

View File

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

View File

@@ -6,6 +6,7 @@ pub mod apps_domains;
pub mod config;
pub mod dead_letters;
pub mod files;
pub mod groups;
pub mod init;
pub mod kv;
pub mod login;
@@ -21,4 +22,5 @@ pub mod secrets;
pub mod topics;
pub mod triggers;
pub mod users;
pub mod vars;
pub mod whoami;

View File

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

View File

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

View File

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

View File

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

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

View 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))
}

View File

@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
mod client;
mod cmds;
mod config;
mod discover;
mod linkstate;
mod manifest;
mod output;
@@ -51,6 +52,12 @@ enum Cmd {
cmd: AppsCmd,
},
/// Group (org-tree) management.
Groups {
#[command(subcommand)]
cmd: GroupsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
@@ -138,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 {
@@ -188,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)]
@@ -211,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)]
@@ -253,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)]
@@ -401,6 +429,9 @@ enum AppsCmd {
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Parent group (slug or id). Defaults to the instance root.
#[arg(long)]
group: Option<String>,
},
/// Show a single app, including the caller's role in it.
@@ -423,6 +454,75 @@ enum AppsCmd {
},
}
#[derive(Subcommand)]
enum GroupsCmd {
/// List all groups (flat).
Ls,
/// Render the group hierarchy as an indented tree.
Tree,
/// Create a new group. Omit `--parent` for a root-level group.
Create {
slug: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Parent group (slug or id).
#[arg(long)]
parent: Option<String>,
},
/// Show a group with its path, subgroups, and apps.
Show { ident: String },
/// Rename a group (name/description only — the slug is frozen).
Rename {
ident: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Move a group under a new parent (`--to` slug/id, or omit for root).
Reparent {
ident: String,
#[arg(long)]
to: Option<String>,
},
/// Delete a group. Refused (409) if non-empty unless `--recursive`,
/// which deletes child groups leaf-first (apps are never auto-deleted).
Rm {
ident: String,
#[arg(long)]
recursive: bool,
},
/// Manage a group's members (inherited down the tree).
Members {
#[command(subcommand)]
cmd: GroupMembersCmd,
},
}
#[derive(Subcommand)]
enum GroupMembersCmd {
/// List a group's members.
Ls { group: String },
/// Grant a member a role on the group.
Add {
group: String,
user_id: String,
#[arg(long, default_value = "viewer")]
role: String,
},
/// Change a member's role.
Set {
group: String,
user_id: String,
#[arg(long)]
role: String,
},
/// Remove a member from the group.
Rm { group: String, user_id: String },
}
#[derive(Subcommand)]
enum DomainsCmd {
/// List the app's domain claims.
@@ -439,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`,
@@ -461,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)]
@@ -1056,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>,
},
}
@@ -1146,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(),
@@ -1176,8 +1392,18 @@ async fn main() -> ExitCode {
slug,
name,
description,
group,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await,
} => {
cmds::apps::create(
&slug,
name.as_deref(),
description.as_deref(),
group.as_deref(),
mode,
)
.await
}
Cmd::Apps {
cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await,
@@ -1201,16 +1427,90 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id },
},
} => cmds::apps_domains::rm(&app, &domain_id).await,
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
Cmd::Groups {
cmd: GroupsCmd::Tree,
} => cmds::groups::tree(mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Create {
slug,
name,
description,
parent,
},
} => {
cmds::groups::create(
&slug,
name.as_deref(),
description.as_deref(),
parent.as_deref(),
mode,
)
.await
}
Cmd::Groups {
cmd: GroupsCmd::Show { ident },
} => cmds::groups::show(&ident, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Rename {
ident,
name,
description,
},
} => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await,
Cmd::Groups {
cmd: GroupsCmd::Reparent { ident, to },
} => cmds::groups::reparent(&ident, to.as_deref(), mode).await,
Cmd::Groups {
cmd: GroupsCmd::Rm { ident, recursive },
} => cmds::groups::rm(&ident, recursive).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd: GroupMembersCmd::Ls { group },
},
} => cmds::groups::members_ls(&group, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd:
GroupMembersCmd::Add {
group,
user_id,
role,
},
},
} => cmds::groups::members_add(&group, &user_id, &role, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd:
GroupMembersCmd::Set {
group,
user_id,
role,
},
},
} => cmds::groups::members_set(&group, &user_id, &role, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd: GroupMembersCmd::Rm { group, user_id },
},
} => 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,
@@ -1253,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,
@@ -1580,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,
@@ -1600,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 {

View File

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

View File

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

View File

@@ -23,6 +23,10 @@ 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;
mod logs;
@@ -35,4 +39,6 @@ mod routes;
mod scripts;
mod secrets;
mod staleness;
mod tree;
mod triggers;
mod vars;

View File

@@ -38,12 +38,71 @@ 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,
user_id: String,
}
/// Deletes a group on drop (best-effort). The group must be empty by then
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
/// child drops (deletes) first, leaving an empty node here.
pub struct GroupGuard {
url: String,
token: String,
slug: String,
}
impl GroupGuard {
pub fn new(url: &str, token: &str, slug: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
slug: slug.to_string(),
}
}
}
impl Drop for GroupGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
.bearer_auth(&self.token)
.send();
}
}
impl UserGuard {
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
Self {

View 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")

View 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")

View 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")
}

View 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")
}

View 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")
}

View File

@@ -0,0 +1,179 @@
//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT,
//! reparent cycle rejection, and the headline invariant — a `group_admin`
//! on an ancestor group can act on an app it is NOT a direct member of
//! (inherited membership), and loses that access the instant the group
//! grant is revoked.
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_tree_create_show_and_delete_restrict() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("g-acme");
let team = common::unique_slug("g-team");
let app = common::unique_slug("g-app");
// Root-level group, then a subgroup under it.
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
let _g_team = GroupGuard::new(&env.url, &env.token, &team);
common::pic_as(&env)
.args(["groups", "create", &team, "--parent", &acme])
.assert()
.success();
// An app under the subgroup.
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &team])
.assert()
.success();
// `groups show team` lists the app.
let out = String::from_utf8(
common::pic_as(&env)
.args(["groups", "show", &team])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
out.contains(&app),
"group detail should list its app:\n{out}"
);
// delete=RESTRICT: acme has a subgroup → refused.
common::pic_as(&env)
.args(["groups", "rm", &acme])
.assert()
.failure()
.stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn reparent_into_own_descendant_is_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("g-cyc-p");
let child = common::unique_slug("g-cyc-c");
let _g_parent = GroupGuard::new(&env.url, &env.token, &parent);
common::pic_as(&env)
.args(["groups", "create", &parent])
.assert()
.success();
let _g_child = GroupGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["groups", "create", &child, "--parent", &parent])
.assert()
.success();
// Moving the parent under its own child would form a cycle → refused.
common::pic_as(&env)
.args(["groups", "reparent", &parent, "--to", &child])
.assert()
.failure()
.stderr(predicate::str::contains("409").or(predicate::str::contains("descendant")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn inherited_group_admin_can_deploy_then_revoke() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("g-inh");
let app = common::unique_slug("g-inh-app");
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
// A fresh Member with NO app membership.
let m = member::member_user(fx, &common::unique_username("inh"));
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let fixture = common::fixture_path("hello.rhai");
// Baseline: without any grant, deploy is forbidden.
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 403"));
// Grant group_admin on the ANCESTOR group (no app_members row).
common::pic_as(&env)
.args([
"groups",
"members",
"add",
&acme,
&m.id,
"--role",
"app_admin",
])
.assert()
.success();
// Inherited: the member can now deploy to the app it never joined.
// (Deploy prints a KvBlock — assert on the script name + create action,
// not a prose string.)
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.success()
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
// Revoke the group grant → access drops immediately (no cache lag).
common::pic_as(&env)
.args(["groups", "members", "rm", &acme, &m.id])
.assert()
.success();
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 403"));
}

View File

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

View File

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

View File

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

View File

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

View 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")
}

View 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")
}

View File

@@ -12,26 +12,29 @@ use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
dev_emails_router, email_inbound_router, files_admin_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, 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, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
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, 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, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
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};
@@ -109,6 +112,10 @@ pub async fn build_app(
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let groups_repo: Arc<dyn GroupRepository> =
Arc::new(PostgresGroupRepository::new(pool.clone()));
let group_members_repo: Arc<dyn GroupMembersRepository> =
Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// The Postgres app_members repo implements both `AppMembersRepository`
@@ -316,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,
@@ -330,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).
@@ -461,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(),
@@ -503,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,
@@ -513,6 +527,7 @@ pub async fn build_app(
routes: route_repo,
domain_table: app_domain_table.clone(),
authz: authz.clone(),
groups: groups_repo.clone(),
};
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
@@ -552,6 +567,31 @@ pub async fn build_app(
members,
authz: authz.clone(),
};
let groups_state = GroupsState {
groups: groups_repo.clone(),
group_members: group_members_repo.clone(),
apps: apps_state.apps.clone(),
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(),
@@ -574,6 +614,12 @@ pub async fn build_app(
.merge(admins_router(admins_state))
.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,
))

View File

@@ -9,7 +9,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::AppId;
use crate::{AppId, GroupId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct App {
@@ -20,6 +20,9 @@ pub struct App {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Parent group in the org tree (Phase 2). Every app has a parent
/// from day one (§9 backfill seeds the instance root group).
pub group_id: GroupId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -98,6 +98,30 @@ impl AppRole {
_ => None,
}
}
/// Authority rank: higher = more authority. Used to fold the highest
/// effective role across an app's own membership and every ancestor
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
/// than via a derived `Ord` because the variant declaration order
/// (`AppAdmin` first) is the reverse of authority order.
#[must_use]
pub const fn precedence(self) -> u8 {
match self {
Self::AppAdmin => 3,
Self::Editor => 2,
Self::Viewer => 1,
}
}
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
#[must_use]
pub fn max(self, other: Self) -> Self {
if self.precedence() >= other.precedence() {
self
} else {
other
}
}
}
/// API-key scope. Exactly seven values; new scopes need a blueprint

View File

@@ -0,0 +1,31 @@
//! Groups: a GitLab-like, single-parent org tree ABOVE apps (Phase 2).
//!
//! Groups are a pure org / RBAC / UI container — they own no resources
//! (scripts/secrets stay app-owned). A `group_admin` on any ancestor is
//! implicitly app_admin on every app/subgroup beneath it; resolution
//! takes the highest effective role across the app's own membership and
//! all ancestor group memberships.
//!
//! See docs/design/groups-and-project-tool.md §5.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::GroupId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub id: GroupId,
/// `None` for a root node. Single-parent keeps the tree acyclic.
pub parent_id: Option<GroupId>,
/// Instance-global identifier, frozen at creation (a rename/reparent
/// never rewrites it — the deployment key stays stable).
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Per-subtree structure version, bumped on every structural mutation
/// (reparent/rename/delete). Not an authz input.
pub structure_version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -57,3 +57,4 @@ id_type!(TriggerId);
id_type!(AppUserId);
id_type!(InvitationId);
id_type!(QueueMessageId);
id_type!(GroupId);

View File

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

View File

@@ -15,6 +15,7 @@ pub mod events;
pub mod exec_summary;
pub mod execution_log;
pub mod files;
pub mod group;
pub mod http;
pub mod ids;
pub mod inbox;
@@ -37,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
@@ -62,10 +64,11 @@ pub use files::{
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
SAFE_RENDER_FALLBACK,
};
pub use group::Group;
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId,
ScriptId, TriggerId,
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
RequestId, ScriptId, TriggerId,
};
pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
@@ -83,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,
@@ -99,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};

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,47 @@ export interface App {
export type AppRole = 'app_admin' | 'editor' | 'viewer';
export interface Group {
id: string;
parent_id: string | null;
slug: string;
name: string;
description: string | null;
structure_version: number;
created_at: string;
updated_at: string;
}
export interface GroupDetail extends Group {
/** Root → … → this node breadcrumb. */
path: Group[];
subgroups: Group[];
apps: App[];
}
export interface GroupMember {
user_id: string;
username: string;
email: string | null;
instance_role: InstanceRole;
is_active: boolean;
role: AppRole;
created_at: string;
}
export interface CreateGroupInput {
slug: string;
name: string;
description?: string | null;
/** Parent group slug or id; omit (or null) for a root group. */
parent?: string | null;
}
export interface PatchGroupInput {
name?: string;
description?: string | null;
}
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain {
@@ -89,6 +130,8 @@ export interface CreateAppInput {
name: string;
description?: string | null;
force_takeover?: boolean;
/** Parent group slug or id; omit for the root group. */
group?: string | null;
}
export interface PatchAppInput {
@@ -778,6 +821,52 @@ export const api = {
)
},
groups: {
list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
get: (idOrSlug: string) =>
adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateGroupInput) =>
adminRequest<Group>('/api/v1/admin/groups', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchGroupInput) =>
adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
reparent: (idOrSlug: string, parent: string | null) =>
adminRequest<Group>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
{ method: 'POST', body: JSON.stringify({ parent }) }
),
delete: (idOrSlug: string) =>
adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
}),
members: {
list: (idOrSlug: string) =>
adminRequest<GroupMember[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
),
grant: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
update: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
}
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(

View File

@@ -55,6 +55,7 @@
<a href={base + '/'} class="brand">PiCloud</a>
<nav>
<a href={base + '/apps'}>Apps</a>
<a href={base + '/groups'}>Groups</a>
{#if user && user.instance_role !== 'member'}
<a href={base + '/users'}>Users</a>
{/if}

Some files were not shown because too many files have changed in this diff Show More