Compare commits

..

72 Commits

Author SHA1 Message Date
MechaCat02
aa2831da90 docs+test(hierarchies): end-to-end review follow-ups (M5/M6/M7)
Findings from a 3-lens end-to-end review (correctness, security, tests).
Security review came back clean (cross-repo authz sound, isolation intact,
M5 gate sound modulo the documented manifest-trust boundary). No behavior
changes here — only accuracy + coverage:

- apply_service Phase B2: correct the descendant-expansion comment. The chain
  is COMMITTED ancestry (ancestors() reads the pool), so a reparent in the same
  apply takes effect next apply — same "one more apply" shape the in-tree path
  and group-create reparenting already have. Authz stays sound: the gate and the
  expansion consume the SAME chain (checked == written).
- doc §4.5: document the two known "one more apply / no data risk" limitations
  — reparent-in-same-apply template resolution, and the `{env}` source split
  (declared apps use --env; cross-repo descendants use apps.environment).
- approval journey: give the app real (`[vars]`) content so the editor member's
  AppVarsWrite is actually exercised — the admin-gate test now proves approval
  is ABOVE editor-write, not just "any non-admin is refused". (Vars cascade with
  the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.)
- templates journey: assert descendant expansions are idempotent (stable row
  ids on re-apply — no churn), matching the in-tree guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:57:21 +02:00
MechaCat02
22d6c33d0b feat(hierarchies): per-env approval-policy gating (M5)
A project's root manifest can mark environments confirm-required; applying to
one needs an explicit `pic apply --dir --env <e> --approve <e>` (a blanket
`--yes` does NOT cover it), the act is admin-gated and audited, and the policy
folds into the bound-plan token. The last unbuilt piece of the project-tool
track (§4.2/§6).

- manifest: `[project]` block → `ManifestProject { environments[{name,confirm}] }`,
  valid only on the tree's ROOT manifest (rejected elsewhere by build_tree).
- discover: build_tree emits `project` into the bundle from the root manifest.
- apply_service: `TreeBundle.project` + `ProjectPolicy`; policy folds into the
  state_token (a policy edit between plan and apply trips StateMoved); plan
  surfaces `approvals_required`; new `ApplyError::ApprovalRequired` → 409.
- apply_api: `enforce_env_approval` (after authz_tree) — refuses a gated env
  not in `approved_envs`, and on approval requires admin (AppAdmin/GroupAdmin)
  on every declared node + audits. The server re-derives the policy from the
  bundle (CLI check is convenience; server is authoritative).
- CLI: `--approve <env>` (repeatable); `resolve_approvals` refuses a gated env
  non-interactively, prompts on a TTY (retype the env name); plan renders gated
  envs. Single-node `apply --file --env <e>` REFUSES a confirm-required env
  (can't carry the admin-gated approval) and directs to `--dir` — closing the
  bypass found in review rather than silently skipping the gate.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
  lives in the manifest, like takeover/blast-radius).
- doc §11 Phase 5 + §12: approval gating shipped; gate is a `--dir` feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:06:27 +02:00
MechaCat02
c04c684a2e feat(hierarchies): cross-repo template expansion (M7)
Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.

- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
  ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
  group not already handled in Phase B. All descendant locks are taken up
  front in the single sorted batch (their union is invariant under reparenting
  in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
  Per-recipient write caps (AppWriteRoute / AppManageTriggers /
  AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
  post-reparent, already-locked app — checked set == written set, no TOCTOU.
  expand_*_templates_tx are now self-contained (load hand-declared identities
  from the tx), so collisions are caught on descendants too. Blast radius now
  counts the true DB subtree. Descendant expansion errors are tagged with the
  app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
  in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
  expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:38:33 +02:00
MechaCat02
7c17d6e363 feat(hierarchies): template {var:NAME} resolves in-transaction (M6)
Route/trigger template `{var:NAME}` placeholders now resolve against vars
applied in the SAME `pic apply` transaction, not committed-only state — so a
var and a template referencing it converge in one apply instead of two.

- config_resolver: add tx-scoped `fetch_var_candidates_tx`, sharing the SQL
  tail (`VAR_CANDIDATES_TAIL`) with the pool variant so they can't drift.
- apply_service: `expand_route_templates_tx`/`expand_trigger_templates_tx`
  read vars via the tx (vars are reconciled in Phase A / the app's own
  reconcile, both before Phase B expansion).
- templates journey: `route_template_var_resolves_in_same_apply` — would fail
  against the old committed-only read.
- doc §4.5: strike the `{var:NAME}` committed-only limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:11:44 +02:00
MechaCat02
4d2eed4e81 feat(hierarchies): trigger templates — per-app fan-out (M4b)
Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.

- Migration 0054: `trigger_templates` table (group-owned; the whole
  BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
  `triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
  params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
  chain trigger template into a concrete trigger per descendant app —
  placeholders resolved in every spec string leaf, then the typed trigger is
  rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
  secrets resolved + re-sealed per recipient app). Idempotent via from_template
  (one trigger per template per app; semantic-identity compare → no-op /
  delete+recreate / reap). Collision with a hand-declared trigger or between
  templates is a hard error. Each resolved trigger is re-validated with the
  per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
  schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
  AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
  prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.

Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:38:04 +02:00
MechaCat02
0396866698 feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)
§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).

- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
  provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
  on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
  plan); tree Phase B expands each chain template into a concrete route per
  descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
  app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
  Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
  is no-churn, --prune reaps expansions). Collision with a hand-declared route,
  or between two templates, is a hard error. Each RESOLVED expansion is
  validated like a hand-declared route (reserved-path, path/host parse,
  host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
  count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
  AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
  prune-reap + collision-rejected); placeholder/validation unit tests; schema
  golden reblessed.

Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:58:41 +02:00
MechaCat02
193336a8a6 feat(hierarchies): multi-repo single-owner ownership + structural prune (M3)
§7 of the groups/project-tool design. A group node is now authoritatively
managed by exactly one project-root; `pic apply --dir` claims, conflicts,
takes over, and (with --prune) structurally reaps owned nodes.

- Migration 0052: `projects(id, key UNIQUE)` + promote the inert
  `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index.
- CLI mints a stable, gitignored project key in `.picloud/project.json`
  (`pic init`, or lazily on first tree plan/apply) and presents it on every
  tree request; `pic apply --dir --takeover` flag.
- Server: prepare_tree resolves ownership read-only (plan surfaces conflicts
  + prune candidates; token folds each group's owner key). apply_tree upserts
  the project in-tx, claims created groups on insert, reconciles existing-group
  ownership under the per-node advisory lock (first-commit-wins), and prunes
  owned-but-undeclared groups leaf-first (delete=RESTRICT, never another repo's
  or a UI-owned node).
- Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested
  node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership
  decision, so --force (which waives the staleness token) can't open a
  takeover-without-admin window. The attacker-supplied project key is
  length/charset-validated server-side.
- Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin
  takeover → 403; prune-owned-only); format_conflicts unit test; schema golden
  reblessed. tree_shape M2 journey updated to reparent in-place (a fresh dir is
  now a distinct project and would correctly conflict).

Closes the M3 milestone; reviewed (4 findings: 1 authz-bypass via --force +
key validation + 2 LOW, all fixed). M4 (trigger/route templates) remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:04:53 +02:00
MechaCat02
c18ce7c2c4 feat(hierarchies): declarative group create/reparent — tree shape (M2)
M2 of the remaining-hierarchies work. `pic apply --dir` now owns the org-tree
SHAPE, not just node content: a `[group]` manifest for a group that doesn't
exist is CREATED under its declared parent, and an existing group whose declared
parent changed is REPARENTED — all inside the single tree-apply transaction
(create + reparent; structural prune is deferred to M3 with the ownership layer).

group_repo: extract transaction-aware structural mutations — `create_group_tx`,
`reparent_group_tx` (the ancestor-walk cycle guard, now reading through the tx so
it sees in-tx writes), `delete_group_tx`, and `acquire_structural_lock_tx`. The
trait `create`/`reparent`/`delete` delegate to them (one SQL definition,
behavior-preserving: the coarse structural advisory lock + structure_version
bump + delete=RESTRICT all preserved).

apply_service: `TreeNode` gains `parent_slug` + `name`. `prepare_tree` classifies
group nodes into existing (resolved id, maybe reparent) vs to-create (absent →
deferred), returning a `PreparedTree { prepared, creates, reparents, token }`.
`apply_tree` adds Phase 0 (`reconcile_tree_structure_tx`): create absent groups
parent-first (topo loop with cycle/unresolved-parent detection) and reparent
existing ones, then Phase A/B reconcile content as before. A to-create group
reconciles against an empty CurrentState (all-Create) — so it needs no DB read
and sidesteps the pool-vs-tx coupling. The bound-plan token folds a
declared-absent marker, so a group created out-of-band between plan and apply
trips StateMoved.

authz (apply_api `authz_tree`): a to-create group mirrors the interactive create
gate — root-level needs `InstanceCreateGroup`, a subgroup under an existing
parent needs only `GroupAdmin(parent)`; a reparent needs `GroupAdmin` at the
group, the SOURCE parent, and the DESTINATION parent (§5.6, parity with
`reparent_group`). No 404 on a to-create node.

CLI: `[group] parent` manifest key (else the parent is inferred from the nearest
ancestor directory's group); `build_tree` emits `parent_slug` + `name` per group
node; the apply report shows a `groups +N reparented M` line.

Reviewed by subagent (core mechanism verified sound: topo termination, empty-
CurrentState reconcile, in-tx cycle guard, atomicity, StateMoved, idempotency);
fixed the two authz-parity gaps it found (missing source-parent check on
reparent; over-gated subgroup create). Tests: a new `tree_shape` journey
(create + reparent + no-op re-plan); 393 manager-core lib + the Phase-5 tree/
group/ext journeys all green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:18:27 +02:00
MechaCat02
dc8c69ac6f docs(design): mark extension points (§5.5) shipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:36:11 +02:00
MechaCat02
4dcb8d1136 feat(hierarchies): extension points — opt-in dynamic module resolution (§5.5)
M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).

Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
  chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
  ext point on the importer's chain first; on a hit it resolves against
  `App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
  query returns Some only when the NEAREST declaration of the name is an ext
  point (a peer/nearer concrete module shadows it). A group origin can't reach
  app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).

Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
  optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
  unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
  (name-based, default change = Update), reconcile (upsert after scripts so the
  default resolves) + prune, `validate_bundle` (module-name shape; default must
  be a local module), `check_imports_resolve` (a declared/on-chain ext point
  satisfies an import), and the state_token fold. Writes gate on the
  script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
  re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.

CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.

Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:29 +02:00
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
MechaCat02
695987d6b7 docs(design): record async disabled-execution + cross-app backstops
Update §4.3: the fire-time `enabled` re-check is shipped and test-backed on
BOTH async arms (the unified outbox `!resolved.active` gate and the queue
arm's fresh-script nack), with the specific regression tests cited, plus the
same-app backstop now present on every async build path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:52 +02:00
MechaCat02
d4b5632db1 style(email): wrap over-width lines (cargo fmt)
Incidental `cargo fmt --all` normalization of two pre-existing over-width
`.lock()` chains in DevEmailSink; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:52 +02:00
MechaCat02
345f265062 fix(cli): env-aware config, pull clobber guard, strict overlays, 409 hint
Four review fixes on the `pic` surface:

- `config --effective` gains `--env`: it now resolves through the same
  overlay as `plan`/`apply`, so the masked report targets the app those
  commands act on instead of silently reading the base slug's secrets.
- `pull` gains `--force` and refuses to overwrite an existing
  `picloud.toml` (and colliding `scripts/*.rhai`) without it — fail-fast
  before any network call or write, mirroring `init`.
- Overlays (`ManifestOverlay`/`OverlayApp`) get `deny_unknown_fields`: a
  `[[scripts]]`/`[[routes]]`/`[[triggers]]` table or a typo'd key in an
  overlay now errors loudly instead of being silently dropped. +unit test.
- `apply` 409 (StateMoved — the only 409 the endpoint emits) surfaces an
  actionable hint (re-run `pic plan`, or `--force`) instead of a bare
  `HTTP 409`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:45 +02:00
MechaCat02
aa3995ae05 fix(apply): reject reserved module names in validate_bundle (parity)
The interactive create path rejects a module whose name shadows a built-in
SDK namespace (`kv`, `secrets`, …) so `import "kv"` can't resolve to a user
module, but `pic apply`'s `validate_bundle` skipped the check — a parity gap
that let a manifest create what the dashboard forbids. Gate it on
`ScriptKind::Module` and share the same `RESERVED_MODULE_NAMES` const (now
`pub(crate)`); endpoints remain unrestricted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:29 +02:00
MechaCat02
a27863d4a6 fix(dispatcher): close disabled-execution + cross-app gaps on the async paths
The `enabled` lifecycle's "a disabled script is not executable via ANY
path" guarantee leaked on the queue arm, and the outbox trigger arm lacked
the cross-app backstop its siblings carry.

- Queue arm: `dispatch_one_queue` bypasses the unified `dispatch_one`
  fire-time gate, so its only `enabled` guard was the per-tick
  `list_active_queue_consumers` snapshot — a TOCTOU window for a message
  claimed in a tick where the script was still enabled. Re-check the
  freshly-read `script.enabled` before executing and release the claim
  (`nack`) when disabled; the message stays queued and resumes on
  re-enable. The nack is load-bearing: `reclaim_visibility_timeouts` only
  reclaims claims for enabled triggers, so without it the message would
  sit claimed indefinitely.
- Trigger arm: `resolve_trigger` built the ExecRequest with
  `app_id = row.app_id` while sourcing the body from `trigger.script_id`
  with no same-app check — the guard the queue/http/invoke arms already
  have. Add it so a hand-edited/restored row can't run one app's script
  under another's SdkCallCx.

Both regression-locked by new unit tests (full mock harness, verified to
fail if the gate is removed):
- queue_enabled_gate::disabled_queue_consumer_releases_claim_without_executing
- outbox_enabled_gate::disabled_http_outbox_row_is_dropped_without_executing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:21 +02:00
MechaCat02
b3f05dfe2a fix(enabled): close disabled-script execution on the async paths (review)
Holistic Phase-1 review found the "a disabled script can't execute via
any path" guarantee (§4.3) held only for the sync user-route, the
execute-by-id bypass, and the trigger outbox arm — three paths still ran
disabled scripts:

- Queue arm: `list_active_queue_consumers` filtered the trigger's
  `enabled` but not the bound script's. Add `JOIN scripts … AND
  s.enabled = TRUE` so a disabled script's queue trigger stops consuming.
- Async-HTTP (202) + queued invoke() arms: `build_http_request` /
  `build_invoke_request` hardcoded `active: true`. Set it to
  `script.enabled`, and MOVE the dispatcher's fire-time `active` drop to
  after the source-kind match so it covers all three arms uniformly
  (previously trigger-only).
- `invoke()` (script-to-script): `resolve_id`/`resolve_name` checked
  cross-app isolation but not `enabled`. A disabled target now resolves
  to NotFound (indistinguishable from absent), matching the data plane.

Also (review LOW/§4.7):
- The route-on/script-off 404 returned `NotFound(script_id)`, leaking the
  internal id and distinguishable from absent. Return the same flat "no
  route matches" 404 as the unmatched case.
- Add the §4.7 "enabled endpoint with no route and no trigger" reachability
  warning (was unimplemented; only disabled-target shipped).

Tested: manager-core lib 368 + orchestrator 75 + 22 project-tool journeys
(incl. a new enabled-route→disabled-script flat-404 e2e) green; clippy
-D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:59:50 +02:00
MechaCat02
5e62f4acfe docs(design): mark Phase 1 shipped + record decisions/deferrals
Fold the implementation outcome back into the project-tool spec (§11) as
the design asks. Records what landed (init/pull/config/plan/apply/prune,
env overlays, enabled lifecycle, trigger name+backfill, content-fingerprint
bound-plan, apply lock) and the deliberate deferrals: tree-structure
version → groups; vars/--explain → Phase 3; trigger upsert-by-name + the
dashboard enabled toggle → tracked follow-ups beyond the §11 bullets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:20:32 +02:00
MechaCat02
73c8c289c1 feat(triggers): surface name on the Trigger model (§4.5, step B-1)
Read the new `name` column through the data model: `Trigger`/`TriggerRow`
carry it, every trigger SELECT/RETURNING includes it, and the row→Trigger
assembly + the interactive create paths populate it (the create INSERTs
still omit it, so they take the migration's default). Behavior unchanged
— the apply diff still keys on the semantic tuple; B-2 switches it to
name-keying (enabling Update) and wires the manifest.

Tested: manager-core lib 367 + trigger journeys green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:17:52 +02:00
MechaCat02
816f143ffd feat(triggers): add name column + backfill (§4.5, schema step)
Schema groundwork for the trigger `name` identity (the manifest merge key
that will let `apply` Update a trigger in place rather than only
Create/Delete). A `gen_random_uuid()` default keeps the existing write
paths valid and unique without code changes; existing rows are backfilled
to the readable `{kind}-{n}` form; `UNIQUE(app_id, name)` is enforced.

No behavior change yet — the manager-core write paths and the apply diff
start using the name in the follow-up. Verified the migration applies and
the trigger journeys (which create triggers via the unnamed path → default
name) stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:09:22 +02:00
MechaCat02
2ba476aac8 feat(cli): env-scoped overlays — --env merges picloud.<env>.toml
The §4.1 base+overlay model for the single-app world: a base `picloud.toml`
holds the shared scripts/routes/triggers; a sparse `picloud.<env>.toml`
carries the per-environment slug (+ extra secret names). `pic plan/apply
--env <E>` merges the overlay onto the base before diffing/applying, so an
environment deploys to its own app ("an environment is an app", §2)
without duplicating the shared definitions.

Merge is sparse: overlay `app.slug`/`name` replace the base's; secret
names union. Scripts/routes/triggers always come from the base. Rich
per-key `vars` resolution stays Phase 3.

Tested: unit tests for the merge + overlay-path derivation; a journey
proving `apply --env staging` deploys to the staging app and leaves the
base app untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:58:04 +02:00
MechaCat02
79153b2063 feat(cli): pic config --effective (masked secret resolution)
The §11 Phase-1 `config --effective` surface. Single-app today, "config"
= the app's secrets, so it cross-references the manifest's declared
secret names against what's set on the server and renders each masked:
`<set>` (managed / on-server-not-in-manifest) or `<unset>` (declared but
not pushed). Values are never fetched or shown (§4.6). Shaped to grow an
`--explain` mode and inherited `vars` when groups/vars arrive (Phase 3).

Tested: a journey verifying declared-but-unset → `<unset>`, post-`secret
set` → `<set>`/managed, and the value never appears in output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:54:46 +02:00
MechaCat02
9e1c24f729 test(enabled): end-to-end route-disable journey
Close the coverage asymmetry flagged in review: script-disable had a full
e2e journey but route-disable was only unit-tested (compile_routes) plus
the apply-refresh path. Add an HTTP-level journey — claim a Host for the
app (two-phase dispatch needs it), then serve `/hello` (200), flip the
route's `enabled = false` and re-apply (404, indistinguishable from
absent), and re-enable (200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:04 +02:00
MechaCat02
8c805a07d0 feat(enabled): apply-time reachability warning + end-to-end journey
- §4.7 warning: `apply` now reports when an enabled route or trigger is
  bound to a disabled script — deployed but unreachable (route 404s,
  trigger won't fire). A warning, not an error: it's valid desired state,
  the operator just shouldn't be surprised by the silent 404.
- E2E journey (`tests/enabled.rs`): apply an active script and confirm
  `/api/v1/execute/{id}` 200s; flip `enabled = false` in the manifest and
  re-apply → 404; re-enable → 200. Exercises the whole path: manifest →
  diff → apply → runtime honoring.

Tested: manager-core lib 367 + cli bins 31 + 15 project-tool journeys
(incl. the new enabled e2e) green; clippy -D warnings clean; versioning
gate passes (migration 0045).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:10:13 +02:00
MechaCat02
bfab7c781d feat(enabled): dispatcher fire-time re-check for disabled triggers/scripts
Close the §4.3 outbox gap: the match-time `enabled` check can't see a
trigger (or its target script) disabled *after* an outbox row was
enqueued, so a pending row would still fire. `resolve_trigger` now folds
`trigger.enabled && script.enabled` into the resolved row, and
`dispatch_one` drops the row at fire time when inactive instead of firing
a stale event.

The queue arm needs no change here: `list_active_queue_consumers` already
re-lists only enabled queue triggers each tick, so a disable is honored
promptly without a stale-row window.

Tested: manager-core lib 366 green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:48:56 +02:00
MechaCat02
4223d3c320 feat(enabled): runtime honoring for disabled scripts and routes
Make the `enabled` flag bite at the data plane (§4.3).

- Routes: `compile_routes` drops disabled rows from the match table, so a
  request to a disabled route 404s indistinguishably from an absent one
  (no info leak). Rebuilt on every route write + at startup, as today.
- Scripts: the orchestrator's two execute paths — `/execute/{id}` bypass
  and the user-route handler — 404 when the resolved script is disabled.
  The route-handler check also covers an enabled-route-bound-to-disabled-
  script (§4.7): the binding is unreachable rather than 500/executing.

Still pending: the dispatcher fire-time re-check for already-enqueued
outbox rows (next commit).

Tested: route_admin disabled-route-dropped unit test + orchestrator suite
(75) green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:42:45 +02:00
MechaCat02
55cf995eda feat(enabled): scripts/routes enabled column + declarative data path
Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers
already carried `enabled`; this adds it to scripts and routes and threads
it through the declarative project tool. Runtime honoring (disabled route
404 / script non-invocable / dispatcher fire-time re-check) is the next
commit — this change only stores and reconciles the flag.

- Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts +
  routes. Default true ⇒ no behavior change on migrate.
- `Script`/`Route` (shared) gain `enabled` (serde default true via the new
  `picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row
  structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it.
- Apply diff treats `enabled` as a declarative field (omitted ⇒ active):
  `script_update_reason` + `diff_routes` detect a toggle as an Update, and
  the create/update/insert paths persist it. The bound-plan `state_token`
  re-includes script/route `enabled` (removed in the earlier review fix
  precisely because the diff didn't key on it yet — now it does).
- CLI manifest model + `build_bundle` + `pull` round-trip `enabled`
  (serialized only when false; omitted ⇒ active). `pic init` scaffold and
  the interactive script/route create paths default active.

Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) +
cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:36:40 +02:00
MechaCat02
627996cde7 fix(project-tool): address independent review of init + staleness
Remediation after an independent review of the two feature commits.

pic init:
- Fix the headline `pic init --dir <new-dir>` flow: slug derivation used
  `canonicalize()`, which fails on a not-yet-created directory → empty
  slug → bail. Derive from the path's own final component first, falling
  back to canonicalize only for `.`. Add a test that exercised the gap
  (the existing tests all passed an explicit slug).
- `ensure_gitignored` no longer overwrites an existing-but-unreadable
  `.gitignore` (only a genuinely-absent file is treated as empty).

Bound-plan staleness:
- Token trigger coverage now mirrors the diff exactly via
  `current_trigger_identity`: dead-letter triggers (which the diff
  ignores and the manifest can't represent) and the currently-inert
  `enabled` flag are no longer hashed, so an out-of-band dead-letter
  change can't force a spurious `--force`. Add a test.
- Correct two overclaiming comments: the check shares the diff's
  pool-read apply-vs-interactive-write window (it is not tx-scoped), and
  the token deliberately mirrors the diff's inputs.
- `.picloud/` self-ignores via a `*` `.gitignore` written alongside the
  plan token, so projects created with `pic pull`/`plan` (not just
  `init`) keep it out of git.
- `clear_plan` is now app-scoped: it won't discard a token recorded for a
  different app sharing the directory.

Tested: manager-core lib 364 + cli bins 31 + 14 project-tool journeys
green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:07:31 +02:00
MechaCat02
be5df06a48 feat(project-tool): bound-plan staleness check (content fingerprint)
`pic plan` now records a fingerprint of the live state it diffed against;
`pic apply` replays it and the server refuses (HTTP 409) if the app
changed underneath the reviewed plan — the §4.2 "apply exactly what you
reviewed" guarantee, in its content-addressed form (no migration, no
changes to interactive write paths).

Server (manager-core):
- `state_token(CurrentState)`: deterministic FNV-1a fingerprint over what
  the diff keys on — script name+version (version bumps on any edit),
  route identity+binding/attrs, trigger membership+enabled, secret names.
  Order-independent; a collision can only yield a false "unchanged", never
  a false refusal.
- `plan` returns it (flattened onto the plan JSON, so the wire stays
  additive); `apply` takes an optional `expected_token` and, under the
  apply lock before any write, returns `StateMoved` (409) on mismatch.

CLI:
- `.picloud/` link state (`linkstate`): `pic plan` writes the token scoped
  to the app slug; `pic apply` replays it, then clears it on success (the
  token is single-use — the next apply re-plans). `--force` skips the
  check; apply with no recorded plan still works standalone (today's
  behavior). `.picloud/` is already gitignored by `pic init`.

The tree-structure version (the other half of §4.2's counter) stays a
deliberate no-op until groups exist — it only guards reparent/structural
moves, which don't exist single-app.

Tested: state_token unit test (stable/order-independent/sensitive) + a
staleness journey (plan → out-of-band deploy → apply refuses → --force
applies); manager-core lib 363 + cli bins 31 + 13 journeys green; clippy
-D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:48:27 +02:00
MechaCat02
b8a4f30219 feat(cli): pic init scaffolds a new declarative project
Offline project scaffolding — the on-ramp to pull/plan/apply. Writes a
`picloud.toml` describing a minimal *working* app (a `hello` endpoint
bound to GET /hello) plus commented examples for the other blocks,
`scripts/hello.rhai`, and a `.gitignore` that pre-ignores the project
tool's `.picloud/` link-state directory (the home for the bound-plan
state token, landing next). Refuses to overwrite an existing manifest
without `--force`; derives the app slug from the directory name when not
given (validated against the canonical slug rule).

The active manifest is rendered through the same `to_toml` model the rest
of the tool uses, so a freshly-init'd project is guaranteed valid and
immediately `pic plan`-able. Offline — no server contact, so the journey
tests need no DATABASE_URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:32:07 +02:00
MechaCat02
d9b3e9973c fix(project-tool): address review findings on the apply foundation
Remediation of the single-app reconcile foundation after two independent
review passes. No new feature surface — closes correctness, parity, and
safety gaps in pull/plan/apply/prune.

apply engine (manager-core):
- validate_bundle reached parity with the interactive trigger API: reject
  an empty kv/docs/files collection_glob and a malformed pubsub
  topic_pattern (previously written and silently never matched), and lift
  the queue visibility floor to MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30) —
  apply had accepted a [5,29] value the dashboard refuses. Per-kind checks
  extracted into a pure, unit-tested validate_trigger_shape.
- An omitted script `description` now means "leave as-is" (matching the
  other optional fields and the function's own documented contract)
  instead of clearing the stored value.
- Doc fixes: drop stale "next milestone" notes; record the one deliberate
  plan/apply divergence (set-but-empty secret); document delete_route_tx
  as intentionally idempotent for reconcile.

CLI:
- Gate destructive `apply --prune` behind confirmation: interactive y/N,
  or `--yes` for CI; a non-interactive prune without `--yes` refuses
  rather than silently deleting. Journey tests pass `--yes`; a new test
  proves the gate refuses and deletes nothing.
- Harden pull's filename safety: reject all control characters and the
  Unicode bidi-override / zero-width chars used for terminal/filename
  spoofing, and cap length at 200 bytes (NAME_MAX headroom).

Tested: manager-core lib (incl. queue-floor + sparse-description parity)
+ CLI bins + 9 project-tool journeys green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:18:51 +02:00
131 changed files with 21050 additions and 797 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,50 @@
//! `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`.
//!
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
//! by declaring it an extension point: an importer on that node's chain then
//! resolves the name against the *inheriting app's* effective view
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
//! descendant app supplies its own impl. The "is this an extension point"
//! check keys on the importer's `origin` (trusted), so only the declaring
//! parent can open the inversion; a descendant can't hijack a sealed import.
//!
//! 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 +53,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 +111,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 +144,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 +159,7 @@ impl PicloudModuleResolver {
cx: Arc<SdkCallCx>,
cache: Arc<ModuleCache>,
depth_limit: u32,
default_origin: ScriptOwner,
) -> Self {
Self {
source,
@@ -113,6 +168,7 @@ impl PicloudModuleResolver {
in_progress: Mutex::new(Vec::new()),
depth: Mutex::new(0),
depth_limit,
default_origin,
}
}
@@ -224,7 +280,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 +375,54 @@ 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);
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
// the importer's chain is an extension point (not a concrete module),
// resolve it against the INHERITING app's effective view instead of the
// sealed lexical chain — each descendant app supplies its own impl,
// falling back to the declared default body. The decision keys on
// `origin` (the trusted importer node), so only a declaration on the
// importer's own ancestry can open this; a descendant can never make a
// parent's sealed `import` dynamic, and a non-ext-point import never
// consults `App(cx.app_id)`. All branches funnel any backend error into
// the single redaction arm below.
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
(|| {
let ep = tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_extension_point(origin, path))
})?;
let Some(ep) = ep else {
// Sealed lexical resolution (the default, unchanged).
return tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve(origin, path))
});
};
let app_origin = ScriptOwner::App(self.cx.app_id);
if let Some(m) = tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve(app_origin, path))
})? {
return Ok(Some(m));
}
match ep.default_script_id {
// The default body is a module declared at the node that
// opened the ext point, i.e. on the importer's (`origin`)
// chain — resolve it there, not against the app.
Some(id) => tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_by_id(origin, id))
}),
None => Ok(None),
}
})();
let module_row = match lookup_result {
Ok(Some(m)) => m,
@@ -355,8 +457,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 +493,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 +508,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,13 +27,29 @@ 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()))
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
_script_id: picloud_shared::ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
}
/// `MakeWriter` that appends to a shared buffer.
@@ -74,6 +90,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 +124,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,51 @@ 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())
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
// This flat fake has no extension points; the inversion is covered by
// `LexicalModuleSource` below and the Postgres journey tests.
Ok(None)
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.values()
.find(|m| m.script_id == script_id)
.cloned())
}
}
@@ -104,6 +136,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 +161,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 +633,350 @@ 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>>,
/// Extension-point declarations, keyed by exact declaring owner →
/// optional default module id. Flat (exact-owner) — the chain-walk +
/// shadowing tie-break is covered by the Postgres journey tests.
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
}
impl LexicalModuleSource {
fn new() -> Arc<Self> {
Arc::new(Self::default())
}
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
let (app_id, group_id) = match owner {
ScriptOwner::App(a) => (Some(a), None),
ScriptOwner::Group(g) => (None, Some(g)),
};
let script_id = ScriptId::new();
self.table.lock().await.insert(
(owner, name.to_string()),
ModuleScript {
script_id,
app_id,
group_id,
name: name.to_string(),
source: source.to_string(),
updated_at: Utc::now(),
},
);
script_id
}
/// Declare `name` an extension point at `owner`, with an optional default
/// module body id.
async fn put_ext_point(
self: &Arc<Self>,
owner: ScriptOwner,
name: &str,
default_script_id: Option<ScriptId>,
) {
self.ext_points
.lock()
.await
.insert((owner, name.to_string()), default_script_id);
}
}
#[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())
}
async fn resolve_extension_point(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
Ok(self
.ext_points
.lock()
.await
.get(&(origin, name.to_string()))
.map(|default_script_id| picloud_shared::ExtPointResolution {
default_script_id: *default_script_id,
}))
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.values()
.find(|m| m.script_id == script_id)
.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));
}
// ---------------------------------------------------------------------------
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
// a module name an extension point; an importer on the group's chain resolves
// it against the INHERITING APP, not the sealed lexical chain.
// ---------------------------------------------------------------------------
/// A group script importing a declared extension point resolves it against the
/// executing app's own module — each tenant supplies its own body.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_resolves_to_app_module() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// The group opens "theme" as an extension point (no default).
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
// The app provides its own "theme".
source
.put(
ScriptOwner::App(app),
"theme",
r#"fn name() { "app-theme" }"#,
)
.await;
let engine = engine_with(source.clone());
// Entry runs as the inherited GROUP script (default_origin = group), but
// the executing app is `app` (cx.app_id).
let resp = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!("app-theme"),
"extension point must resolve to the inheriting app's module"
);
}
/// When the app provides no module for the extension point, the declared
/// default body is used.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_falls_back_to_default() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// The group's default "theme" body, registered as a group module.
let default_id = source
.put(
ScriptOwner::Group(group),
"default-theme",
r#"fn name() { "default-theme" }"#,
)
.await;
source
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
.await;
// The app provides NO "theme".
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!("default-theme"));
}
/// An extension point with neither an app provider nor a default is a clean
/// module-not-found at import time.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_no_provider_no_default_errors() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
let engine = engine_with(source.clone());
let err = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect_err("ext point with no provider/default should fail");
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
"expected module-not-found-flavoured error, got {msg}"
);
}
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
/// module of the same name — the inversion only fires for declared extension
/// points, so the trust boundary holds.
#[tokio::test(flavor = "multi_thread")]
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// "auth" is a concrete group module — NOT an extension point.
source
.put(
ScriptOwner::Group(group),
"auth",
r#"fn who() { "group-auth" }"#,
)
.await;
// The app has a trap "auth" that must NOT be selected.
source
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
.await;
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "auth" as a; a::who()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!("group-auth"),
"a non-ext-point import must seal to the group, never the leaf's trap"
);
}
/// Two apps inheriting the same extension point get their OWN bodies — the
/// id-keyed module cache does not bleed one tenant's body into another.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_two_apps_get_distinct_bodies() {
let source = LexicalModuleSource::new();
let app1 = AppId::new();
let app2 = AppId::new();
let group = GroupId::new();
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
source
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
.await;
source
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
.await;
let engine = engine_with(source.clone());
let r1 = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app1, ScriptOwner::Group(group)),
)
.expect("app1 executes");
let r2 = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app2, ScriptOwner::Group(group)),
)
.expect("app2 executes");
assert_eq!(r1.body, serde_json::json!("one"));
assert_eq!(
r2.body,
serde_json::json!("two"),
"no cross-tenant cache bleed"
);
}

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,8 @@
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
-- this adds the same toggle to scripts and routes. A disabled script is not
-- invocable; a disabled route 404s (indistinguishable from absent). Default
-- TRUE so every existing row stays active — no behavior change on migrate.
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -0,0 +1,29 @@
-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the
-- manifest merge/upsert key (Step B switches the apply diff to key on it).
-- Until now triggers were identified only by their per-kind semantic tuple,
-- so the declarative tool could only Create/Delete them, never Update.
--
-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by
-- creation order) — the spec-sanctioned fallback when there's no cleaner
-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name).
-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet
-- supply a name) valid and unique — the manager-core write paths start
-- providing meaningful names in the follow-up; new rows until then get a
-- harmless unique placeholder.
ALTER TABLE triggers
ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text;
-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form.
UPDATE triggers t
SET name = sub.nm
FROM (
SELECT id,
kind || '-' || row_number() OVER (
PARTITION BY app_id, kind ORDER BY created_at, id
) AS nm
FROM triggers
) sub
WHERE t.id = sub.id;
CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name);

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

@@ -0,0 +1,49 @@
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
--
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
-- against the importer's own defining node — a leaf app cannot shadow it
-- (the trust boundary). When a node declares `x` an extension point, an
-- importer whose defining node is on that node's chain instead resolves `x`
-- against the INHERITING app's effective view, so each descendant app may
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
-- descendant cannot make one of its parent's sealed imports dynamic, because
-- the declaration must live on the importer's own ancestry.
--
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
-- Optional `default_script_id` is the fallback module body used when the
-- inheriting app provides no module of `name`.
CREATE TABLE extension_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Polymorphic owner: exactly one FK set (real FKs 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 extension_points_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
name TEXT NOT NULL,
-- Optional fallback module (a module owned by the declaring node). SET
-- NULL on delete so deleting the module can never block — the fallback is
-- then absent and a non-providing app errors at import time (the next plan
-- shows the now-null default as an Update). A valid manifest apply can't
-- reach this: validation rejects a kept ext point whose default names a
-- module not in the bundle.
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One declaration per (owner, name), case-insensitive to match the module
-- name indexes (0050). Partial because the owner is split across two columns.
CREATE UNIQUE INDEX extension_points_group_uidx
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX extension_points_app_uidx
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
CREATE INDEX extension_points_group_id_idx
ON extension_points (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX extension_points_app_id_idx
ON extension_points (app_id) WHERE app_id IS NOT NULL;
CREATE INDEX extension_points_default_script_idx
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;

View File

@@ -0,0 +1,39 @@
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
--
-- A group node is authoritatively MANAGED by at most one project-root (the
-- repo whose manifest declares it as something it applies, not merely
-- references). `groups.owner_project` has carried this seam since 0047 as an
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
-- foreign key from `groups.owner_project` to it.
--
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
-- from their group (apps are never claimed directly). A NULL `owner_project`
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
-- claims; a second project's apply to an owned node is refused unless
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
--
-- ON DELETE SET NULL: deleting a project row (not something the platform does
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
-- maps it to this row (upsert-by-key), so the same repo keeps the same
-- project identity across clones/CI without committing any server id.
key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
-- no validation burden.
ALTER TABLE groups
ADD CONSTRAINT groups_owner_project_fkey
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
-- The ownership reconcile reads "which groups does project P own?" (to find
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
CREATE INDEX groups_owner_project_idx ON groups (owner_project);

View File

@@ -0,0 +1,49 @@
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
--
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
-- fans it out into one concrete per-`app_id` row for every descendant app. This
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
-- isolation boundary is unchanged); the template is just a stamp.
--
-- Placeholders in template fields are a small, inert, documented set resolved
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
-- effective var). Provenance: each expanded `routes` row carries `from_template`
-- (the template id it was stamped from), so re-apply diffs idempotently and
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
-- same identity.
--
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
-- `triggers.from_template`.)
-- Route templates — one per (group, name). Mirrors the `routes` column shape
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
-- nearest inherited endpoint at expansion, like declarative route bindings).
CREATE TABLE route_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- Explicit identity/upsert key, unique per group (case-insensitive).
name TEXT NOT NULL,
script_name TEXT NOT NULL,
method TEXT,
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
host TEXT NOT NULL DEFAULT '',
host_param_name TEXT,
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
path TEXT NOT NULL,
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX route_templates_group_name_idx
ON route_templates (group_id, LOWER(name));
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
-- manifest); non-NULL = stamped from this template, managed by template
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
-- deletes orphaned expansions explicitly), and a dangling id after a raw
-- template delete is harmless (treated as "template gone → prune the row").
ALTER TABLE routes ADD COLUMN from_template UUID;
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);

View File

@@ -0,0 +1,33 @@
-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5).
--
-- The trigger half of templates (M4a shipped routes). A `[group]` manifest
-- declares `[[trigger_templates.<kind>]]`; the apply fans each one out into a
-- concrete per-`app_id` trigger on every descendant app, reusing the same
-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and
-- `from_template` provenance as routes.
--
-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone,
-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the
-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The
-- expansion resolves placeholders in the spec's string leaves, then rebuilds the
-- typed trigger and inserts it through the normal trigger path (email secrets
-- are resolved + re-sealed per app, exactly like a hand-declared email trigger).
CREATE TABLE trigger_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- Identity/upsert key, unique per group (case-insensitive).
name TEXT NOT NULL,
-- The full `BundleTrigger` wire object (internally tagged by `kind`), with
-- placeholders left unresolved. Rebuilt + resolved per descendant at apply.
spec JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX trigger_templates_group_name_idx
ON trigger_templates (group_id, LOWER(name));
-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL =
-- hand-declared; non-NULL = stamped from this template, managed by expansion.
ALTER TABLE triggers ADD COLUMN from_template UUID;
CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template);

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,
@@ -246,6 +268,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
} else {
Some(input.sandbox)
},
// Scripts are created active; toggling is a dedicated path.
enabled: true,
imports: validated.imports,
})
.await?;
@@ -258,7 +282,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
/// real KV bridge — defense against author confusion, not a security
/// boundary (stdlib namespaces and module imports already live in
/// disjoint Rhai scopes).
const RESERVED_MODULE_NAMES: &[&str] = &[
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
"log",
"regex",
"random",
@@ -289,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?;
@@ -345,6 +373,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
memory_limit_mb: input.memory_limit_mb,
sandbox: input.sandbox,
kind: input.kind,
enabled: None,
imports: imports_for_patch,
},
)
@@ -358,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?;
@@ -405,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
@@ -476,10 +511,9 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_)
| Self::BadRequest(_)
| Self::Invalid(_)
| Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {

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(),
@@ -68,6 +69,7 @@ async fn seed_into(
timeout_seconds: Some(5),
memory_limit_mb: None,
sandbox: None,
enabled: true,
imports: Vec::new(),
})
.await?;
@@ -85,6 +87,8 @@ async fn seed_into(
// `curl -d '{"name":"X"}' /hello` work out of the box.
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
from_template: None,
})
.await?;

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

@@ -8,30 +8,84 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
routing::{get, 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, Plan};
use crate::apply_service::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
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("/apps/{id}/extension-points", get(app_ext_points_handler))
.route(
"/groups/{id}/extension-points",
get(group_ext_points_handler),
)
.route("/tree/plan", post(tree_plan_handler))
.route("/tree/apply", post(tree_apply_handler))
.with_state(service)
}
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
async fn app_ext_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
Ok(Json(
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
))
}
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
async fn group_ext_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
&principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
Ok(Json(
svc.list_extension_points(ApplyOwner::Group(group_id))
.await?,
))
}
#[derive(Deserialize)]
pub struct ApplyRequest {
pub bundle: Bundle,
#[serde(default)]
pub prune: bool,
/// Optional bound-plan token from a prior `plan`. When present, apply
/// refuses (409) if the app's live state has changed since.
#[serde(default)]
pub expected_token: Option<String>,
}
async fn apply_handler(
@@ -41,55 +95,15 @@ 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, &req.bundle, req.prune, principal.user_id)
.apply(
app_id,
&req.bundle,
req.prune,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
Ok(Json(report))
}
@@ -99,7 +113,7 @@ async fn plan_handler(
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
) -> Result<Json<Plan>, ApplyError> {
) -> Result<Json<PlanResult>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
@@ -114,6 +128,602 @@ 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 TreePlanRequest {
bundle: TreeBundle,
/// Stable project key from the repo's `.picloud/` link state (§7, M3).
/// Lets `plan` surface ownership conflicts and prune candidates. Optional
/// for legacy callers (then no ownership diagnosis is reported).
#[serde(default)]
project_key: Option<String>,
}
#[derive(Deserialize)]
struct TreeApplyRequest {
bundle: TreeBundle,
#[serde(default)]
prune: bool,
#[serde(default)]
expected_token: Option<String>,
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
/// for this project and refuses ones another project owns.
#[serde(default)]
project_key: Option<String>,
/// Take over declared groups owned by another project (group-admin gated).
#[serde(default)]
allow_takeover: bool,
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
/// placeholder when expanding route templates. The CLI passes `--env`.
#[serde(default)]
env: Option<String>,
/// Environments the actor explicitly approved this apply for (§4.2, M5).
/// A confirm-required env (per the bundle's `[project]` policy) is refused
/// unless it appears here; `--yes` alone does NOT add it.
#[serde(default)]
approved_envs: Vec<String>,
}
async fn tree_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreePlanRequest>,
) -> Result<Json<TreePlanResult>, ApplyError> {
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
Ok(Json(
svc.plan_tree(&req.bundle, req.project_key.as_deref())
.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),
req.allow_takeover,
)
.await?;
enforce_env_approval(
&svc,
&principal,
&req.bundle,
req.env.as_deref(),
&req.approved_envs,
)
.await?;
let report = svc
.apply_tree(
&req.bundle,
req.prune,
&principal,
req.expected_token.as_deref(),
req.project_key.as_deref(),
req.allow_takeover,
req.env.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.
#[allow(clippy::too_many_lines)]
async fn authz_tree(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
apply_prune: Option<bool>,
allow_takeover: 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?;
// Template expansion (§4.5) writes routes/triggers INTO
// this app, so the actor needs the matching write cap
// when the app's chain carries templates — even if the
// app itself declares none. Without this, expanding into
// an app a principal can't write would slip the gate.
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
if recv.routes {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if recv.triggers {
require(
svc.authz.as_ref(),
principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
// Email-trigger expansion resolves + seals the recipient
// app's secret server-side — same `AppSecretsRead` gate a
// hand-declared email trigger requires.
if recv.email {
require(
svc.authz.as_ref(),
principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
}
}
}
NodeKind::Group => {
let existing = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let Some(g) = existing else {
// To-create group (M2): mirror the interactive create gate
// (`groups_api::create_group`). A root-level group (no
// resolvable parent) needs `InstanceCreateGroup`; a subgroup
// under an EXISTING parent needs only `GroupAdmin(parent)`.
// A parent that is itself to-create has no id yet → fall
// back to `InstanceCreateGroup`.
let parent = match &node.parent_slug {
Some(p) => svc
.groups
.get_by_slug(p)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
None => None,
};
match parent {
Some(pg) => {
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id))
.await
.map_err(map_authz)?;
}
None => {
require(
svc.authz.as_ref(),
principal,
Capability::InstanceCreateGroup,
)
.await
.map_err(map_authz)?;
}
}
continue;
};
let group_id = g.id;
match apply_prune {
Some(prune) => {
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
.await?;
// Takeover gate (§7.4): seizing a group that another
// project owns needs GroupAdmin specifically — a second,
// independent gate on top of the write caps. We gate it
// for any already-claimed node under `--takeover` (a
// superset of genuinely-contested, never laxer); claiming
// an unclaimed node, or applying without `--takeover`,
// does not require admin. The authoritative owner rewrite
// happens in-tx in `apply_tree` (which knows our project).
if allow_takeover
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_some()
{
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
}
// Reparent: an explicit parent differing from the live
// one needs GroupAdmin at the group, the SOURCE parent,
// and the DESTINATION parent — mirroring the interactive
// `reparent_group` (§5.6 triply-gated).
if let Some(parent) = &node.parent_slug {
if let Some(pg) = svc
.groups
.get_by_slug(parent)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if Some(pg.id) != g.parent_id {
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
if let Some(src) = g.parent_id {
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(src),
)
.await
.map_err(map_authz)?;
}
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(pg.id),
)
.await
.map_err(map_authz)?;
}
}
}
}
None => {
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsRead(group_id),
)
.await
.map_err(map_authz)?;
}
}
}
}
}
// NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in
// this bundle (the cross-repo subtree). Those per-recipient write caps are
// gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the
// post-reparent app set, each app already locked — so the checked set is by
// construction the written set (no pre-tx TOCTOU, and a reparent that moves
// apps under a templated group in the same apply can't slip the gate).
Ok(())
}
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
/// enforcement is convenience, this is authoritative), it must be explicitly
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
/// authority on every declared node — the "override a gate" capability (§4.2),
/// a deliberate step up from the editor-level write caps an ordinary apply
/// needs — and is audited.
async fn enforce_env_approval(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
env: Option<&str>,
approved_envs: &[String],
) -> Result<(), ApplyError> {
let (Some(policy), Some(env)) = (&bundle.project, env) else {
return Ok(());
};
if !policy.confirm_required(env) {
return Ok(());
}
if !approved_envs.iter().any(|e| e == env) {
return Err(ApplyError::ApprovalRequired(env.to_string()));
}
// Approved: require admin authority on every declared node.
for node in &bundle.nodes {
match node.kind {
NodeKind::App => {
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
.await
.map_err(map_authz)?;
}
NodeKind::Group => {
// A to-create group has no id yet; its creation already required
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
.await
.map_err(map_authz)?;
}
}
}
}
tracing::info!(
user_id = ?principal.user_id,
env = %env,
nodes = bundle.nodes.len(),
"apply: approved gated-environment apply (audit)"
);
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)?;
// Extension points are module-resolution declarations, so they gate on the
// same script-write tier as modules — without this, an ext-point-only
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
// and let a reader open the §5.5 import trust boundary.
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.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> {
// Extension points and route/trigger templates gate on the script-write
// tier (see the app variant) — all are code-binding declarations.
if prune
|| !bundle.scripts.is_empty()
|| !bundle.extension_points.is_empty()
|| !bundle.route_templates.is_empty()
|| !bundle.trigger_templates.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(())
}
/// What template expansion (§4.5) will write into `app_id` during this tree
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
/// resolves + seals the recipient app's secret server-side, like a hand-declared
/// email trigger).
#[derive(Default)]
struct Recipient {
routes: bool,
triggers: bool,
email: bool,
}
fn spec_is_email(spec: &serde_json::Value) -> bool {
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
}
async fn app_receives_template_expansions(
svc: &ApplyService,
app_id: AppId,
bundle: &TreeBundle,
) -> Result<Recipient, ApplyError> {
let Some(app) = svc
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(Recipient::default());
};
let ancestors = svc
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
let mut r = Recipient::default();
// Committed templates on any ancestor group.
for gid in &ancestor_ids {
if !r.routes
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_empty()
{
r.routes = true;
}
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
r.triggers = true;
r.email = r.email || spec_is_email(&tt.spec);
}
}
// Templates newly declared by a group node in this bundle that is an ancestor
// of the app (existing groups only — a to-create group has no apps).
for node in &bundle.nodes {
if node.kind != NodeKind::Group {
continue;
}
let declares_routes = !node.bundle.route_templates.is_empty();
let declares_triggers = !node.bundle.trigger_templates.is_empty();
if !declares_routes && !declares_triggers {
continue;
}
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if ancestor_ids.contains(&g.id) {
r.routes = r.routes || declares_routes;
r.triggers = r.triggers || declares_triggers;
r.email = r.email
|| node
.bundle
.trigger_templates
.iter()
.any(|t| spec_is_email(&t.spec));
}
}
}
Ok(r)
}
/// 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> {
@@ -139,6 +749,10 @@ impl IntoResponse for ApplyError {
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "apply authz repo error");

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,495 @@
//! 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} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
/// **written earlier in the same transaction** (not just committed state).
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
/// a var set in the *same* `pic apply` rather than landing one apply late.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
/// transaction variants so the two can't drift.
const VAR_CANDIDATES_TAIL: &str = "\
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";
/// 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}));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -341,7 +341,10 @@ impl DevEmailSink {
}
fn push(&self, email: CapturedEmail) {
let mut q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut q = self
.captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while q.len() >= self.capacity {
q.pop_front();
}
@@ -351,7 +354,10 @@ impl DevEmailSink {
/// Newest-first snapshot of the captured mail.
#[must_use]
pub fn snapshot(&self) -> Vec<CapturedEmail> {
let q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let q = self
.captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
q.iter().rev().cloned().collect()
}
}

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,597 @@
//! 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::{AppId, 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 mut tx = self.pool.begin().await?;
// Interactive create: UI/API-owned (no project claim — §7.5).
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
tx.commit().await?;
Ok(g)
}
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 race a concurrent reparent.
acquire_structural_lock_tx(&mut tx).await?;
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
tx.commit().await?;
Ok(g)
}
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
delete_group_tx(&mut tx, id).await?;
tx.commit().await?;
Ok(())
}
}
// ----------------------------------------------------------------------------
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
//
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
// so group create/reparent/delete must run inside the caller's tx (not on the
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
// methods above delegate to them (begin → fn → commit) so there is one SQL
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
// the structure phase, so the cycle guard + parent writes serialize against
// concurrent reparents exactly as the single-shot `reparent` does.
// ----------------------------------------------------------------------------
/// Take the coarse structural lock inside the caller's transaction. Held until
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
pub(crate) async fn acquire_structural_lock_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), GroupRepositoryError> {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
/// conflict. (The structural lock is not required for a pure insert, but the
/// apply path holds it for the whole phase anyway.)
pub(crate) async fn create_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
owner_project: Option<Uuid>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.bind(owner_project)
.fetch_one(&mut **tx)
.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()),
}
}
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
/// against the IN-TX tree (so it sees parent writes made earlier in the same
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
pub(crate) async fn reparent_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
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),
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?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
/// with a clean conflict if it still has child groups or apps.
pub(crate) async fn delete_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
) -> Result<(), GroupRepositoryError> {
let counts: (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(&mut **tx)
.await?;
if counts.0 != 0 || counts.1 != 0 {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.0, counts.1
)));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id.into_inner())
.execute(&mut **tx)
.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() => {
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
}
Err(e) => Err(e.into()),
}
}
// ----------------------------------------------------------------------------
// Project ownership (§7, M3). A `projects` row is keyed by the stable, opaque
// key the CLI mints in `.picloud/`; `groups.owner_project` references it. These
// helpers are free functions (pool + tx variants) so the apply path can claim
// ownership inside the single apply transaction (first-commit-wins), while the
// read-only plan path can surface conflicts without writing.
// ----------------------------------------------------------------------------
/// Resolve a project key to its id, if the project has ever been persisted.
/// `None` means no apply has claimed under this key yet (so `plan` treats every
/// node as claimable). Read-only — used by the plan path.
pub(crate) async fn get_project_id_by_key(
pool: &PgPool,
key: &str,
) -> Result<Option<Uuid>, GroupRepositoryError> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM projects WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.0))
}
/// Upsert the project keyed by `key` inside the apply tx and return its id.
/// Idempotent: re-applying the same repo reuses the same project identity.
pub(crate) async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key: &str,
) -> Result<Uuid, GroupRepositoryError> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO projects (key) VALUES ($1) \
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING id",
)
.bind(key)
.fetch_one(&mut **tx)
.await?;
Ok(row.0)
}
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
/// authoritative re-check. Joins through `projects` so the caller gets the
/// human-facing key for a conflict message.
pub(crate) async fn get_group_owner(
pool: &PgPool,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(pool)
.await?;
Ok(row)
}
/// The same owner lookup, but inside the apply tx — the authoritative read that
/// the claim/conflict decision keys on (so a concurrent claim that committed
/// after `plan` is seen under this tx's per-node advisory lock).
pub(crate) async fn get_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(&mut **tx)
.await?;
Ok(row)
}
/// Stamp (claim or take over) `group_id`'s owning project inside the apply tx.
/// Bumps `structure_version` so an ownership flip trips a bound plan token.
pub(crate) async fn set_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
project_id: Uuid,
) -> Result<(), GroupRepositoryError> {
let res = sqlx::query(
"UPDATE groups SET owner_project = $2, \
structure_version = structure_version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(group_id.into_inner())
.bind(project_id)
.execute(&mut **tx)
.await?;
if res.rows_affected() == 0 {
return Err(GroupRepositoryError::NotFound(group_id));
}
Ok(())
}
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
/// structural prune (those absent from the manifest are the ones to delete).
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
pub(crate) async fn list_owned_groups(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<(GroupId, String)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, slug FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug)| (id.into(), slug))
.collect())
}
/// The same owned-group enumeration inside the apply tx, with each node's
/// `parent_id` so the prune can delete leaf-first (children before parents).
pub(crate) async fn list_owned_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
) -> Result<Vec<(GroupId, String, Option<GroupId>)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String, Option<Uuid>)> =
sqlx::query_as("SELECT id, slug, parent_id FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(&mut **tx)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug, parent)| (id.into(), slug, parent.map(GroupId::from)))
.collect())
}
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
/// whole subtree of group ids, then every app whose `group_id` falls in it.
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
const DESCENDANT_APPS_SQL: &str = "\
WITH RECURSIVE subtree AS ( \
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
UNION ALL \
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
WHERE s.depth < 64 \
) \
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
/// Every app in the subtree rooted at `group_id` (the group itself and all
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
/// `plan` to size a template's true blast radius across the whole DB subtree,
/// not just the apps present in the current apply.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn descendant_app_ids(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<AppId>, GroupRepositoryError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
}
/// The same subtree enumeration inside the apply tx, so groups/apps created
/// earlier in this transaction (M2 structural reconcile) are included when
/// fanning a template out to its descendants (§4.5, M7).
pub(crate) async fn descendant_app_ids_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Vec<AppId>, GroupRepositoryError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
.bind(group_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
}
#[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,12 +80,22 @@ 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 {
// §4.3: a disabled script is not invocable via any path. Surface as
// NotFound (indistinguishable from absent), like the data plane.
return Err(InvokeError::NotFound(format!("id {script_id}")));
}
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,
@@ -97,15 +107,27 @@ 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:?}")))?;
if !script.enabled {
return Err(InvokeError::NotFound(format!("name {name:?}")));
}
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,
@@ -219,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,
@@ -304,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,
@@ -313,6 +357,7 @@ mod tests {
timeout_seconds: 30,
memory_limit_mb: 64,
sandbox: ScriptSandbox::default(),
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}

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;
@@ -74,6 +80,7 @@ pub mod secrets_api;
pub mod secrets_repo;
pub mod secrets_service;
pub mod ssrf;
pub mod template_repo;
pub mod topic_repo;
pub mod topics_api;
pub mod trigger_config;
@@ -81,6 +88,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 +153,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 +176,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 +209,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 +228,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,24 @@
//! `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::{
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
};
use sqlx::PgPool;
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
pub struct PostgresModuleSource {
pool: PgPool,
}
@@ -27,7 +33,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 +44,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,25 +55,132 @@ 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)
// 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))
}
async fn resolve_extension_point(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
// Find the nearest extension-point declaration of `name` on `origin`'s
// chain that is NOT shadowed by a concrete module of the same name at
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
// surviving declaration. An App origin can see ext points declared on
// an ancestor group (or on the app itself); a Group origin only walks
// groups — same trust boundary as `resolve`.
let (cte, ep_join, mod_join) = match origin {
ScriptOwner::App(_) => (
CHAIN_LEVELS_CTE,
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
),
ScriptOwner::Group(_) => (
GROUP_CHAIN_LEVELS_CTE,
"e.group_id = c.group_owner",
"s.group_id = c2.group_owner",
),
};
let query = format!(
"{cte} \
SELECT e.default_script_id \
FROM extension_points e \
JOIN chain c ON {ep_join} \
WHERE LOWER(e.name) = LOWER($2) \
AND NOT EXISTS ( \
SELECT 1 FROM scripts s \
JOIN chain c2 ON {mod_join} \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
AND c2.depth <= c.depth \
) \
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<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
.bind(root)
.bind(name)
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
Ok(row.map(|(default_script_id,)| ExtPointResolution {
default_script_id: default_script_id.map(Into::into),
}))
}
async fn resolve_by_id(
&self,
origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
// Load a module body by id (the ext-point fallback), but ONLY if it is
// owned by a node on `origin`'s chain — the same trust boundary as
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
// that ever pointed off-chain resolves to None instead of executing
// cross-tenant code. Endpoints are never importable (kind filter).
let (cte, join) = match origin {
ScriptOwner::App(_) => (
CHAIN_LEVELS_CTE,
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
),
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
};
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let row: Option<ModuleRow> = sqlx::query_as(&format!(
"{cte} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s JOIN chain c ON {join} \
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
))
.bind(root)
.bind(script_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;

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,
@@ -154,6 +204,8 @@ pub struct NewScript {
/// Sandbox overrides; `None` means store an empty object (use
/// platform defaults at exec time).
pub sandbox: Option<ScriptSandbox>,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// v1.1.3: literal-path `import "<name>"` declarations extracted
/// from the source. The repo writes these into `script_imports`
/// transactionally with the script row. Empty when validation
@@ -176,6 +228,8 @@ pub struct ScriptPatch {
/// rejects unsafe transitions (e.g. endpoint→module when routes
/// or triggers reference the script).
pub kind: Option<ScriptKind>,
/// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it.
pub enabled: Option<bool>,
/// v1.1.3: when `source` is also `Some`, the repo replaces the
/// `script_imports` edges for this script with these names.
/// `None` keeps the existing edges untouched (a name/description
@@ -202,8 +256,9 @@ 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, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
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";
#[async_trait]
impl ScriptRepository for PostgresScriptRepository {
@@ -232,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"
@@ -251,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,
@@ -392,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, \
timeout_seconds, memory_limit_mb, sandbox \
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
app_id, group_id, name, description, source, kind, \
timeout_seconds, memory_limit_mb, sandbox, enabled \
) 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)
@@ -405,19 +526,25 @@ pub(crate) async fn insert_script_tx(
.bind(input.timeout_seconds)
.bind(input.memory_limit_mb)
.bind(sandbox_json)
.bind(input.enabled)
.fetch_one(&mut **tx)
.await;
let script: Script = match res {
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)
}
@@ -441,6 +568,7 @@ pub(crate) async fn update_script_tx(
memory_limit_mb = COALESCE($7, memory_limit_mb), \
sandbox = COALESCE($8, sandbox), \
kind = COALESCE($9, kind), \
enabled = COALESCE($10, enabled), \
version = version + 1, \
updated_at = NOW() \
WHERE id = $1 \
@@ -455,6 +583,7 @@ pub(crate) async fn update_script_tx(
.bind(patch.memory_limit_mb)
.bind(sandbox_json)
.bind(patch.kind.map(ScriptKind::as_str))
.bind(patch.enabled)
.fetch_optional(&mut **tx)
.await;
let script: Script = match res {
@@ -462,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)
}
@@ -493,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,
@@ -505,6 +639,7 @@ struct ScriptRow {
timeout_seconds: i32,
memory_limit_mb: i32,
sandbox: serde_json::Value,
enabled: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -522,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,
@@ -531,6 +667,7 @@ impl From<ScriptRow> for Script {
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
sandbox,
enabled: r.enabled,
created_at: r.created_at,
updated_at: r.updated_at,
}

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,
@@ -229,6 +239,10 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
path: normalized_path,
method: input.method,
dispatch_mode: input.dispatch_mode,
// Routes are created active; toggling is a dedicated path.
enabled: true,
// Hand-declared via the interactive API — not a template expansion.
from_template: None,
})
.await?;
refresh_table(&state).await?;
@@ -394,6 +408,9 @@ async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
#[must_use]
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
rows.iter()
// A disabled route (§4.3) is dropped from the match table entirely, so
// a request to it 404s indistinguishably from an absent route.
.filter(|r| r.enabled)
.filter_map(|r| match compile_route(r) {
Ok(compiled) => Some(compiled),
Err(e) => {
@@ -625,6 +642,7 @@ mod tests {
path: path.to_string(),
method: None,
dispatch_mode: DispatchMode::default(),
enabled: true,
created_at: chrono::Utc::now(),
}
}
@@ -651,4 +669,16 @@ mod tests {
"a reserved-path route must be skipped, never abort the compile"
);
}
#[test]
fn disabled_route_is_dropped_from_compiled_table() {
// §4.3: a disabled route is excluded from the match table, so a
// request to it 404s indistinguishably from an absent route.
let active = route_with_path("/on");
let mut disabled = route_with_path("/off");
disabled.enabled = false;
let compiled = compile_routes(&[active.clone(), disabled.clone()]);
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
}
}

View File

@@ -21,6 +21,12 @@ pub struct NewRoute {
pub path: String,
pub method: Option<String>,
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
/// or `None` for a hand-declared route. Defaults `None` everywhere except
/// template expansion.
pub from_template: Option<Uuid>,
}
#[async_trait]
@@ -63,7 +69,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes ORDER BY created_at",
)
.fetch_all(&self.pool)
@@ -74,7 +80,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE id = $1",
)
.bind(route_id)
@@ -86,7 +92,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE app_id = $1 ORDER BY created_at",
)
.bind(app_id.into_inner())
@@ -101,7 +107,7 @@ impl RouteRepository for PostgresRouteRepository {
) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at",
)
.bind(script_id.into_inner())
@@ -173,10 +179,10 @@ pub(crate) async fn insert_route_tx(
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
path_kind, path, method, dispatch_mode, enabled, from_template \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at",
path_kind, path, method, dispatch_mode, enabled, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
@@ -187,6 +193,8 @@ pub(crate) async fn insert_route_tx(
.bind(&input.path)
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.bind(input.from_template)
.fetch_one(&mut **tx)
.await;
match res {
@@ -202,6 +210,12 @@ pub(crate) async fn insert_route_tx(
}
/// Delete a route by id within an existing transaction.
///
/// Unlike the non-tx [`RouteRepository::delete`], this is intentionally
/// idempotent: a missing row is not an error. The only caller is the
/// reconcile engine (`ApplyService`), where "delete a route already gone"
/// (e.g. removed out-of-band between the diff read and the write) is a
/// no-op to converge on, not a failure to roll back the whole apply.
pub(crate) async fn delete_route_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
route_id: Uuid,
@@ -225,6 +239,7 @@ struct RouteRow {
path: String,
method: Option<String>,
dispatch_mode: String,
enabled: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@@ -249,6 +264,7 @@ impl From<RouteRow> for Route {
path: r.path,
method: r.method,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
created_at: r.created_at,
}
}

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

@@ -0,0 +1,318 @@
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
//! group that the apply engine fans out into one concrete `routes` row per
//! descendant app. Mirrors the tx-accepting free-function pattern of
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
//! variants for the transactional apply (upsert/delete and chain expansion).
use picloud_shared::{DispatchMode, HostKind, PathKind};
use sqlx::PgPool;
use uuid::Uuid;
use crate::apply_service::RouteTemplateSpec;
use crate::group_repo::GroupRepositoryError as Error;
use picloud_shared::GroupId;
/// A persisted route template, decoded with its enum fields parsed — ready for
/// the diff (by name) and for expansion (synthesizing a concrete route).
#[derive(Debug, Clone)]
pub struct RouteTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub script_name: String,
pub method: Option<String>,
pub host_kind: HostKind,
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub dispatch_mode: DispatchMode,
pub enabled: bool,
}
#[derive(sqlx::FromRow)]
struct RouteTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
script_name: String,
method: Option<String>,
host_kind: String,
host: String,
host_param_name: Option<String>,
path_kind: String,
path: String,
dispatch_mode: String,
enabled: bool,
}
impl From<RouteTemplateRow> for RouteTemplate {
fn from(r: RouteTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
script_name: r.script_name,
method: r.method,
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,
"wildcard" => HostKind::Wildcard,
_ => HostKind::Any,
},
host: r.host,
host_param_name: r.host_param_name,
path_kind: match r.path_kind.as_str() {
"prefix" => PathKind::Prefix,
"param" => PathKind::Param,
_ => PathKind::Exact,
},
path: r.path,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
}
}
}
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled";
const fn host_kind_str(k: HostKind) -> &'static str {
match k {
HostKind::Any => "any",
HostKind::Strict => "strict",
HostKind::Wildcard => "wildcard",
}
}
const fn path_kind_str(k: PathKind) -> &'static str {
match k {
PathKind::Exact => "exact",
PathKind::Prefix => "prefix",
PathKind::Param => "param",
}
}
/// A group's OWN route templates (read/diff path).
pub(crate) async fn list_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<RouteTemplate>, Error> {
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
))
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every route template owned by any group in `group_ids` (expansion path,
/// in-tx so it sees templates just reconciled earlier in this apply).
pub(crate) async fn list_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<RouteTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
))
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new route template (diff Op::Create).
pub(crate) async fn insert_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"INSERT INTO route_templates ( \
group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
/// (diff Op::Update).
pub(crate) async fn update_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"UPDATE route_templates SET \
script_name = $3, method = $4, host_kind = $5, host = $6, \
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
enabled = $11, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a route template by name (diff Op::Delete under `--prune`).
pub(crate) async fn delete_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}
// ----------------------------------------------------------------------------
// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole
// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders
// unresolved); the apply rebuilds + resolves it per descendant.
// ----------------------------------------------------------------------------
/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger`
/// wire object.
#[derive(Debug, Clone)]
pub struct TriggerTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub spec: serde_json::Value,
}
#[derive(sqlx::FromRow)]
struct TriggerTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
spec: serde_json::Value,
}
impl From<TriggerTemplateRow> for TriggerTemplate {
fn from(r: TriggerTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
spec: r.spec,
}
}
}
/// A group's OWN trigger templates (read/diff path).
pub(crate) async fn list_triggers_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<TriggerTemplate>, Error> {
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
"SELECT id, group_id, name, spec FROM trigger_templates \
WHERE group_id = $1 ORDER BY LOWER(name)",
)
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every trigger template owned by any group in `group_ids` (expansion path).
pub(crate) async fn list_triggers_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<TriggerTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
"SELECT id, group_id, name, spec FROM trigger_templates \
WHERE group_id = ANY($1) ORDER BY LOWER(name)",
)
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new trigger template (diff Op::Create).
pub(crate) async fn insert_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
spec: &serde_json::Value,
) -> Result<(), Error> {
sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)")
.bind(group_id.into_inner())
.bind(name)
.bind(spec)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update).
pub(crate) async fn update_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
spec: &serde_json::Value,
) -> Result<(), Error> {
sqlx::query(
"UPDATE trigger_templates SET spec = $3, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(name)
.bind(spec)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a trigger template by name (Delete under `--prune`).
pub(crate) async fn delete_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}

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

@@ -34,6 +34,8 @@ pub struct Trigger {
pub id: TriggerId,
pub app_id: AppId,
pub script_id: ScriptId,
/// §4.5 per-app trigger identifier; the manifest merge/upsert key.
pub name: String,
pub kind: TriggerKind,
pub enabled: bool,
pub dispatch_mode: TriggerDispatchMode,
@@ -517,6 +519,7 @@ pub(crate) async fn insert_trigger_tx(
retry_backoff: BackoffShape,
retry_base_ms: u32,
details: &TriggerDetails,
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
let kind = match details {
TriggerDetails::Kv { .. } => "kv",
@@ -560,8 +563,8 @@ pub(crate) async fn insert_trigger_tx(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
registered_by_principal, from_template \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
@@ -571,6 +574,7 @@ pub(crate) async fn insert_trigger_tx(
.bind(retry_backoff.as_str())
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
.bind(registered_by.into_inner())
.bind(from_template)
.fetch_one(&mut **tx)
.await?;
let tid = row.0;
@@ -677,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx(
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
registered_by_principal, from_template \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.bind(registered_by.into_inner())
.bind(from_template)
.fetch_one(&mut **tx)
.await?;
sqlx::query(
@@ -735,7 +741,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'kv', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -766,6 +772,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Kv,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -800,7 +807,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'docs', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -831,6 +838,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Docs,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -863,7 +871,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -891,6 +899,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::DeadLetter,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -927,7 +936,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -957,6 +966,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Cron,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -992,7 +1002,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -1023,6 +1033,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Files,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -1056,7 +1067,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -1084,6 +1095,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Pubsub,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -1116,7 +1128,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -1143,6 +1155,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Email,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -1185,7 +1198,7 @@ impl TriggerRepo for PostgresTriggerRepo {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
@@ -1213,7 +1226,7 @@ impl TriggerRepo for PostgresTriggerRepo {
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
let parent: Option<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at \
FROM triggers WHERE id = $1",
@@ -1463,7 +1476,7 @@ impl TriggerRepo for PostgresTriggerRepo {
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'queue', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
@@ -1494,6 +1507,7 @@ impl TriggerRepo for PostgresTriggerRepo {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name.clone(),
kind: TriggerKind::Queue,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -1522,7 +1536,8 @@ impl TriggerRepo for PostgresTriggerRepo {
t.registered_by_principal \
FROM triggers t \
JOIN queue_trigger_details d ON d.trigger_id = t.id \
WHERE t.kind = 'queue' AND t.enabled = TRUE",
JOIN scripts s ON s.id = t.script_id \
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE",
)
.fetch_all(&self.pool)
.await?;
@@ -1700,6 +1715,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
name: parent.name,
kind,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
@@ -1742,6 +1758,7 @@ struct TriggerRow {
id: Uuid,
app_id: Uuid,
script_id: Uuid,
name: String,
kind: String,
enabled: bool,
dispatch_mode: String,

View File

@@ -38,7 +38,7 @@ use crate::trigger_repo::{
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
/// every 30s; an executor needs more wall-clock than this to do useful
/// work). Reject below this with a 422 + actionable error.
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
pub(crate) const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
/// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`
/// is unset. Re-exported from the dispatcher so the single source of
@@ -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"
)));
@@ -892,6 +902,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Kv,
enabled: true,
dispatch_mode: req.dispatch_mode,
@@ -920,6 +931,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Docs,
enabled: true,
dispatch_mode: req.dispatch_mode,
@@ -948,6 +960,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::DeadLetter,
enabled: true,
dispatch_mode: TriggerDispatchMode::Async,
@@ -977,6 +990,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: TriggerKind::Email,
enabled: true,
dispatch_mode: TriggerDispatchMode::Async,
@@ -1021,6 +1035,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Cron,
enabled: true,
dispatch_mode: req.dispatch_mode,
@@ -1050,6 +1065,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Files,
enabled: true,
dispatch_mode: req.dispatch_mode,
@@ -1078,6 +1094,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Pubsub,
enabled: true,
dispatch_mode: req.dispatch_mode,
@@ -1154,6 +1171,7 @@ mod tests {
id,
app_id,
script_id: req.script_id,
name: "mock".into(),
kind: TriggerKind::Queue,
enabled: true,
dispatch_mode: req.dispatch_mode,
@@ -1201,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,
},
@@ -1218,6 +1237,7 @@ mod tests {
_slug: &str,
_name: &str,
_description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
@@ -1226,6 +1246,7 @@ mod tests {
_slug: &str,
_name: &str,
_description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
@@ -1244,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,
@@ -1331,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 {
@@ -1347,6 +1375,7 @@ mod tests {
timeout_seconds: 30,
sandbox: picloud_shared::ScriptSandbox::default(),
memory_limit_mb: 256,
enabled: true,
created_at: now,
updated_at: now,
},
@@ -1375,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> {
@@ -1389,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,
@@ -1716,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,
},
@@ -1727,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
@@ -196,6 +198,15 @@ table: execution_logs
app_id: uuid NOT NULL
source: text NOT NULL default='http'::text
table: extension_points
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
name: text NOT NULL
default_script_id: uuid NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: files
app_id: uuid NOT NULL
collection: text NOT NULL
@@ -212,6 +223,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
@@ -242,6 +270,11 @@ table: outbox
claimed_by: text NULL
created_at: timestamp with time zone NOT NULL default=now()
table: projects
id: uuid NOT NULL default=gen_random_uuid()
key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: pubsub_trigger_details
trigger_id: uuid NOT NULL
topic_pattern: text NOT NULL
@@ -265,6 +298,22 @@ table: queue_trigger_details
visibility_timeout_secs: integer NOT NULL default=30
last_fired_at: timestamp with time zone NULL
table: route_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
script_name: text NOT NULL
method: text NULL
host_kind: text NOT NULL
host: text NOT NULL default=''::text
host_param_name: text NULL
path_kind: text NOT NULL
path: text NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: routes
id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL
@@ -277,6 +326,8 @@ 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
from_template: uuid NULL
table: script_imports
app_id: uuid NOT NULL
@@ -295,17 +346,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
@@ -315,6 +370,14 @@ table: topics
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: trigger_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
spec: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: triggers
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
@@ -328,6 +391,19 @@ 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
from_template: uuid NULL
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 +471,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)
@@ -426,6 +503,14 @@ indexes on execution_logs:
execution_logs_pkey: public.execution_logs USING btree (id)
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on extension_points:
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
extension_points_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_id IS NOT NULL)
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
extension_points_pkey: public.extension_points USING btree (id)
indexes on files:
files_pkey: public.files USING btree (app_id, collection, id)
idx_files_app_collection: public.files USING btree (app_id, collection)
@@ -433,6 +518,16 @@ 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_owner_project_idx: public.groups USING btree (owner_project)
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)
@@ -445,6 +540,10 @@ indexes on outbox:
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
outbox_pkey: public.outbox USING btree (id)
indexes on projects:
projects_key_key: public.projects USING btree (key)
projects_pkey: public.projects USING btree (id)
indexes on pubsub_trigger_details:
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
@@ -458,8 +557,13 @@ indexes on queue_trigger_details:
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
indexes on route_templates:
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
route_templates_pkey: public.route_templates USING btree (id)
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_from_template_idx: public.routes USING btree (app_id, from_template)
routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (id)
routes_script_id_idx: public.routes USING btree (script_id)
@@ -473,22 +577,39 @@ 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)
indexes on trigger_templates:
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
trigger_templates_pkey: public.trigger_templates USING btree (id)
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_from_template_idx: public.triggers USING btree (app_id, from_template)
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 +682,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)
@@ -597,6 +719,13 @@ constraints on execution_logs:
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on extension_points:
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] extension_points_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
constraints on files:
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
@@ -605,6 +734,18 @@ 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_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
[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)
@@ -618,6 +759,10 @@ constraints on outbox:
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
constraints on projects:
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
[UNIQUE] projects_key_key: UNIQUE (key)
constraints on pubsub_trigger_details:
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
@@ -630,6 +775,13 @@ constraints on queue_trigger_details:
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on route_templates:
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
constraints on routes:
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
@@ -648,19 +800,26 @@ 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])))
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
constraints on trigger_templates:
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
constraints on triggers:
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
@@ -670,6 +829,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 +880,13 @@ 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
0051: extension points
0052: owner project
0053: templates
0054: trigger templates

View File

@@ -121,9 +121,21 @@ where
.resolve(id)
.await?
.ok_or(ApiError::NotFound(id))?;
// A disabled script (§4.3) is not invocable — 404, indistinguishable
// from an absent one (no info leak that the id exists but is off).
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();
@@ -146,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,
@@ -164,6 +176,7 @@ where
Ok(exec_response_to_http(outcome?))
}
#[allow(clippy::too_many_lines)]
async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>,
Extension(principal): Extension<Option<Principal>>,
@@ -221,6 +234,19 @@ where
.resolve(matched.matched.script_id)
.await?
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
// An enabled route bound to a disabled script (§4.3, §4.7) is unreachable.
// Return the SAME flat "no route matches" 404 as the unmatched case — not
// `NotFound(script_id)`, which would both leak the internal script id to an
// anonymous caller and be distinguishable from absent.
if !script.enabled {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no route matches {method} {path}")
})),
)
.into_response());
}
// Drain the body now that we know we'll execute. 10 MiB cap matches
// the conservative default response/request size in the blueprint.
@@ -603,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

@@ -34,6 +34,7 @@ toml = "0.8"
directories = "5"
rpassword = "7"
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
assert_cmd = "2"

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};
@@ -122,6 +123,19 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
/// extension-point declarations (for `pic pull`).
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
@@ -149,6 +163,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 +322,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 +697,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,31 +1181,122 @@ 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 body = serde_json::json!({ "bundle": bundle, "prune": prune });
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?;
// The apply endpoint returns 409 only for a stale bound plan
// (`StateMoved`): the app changed since `pic plan` recorded its
// token. Surface an actionable next step instead of a bare
// `HTTP 409`.
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 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).
/// `project_key` (§7, M3) lets the server report ownership conflicts and
/// structural-prune candidates for this repo.
pub async fn plan_tree(
&self,
bundle: &serde_json::Value,
project_key: Option<&str>,
) -> Result<TreePlanDto> {
let body = serde_json::json!({
"bundle": bundle,
"project_key": project_key,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
/// (§7, M3): the apply claims unclaimed declared groups for this project and
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
#[allow(clippy::too_many_arguments)]
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
env: Option<&str>,
approved_envs: &[String],
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
"project_key": project_key,
"allow_takeover": allow_takeover,
"env": env,
"approved_envs": approved_envs,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body)
.send()
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
// 409 covers both bound-plan staleness AND an ownership conflict; the
// server's message is self-explanatory for each, so surface it raw.
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}"));
}
decode(resp).await
}
}
@@ -952,6 +1321,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.
@@ -965,6 +1351,14 @@ pub struct PlanDto {
pub triggers: Vec<ChangeDto>,
#[serde(default)]
pub secrets: Vec<ChangeDto>,
#[serde(default)]
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: 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)]
pub state_token: String,
}
#[derive(Debug, Deserialize)]
@@ -975,6 +1369,73 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// One of an owner's OWN extension-point declarations (`pic pull`).
#[derive(Debug, Deserialize)]
pub struct ExtPointDto {
pub name: String,
#[serde(default)]
pub default: 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,
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
/// the conflict before `apply` refuses it.
#[serde(default)]
pub conflicts: Vec<OwnershipConflictDto>,
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
#[serde(default)]
pub structural_prunes: Vec<String>,
/// Route-template fan-out per declaring group (§4.5, M4a).
#[serde(default)]
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
/// Environments the project marks confirm-required (§4.2, M5).
#[serde(default)]
pub approvals_required: Vec<String>,
}
/// A single ownership conflict (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct OwnershipConflictDto {
pub slug: String,
pub owner_key: String,
}
/// One group's route-template blast radius (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct TemplateBlastRadiusDto {
pub group: String,
pub affected_apps: usize,
}
#[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>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub route_templates: Vec<ChangeDto>,
#[serde(default)]
pub trigger_templates: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
#[derive(Debug, Default, Deserialize)]
pub struct ApplyReportDto {
@@ -995,6 +1456,40 @@ 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 extension_points_created: u32,
#[serde(default)]
pub extension_points_updated: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub groups_created: u32,
#[serde(default)]
pub groups_reparented: u32,
#[serde(default)]
pub groups_claimed: u32,
#[serde(default)]
pub groups_taken_over: u32,
#[serde(default)]
pub groups_pruned: u32,
#[serde(default)]
pub route_templates_created: u32,
#[serde(default)]
pub route_templates_updated: u32,
#[serde(default)]
pub route_templates_deleted: u32,
#[serde(default)]
pub trigger_templates_created: u32,
#[serde(default)]
pub trigger_templates_updated: u32,
#[serde(default)]
pub trigger_templates_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>,
}
@@ -1027,6 +1522,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)]
@@ -1208,6 +1728,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>,
@@ -1219,9 +1770,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).
@@ -1250,6 +1864,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

@@ -1,31 +1,85 @@
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
//! manifest's desired state in one server-side transaction. Additive in
//! this milestone (creates + updates); pruning of stale resources lands
//! next.
//! manifest's desired state in one server-side transaction. Creates and
//! updates are applied; `--prune` additionally deletes live scripts/routes/
//! triggers absent from the manifest (secrets are never pruned).
use std::io::{IsTerminal, Write};
use std::path::Path;
use anyhow::Result;
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;
use crate::output::{KvBlock, OutputMode};
pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<()> {
pub async fn run(
manifest_path: &Path,
env: Option<&str>,
prune: bool,
yes: bool,
force: bool,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let manifest = Manifest::load(manifest_path)?;
let manifest = Manifest::load_with_env(manifest_path, env)?;
// Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
// admin-gated, audited per-env approval is enforced server-side only on the
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
// so rather than silently bypass a confirm-required env, refuse and direct
// the user to `--dir` (which carries the policy + `--approve` to the server).
if let (Some(env), Some(project)) = (env, &manifest.project) {
if project
.environments
.iter()
.any(|e| e.name == env && e.confirm)
{
anyhow::bail!(
"environment `{env}` is confirm-required by this project's [project] policy; \
single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
Use `pic apply --dir <root> --env {env} --approve {env}` instead."
);
}
}
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();
let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
if prune && !yes {
confirm_prune(&slug)?;
}
// Bound-plan check: replay the token from the last `pic plan` (for this
// 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 == slug)
.map(|l| l.state_token)
};
let report = client
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
.await?;
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!(
@@ -43,10 +97,260 @@ pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<
.field(
"triggers",
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
)
.field(
"vars",
format!(
"+{} ~{} -{}",
report.vars_created, report.vars_updated, report.vars_deleted
),
)
.field(
"extension-points",
format!(
"+{} ~{} -{}",
report.extension_points_created,
report.extension_points_updated,
report.extension_points_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0 || report.groups_reparented > 0 {
block.field(
"groups",
format!(
"+{} reparented {}",
report.groups_created, report.groups_reparented
),
);
}
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.
#[allow(clippy::too_many_arguments)]
pub async fn run_tree(
dir: &Path,
prune: bool,
yes: bool,
force: bool,
takeover: bool,
env: Option<&str>,
approve: &[String],
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
}
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
// local check just fails fast with a clear message. `approved` is the set
// sent on the wire.
let approved = resolve_approvals(env, &envs, approve)?;
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)
};
// This repo's stable project key (§7, M3): the server claims declared
// groups for it and refuses ones another project owns (unless --takeover).
let project_key = crate::linkstate::ensure_project_key(dir)
.context("establishing project identity in .picloud/")?;
let report = client
.apply_tree(
&bundle,
prune,
expected_token.as_deref(),
Some(&project_key),
takeover,
env,
&approved,
)
.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
),
)
.field(
"extension-points",
format!(
"+{} ~{} -{}",
report.extension_points_created,
report.extension_points_updated,
report.extension_points_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0
|| report.groups_reparented > 0
|| report.groups_pruned > 0
|| report.groups_claimed > 0
|| report.groups_taken_over > 0
{
block.field(
"groups",
format!(
"+{} reparented {} pruned {}",
report.groups_created, report.groups_reparented, report.groups_pruned
),
);
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
block.field(
"ownership",
format!(
"claimed {} taken-over {}",
report.groups_claimed, report.groups_taken_over
),
);
}
}
// Route templates (§4.5, M4a) — the template rows; their per-app route
// expansions are folded into the `routes` line above.
if report.route_templates_created > 0
|| report.route_templates_updated > 0
|| report.route_templates_deleted > 0
{
block.field(
"route-templates",
format!(
"+{} ~{} -{}",
report.route_templates_created,
report.route_templates_updated,
report.route_templates_deleted
),
);
}
if report.trigger_templates_created > 0
|| report.trigger_templates_updated > 0
|| report.trigger_templates_deleted > 0
{
block.field(
"trigger-templates",
format!(
"+{} ~{} -{}",
report.trigger_templates_created,
report.trigger_templates_updated,
report.trigger_templates_deleted
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}
block.print(mode);
Ok(())
}
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
/// If the selected `env` is confirm-required (per the root manifest's
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
/// interactive TTY confirmation that requires re-typing the env name. A blanket
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
/// wire (the server re-checks authoritatively). A non-gated env passes through.
fn resolve_approvals(
env: Option<&str>,
policy: &[crate::manifest::ManifestEnvironment],
approve: &[String],
) -> Result<Vec<String>> {
let mut approved: Vec<String> = approve.to_vec();
let Some(env) = env else {
return Ok(approved); // no env selected → nothing env-specific to gate
};
let gated = policy.iter().any(|e| e.name == env && e.confirm);
if !gated || approved.iter().any(|e| e == env) {
return Ok(approved);
}
// Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
// --approve explicitly; --yes is deliberately insufficient).
if std::io::stdin().is_terminal() {
eprint!(
"environment `{env}` requires explicit approval to apply. \
Type the environment name to approve, or anything else to abort: "
);
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read approval")?;
if answer.trim() == env {
approved.push(env.to_string());
return Ok(approved);
}
anyhow::bail!("aborted: environment `{env}` not approved");
}
anyhow::bail!(
"environment `{env}` requires explicit approval: re-run with `--approve {env}` \
(a blanket `--yes` does not cover a confirm-required environment)"
);
}
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
/// rather than silently deleting (review the deletions first with `pic plan`).
fn confirm_prune(slug: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"refusing to `apply --prune` non-interactively without `--yes`: prune \
deletes resources absent from the manifest and cannot be undone. \
Review with `pic plan`, then re-run with `--yes`."
);
}
eprint!(
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
absent from the manifest — and, for a tree apply, entire owned groups absent \
from the manifest. This cannot be undone. Continue? [y/N] "
);
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read confirmation")?;
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
anyhow::bail!("aborted");
}
Ok(())
}

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

@@ -0,0 +1,106 @@
//! `pic config --effective` — read-only view of an app's resolved
//! configuration, with secret values masked (§4.6).
//!
//! 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, 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<()> {
if !effective {
bail!("`pic config` currently supports only `--effective`");
}
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
// Resolve against the same env overlay as `pic plan`/`apply` so the
// 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> = eff.secrets.keys().cloned().collect();
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
for name in declared.union(&on_server) {
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
(true, true) => ("<set>", "managed"),
(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"),
};
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

@@ -0,0 +1,287 @@
//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a
//! `picloud.toml` describing a minimal working app (one `hello` endpoint +
//! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores
//! the project tool's `.picloud/` link-state directory.
//!
//! Offline by design — it never contacts the server. Run `pic plan` to
//! preview the create, then `pic apply` to deploy. Refuses to overwrite an
//! existing `picloud.toml` unless `--force`.
use std::fs;
use std::path::Path;
use anyhow::{bail, Context, Result};
use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind};
use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE};
use crate::output::{KvBlock, OutputMode};
const HEADER: &str = "\
# picloud project manifest — the declarative desired state for one app.
# Edit, then `pic plan` to preview changes and `pic apply` to reconcile.
# Scripts live under scripts/ and are referenced by `file`.
\n";
const EXAMPLES: &str = "\
\n# ---------------------------------------------------------------------------
# More to add (uncomment and adapt):
#
# [[scripts]]
# name = \"lib\"
# file = \"scripts/lib.rhai\"
# kind = \"module\" # default: endpoint
#
# [[routes]]
# script = \"hello\"
# method = \"POST\" # omit for ANY
# host_kind = \"any\"
# path_kind = \"param\" # exact | prefix | param
# path = \"/hello/:name\"
#
# [[triggers.cron]]
# script = \"hello\"
# schedule = \"0 0 * * * *\" # 6-field cron (seconds first)
# timezone = \"UTC\"
#
# [secrets] # names only — push values with `pic secret set`
# names = [\"STRIPE_KEY\"]
";
const HELLO_RHAI: &str = "\
// A minimal endpoint script. Its return value is the HTTP response body.
// `ctx` exposes the request; see the stdlib reference for the full SDK.
\"Hello from PiCloud!\"
";
const GITIGNORE_LINE: &str = ".picloud/";
pub fn run(
dir: &Path,
slug_arg: Option<&str>,
name_arg: Option<&str>,
force: bool,
mode: OutputMode,
) -> Result<()> {
let slug = resolve_slug(dir, slug_arg)?;
let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string);
let manifest_path = dir.join(MANIFEST_FILE);
if manifest_path.exists() && !force {
bail!(
"{} already exists; refusing to overwrite (use --force)",
manifest_path.display()
);
}
let manifest = scaffold_manifest(&slug, &name);
// Build the file as a commented header + the (valid, round-trippable)
// active manifest + commented examples. Rendering the active part through
// `to_toml` guarantees it parses and matches the wire model.
let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?);
fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?;
let hello_path = dir.join("scripts/hello.rhai");
let wrote_hello = !hello_path.exists();
if wrote_hello {
fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?;
}
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
ensure_gitignored(dir)?;
// Mint the repo's stable project identity (§7, M3) so the first tree apply
// claims its groups under a key that survives clones/CI. Gitignored.
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
let mut block = KvBlock::new();
block
.field("manifest", manifest_path.display().to_string())
.field("app", slug)
.field(
"scripts",
if wrote_hello {
"scripts/hello.rhai"
} else {
"scripts/hello.rhai (kept existing)"
}
.to_string(),
)
.field("next", "pic plan then pic apply".to_string());
block.print(mode);
Ok(())
}
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
Manifest {
app: Some(ManifestApp {
slug: slug.to_string(),
name: name.to_string(),
description: None,
}),
group: None,
project: None,
scripts: vec![ManifestScript {
name: "hello".into(),
file: "scripts/hello.rhai".into(),
kind: ScriptKind::Endpoint,
description: None,
timeout_seconds: None,
memory_limit_mb: None,
sandbox: None,
enabled: true,
}],
routes: vec![ManifestRoute {
script: "hello".into(),
method: Some("GET".into()),
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/hello".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}
/// Use the explicit slug if given, else derive one from the target
/// directory's name. Either way it must satisfy the app-slug rule.
fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result<String> {
if let Some(s) = slug_arg {
if !is_valid_slug(s) {
bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)");
}
return Ok(s.to_string());
}
// Derive from the directory's own name. Use the path as given first —
// `canonicalize` requires the dir to already exist, which breaks the
// natural `pic init --dir new-project` flow. Fall back to canonicalizing
// only when the path has no final component of its own (e.g. `.`).
let base = dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.or_else(|| {
dir.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
})
.unwrap_or_default();
let derived = slugify(&base);
if !is_valid_slug(&derived) {
bail!(
"could not derive a valid app slug from directory `{base}`; \
pass one explicitly, e.g. `pic init my-app`"
);
}
Ok(derived)
}
/// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes.
fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_string()
}
/// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`.
fn is_valid_slug(s: &str) -> bool {
if s.is_empty() || s.len() > 63 {
return false;
}
let mut chars = s.chars();
let first = chars.next().expect("non-empty checked above");
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// Title-case a slug for a default display name: `my-blog` → `My Blog`.
fn title_from_slug(slug: &str) -> String {
slug.split('-')
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
c.next().map_or_else(String::new, |f| {
f.to_ascii_uppercase().to_string() + c.as_str()
})
})
.collect::<Vec<_>>()
.join(" ")
}
/// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state).
/// Appends the line if missing; creates the file if absent. A repo without
/// git still gets a correct `.gitignore` for when it's initialized.
fn ensure_gitignored(dir: &Path) -> Result<()> {
let path = dir.join(".gitignore");
// Only a genuinely-absent file is treated as empty; an existing-but-
// unreadable `.gitignore` must error rather than be silently clobbered.
let existing = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).context("reading .gitignore"),
};
if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) {
return Ok(());
}
let mut next = existing;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(GITIGNORE_LINE);
next.push('\n');
fs::write(&path, next).context("updating .gitignore")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaffold_is_valid_and_round_trips() {
let m = scaffold_manifest("blog", "Blog");
let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap());
// The active manifest (header/examples are comments) must parse back
// to exactly the scaffold — the commented examples are inert.
let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML");
assert_eq!(parsed, m);
// And it's a deployable project: one endpoint + its route.
assert_eq!(parsed.scripts.len(), 1);
assert_eq!(parsed.routes.len(), 1);
assert_eq!(parsed.routes[0].script, "hello");
}
#[test]
fn slugify_and_validation() {
assert_eq!(slugify("My Blog!"), "my-blog");
assert_eq!(slugify(" weird__name "), "weird-name");
assert_eq!(slugify("Project (2026)"), "project-2026");
assert!(is_valid_slug("blog"));
assert!(is_valid_slug("a1-b2"));
assert!(!is_valid_slug(""));
assert!(!is_valid_slug("-leading"));
assert!(!is_valid_slug(&"a".repeat(64)));
}
#[test]
fn title_from_slug_humanizes() {
assert_eq!(title_from_slug("my-blog"), "My Blog");
assert_eq!(title_from_slug("api"), "Api");
}
}

View File

@@ -3,8 +3,11 @@ pub mod api_keys;
pub mod apply;
pub mod apps;
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;
pub mod logout;
@@ -19,4 +22,5 @@ pub mod secrets;
pub mod topics;
pub mod triggers;
pub mod users;
pub mod vars;
pub mod whoami;

View File

@@ -9,24 +9,118 @@ 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};
pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> {
/// 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)?;
let manifest = Manifest::load(manifest_path)?;
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
// 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.slug(), &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
}
}
render(&plan, mode);
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, _envs) = crate::discover::build_tree(dir, env)?;
// Mint/read this repo's project key so the server can report ownership
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
let project_key = crate::linkstate::ensure_project_key(dir).ok();
let plan = client.plan_tree(&bundle, project_key.as_deref()).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>); 8] = [
("script", &n.scripts),
("route", &n.routes),
("trigger", &n.triggers),
("secret", &n.secrets),
("var", &n.vars),
("extension-point", &n.extension_points),
("route-template", &n.route_templates),
("trigger-template", &n.trigger_templates),
];
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);
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
if mode != OutputMode::Json {
if !plan.conflicts.is_empty() {
eprintln!("\nownership conflicts (apply blocked without --takeover):");
for c in &plan.conflicts {
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
}
}
if !plan.structural_prunes.is_empty() {
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
for slug in &plan.structural_prunes {
eprintln!(" - {slug}");
}
}
if !plan.template_blast_radius.is_empty() {
eprintln!("\nroute-template blast radius (every descendant app in the DB subtree):");
for b in &plan.template_blast_radius {
eprintln!(" - {}{} app(s)", b.group, b.affected_apps);
}
}
if !plan.approvals_required.is_empty() {
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
for e in &plan.approvals_required {
eprintln!(" - {e}");
}
}
}
}
/// 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.
@@ -51,6 +145,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
if let Some(sb) = &s.sandbox {
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
}
obj.insert("enabled".into(), json!(s.enabled));
scripts.push(Value::Object(obj));
}
@@ -84,11 +179,58 @@ 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}`"))?,
);
}
// Extension points: name + optional default module. The server's
// `ExtPointSpec` deserializes this shape directly.
let extension_points: Vec<Value> = manifest
.extension_points
.iter()
.map(|e| {
let mut o = Map::new();
o.insert("name".into(), json!(e.name));
if let Some(d) = &e.default {
o.insert("default".into(), json!(d));
}
Value::Object(o)
})
.collect();
// Route templates (§4.5, M4a): name + the flattened route fields the
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
// deserializes. Group manifests only; the server rejects them on an app.
let route_templates = manifest
.route_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
// `{ name, <BundleTrigger> }`. Group manifests only.
let trigger_templates = manifest
.trigger_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({
"scripts": scripts,
"routes": routes,
"triggers": triggers,
"secrets": manifest.secrets.names,
"vars": vars,
"extension_points": extension_points,
"route_templates": route_templates,
"trigger_templates": trigger_templates,
}))
}
@@ -103,11 +245,13 @@ 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>); 6] = [
("script", &plan.scripts),
("route", &plan.routes),
("trigger", &plan.triggers),
("secret", &plan.secrets),
("var", &plan.vars),
("extension-point", &plan.extension_points),
];
for (kind, changes) in groups {
for c in changes {

View File

@@ -14,24 +14,59 @@ 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,
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
QueueTriggerSpec, MANIFEST_FILE,
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
};
use crate::output::{KvBlock, OutputMode};
pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
// Refuse to clobber an existing project (mirrors `pic init`). `pull`
// overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
// stray `pic pull <wrong-app>` in a populated dir would destroy local
// edits. Fail fast — before any network call or file write — unless the
// operator opted in with `--force`.
let manifest_path = dir.join(MANIFEST_FILE);
if !force && manifest_path.exists() {
anyhow::bail!(
"{} already exists; refusing to overwrite. Re-run with --force to \
replace it (and any colliding scripts/*.rhai).",
manifest_path.display()
);
}
// One GET per resource kind (routes are per-script, below).
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();
@@ -49,6 +84,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
path_kind: r.path_kind,
path: r.path,
dispatch_mode: r.dispatch_mode,
enabled: r.enabled,
});
}
}
@@ -62,8 +98,9 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
for s in &scripts {
if !is_safe_filename(&s.name) {
anyhow::bail!(
"script name {:?} is not filesystem-safe (contains a path \
separator, `..`, or a leading dot); cannot pull",
"script name {:?} is not filesystem-safe (a path separator, \
`..`, a leading dot, a control character, or longer than 200 \
bytes); cannot pull",
s.name
);
}
@@ -89,6 +126,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
} else {
Some(s.sandbox)
},
enabled: s.enabled,
});
}
@@ -172,28 +210,46 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
}
// Extension points (§5.5): the app's own declarations, exported so a
// re-applied pulled manifest doesn't prune them.
let extension_points: Vec<ManifestExtensionPoint> = client
.extension_points_list(app_ident)
.await?
.into_iter()
.map(|e| ManifestExtensionPoint {
name: e.name,
default: e.default,
})
.collect();
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,
project: None,
scripts: manifest_scripts,
routes,
triggers: manifest_triggers,
secrets: ManifestSecrets {
names: secrets.iter().map(|s| s.name.clone()).collect(),
},
vars: manifest_vars,
extension_points,
// `pull` is app-scoped; templates are group-owned (§4.5).
route_templates: Vec::new(),
trigger_templates: Vec::new(),
};
let manifest_path = dir.join(MANIFEST_FILE);
std::fs::write(&manifest_path, manifest.to_toml()?)
.with_context(|| format!("writing {}", manifest_path.display()))?;
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())
@@ -207,14 +263,35 @@ fn trigger_count(t: &ManifestTriggers) -> usize {
}
/// True if `name` is safe to use as a single path component in `scripts/`.
/// Rejects empty names, path separators, `.`/`..`, and leading dots.
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
/// and any deceptive display character — a server-returned name is otherwise
/// written verbatim as a filename and printed to the operator's terminal.
fn is_safe_filename(name: &str) -> bool {
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
// `.rhai` suffix.
const MAX_LEN: usize = 200;
!name.is_empty()
&& name.len() <= MAX_LEN
&& !name.starts_with('.')
&& !name.contains('/')
&& !name.contains('\\')
&& name != ".."
&& !name.contains('\0')
&& !name.chars().any(is_deceptive_char)
}
/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
/// bidirectional-override and zero-width/format characters used to spoof how a
/// name renders in a terminal — both classes are unsafe to print verbatim.
fn is_deceptive_char(c: char) -> bool {
c.is_control()
|| matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks
| '\u{2028}'..='\u{2029}' // line / paragraph separators
| '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
| '\u{2060}' // word joiner
| '\u{2066}'..='\u{2069}' // bidi isolates
| '\u{FEFF}' // BOM / zero-width no-break space
)
}
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
@@ -276,6 +353,29 @@ mod tests {
}
}
#[test]
fn rejects_control_chars_and_overlong() {
for bad in [
"a\nb",
"a\tb",
"line\rdrop",
"esc\x1b[2Jseq",
"rtl\u{202E}gpj.exe", // bidi override (filename spoof)
"zero\u{200B}width", // zero-width space
"bom\u{FEFF}name", // BOM
] {
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
}
assert!(
!is_safe_filename(&"a".repeat(201)),
"expected an over-long name to be rejected"
);
assert!(
is_safe_filename(&"a".repeat(200)),
"a 200-char name is at the limit and allowed"
);
}
#[test]
fn accepts_normal_names() {
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {

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,151 @@
//! 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::{HashMap, 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}], project }`)
/// from every manifest under `root`. Returns the JSON, the node count, and the
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
/// but the root manifest.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
let paths = find_manifests(root)?;
if paths.is_empty() {
bail!(
"no {MANIFEST_FILE} found under {} — nothing to apply",
root.display()
);
}
let root_manifest = root.join(MANIFEST_FILE);
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
// so a group node's parent can be inferred from the enclosing directory.
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
let mut project: Option<crate::manifest::ManifestProject> = None;
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
// `[project]` is project-level — valid only on the tree's root manifest.
if let Some(p) = &manifest.project {
if path == &root_manifest {
project = Some(p.clone());
} else {
bail!(
"{} declares [project], which is only valid on the root manifest ({})",
path.display(),
root_manifest.display()
);
}
}
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
if manifest.is_group() {
group_dirs.insert(dir.clone(), manifest.slug().to_string());
}
loaded.push((dir, manifest));
}
let mut nodes = Vec::with_capacity(loaded.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for (dir, manifest) in &loaded {
let bundle = build_bundle(manifest, 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");
}
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
// Group nodes carry their parent (explicit `[group] parent`, else the
// nearest ancestor directory's group) + display name, so the server can
// create/reparent them (M2). App nodes ignore both server-side.
if let Some(g) = &manifest.group {
let parent = g
.parent
.clone()
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
let obj = node.as_object_mut().expect("json object");
obj.insert("name".into(), json!(g.name));
if let Some(p) = parent {
obj.insert("parent_slug".into(), json!(p));
}
}
nodes.push(node);
}
let count = nodes.len();
let envs = project
.as_ref()
.map(|p| p.environments.clone())
.unwrap_or_default();
let mut bundle = json!({ "nodes": nodes });
if let Some(p) = &project {
bundle.as_object_mut().expect("json object").insert(
"project".into(),
json!({
"environments": p.environments.iter()
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
.collect::<Vec<_>>(),
}),
);
}
Ok((bundle, count, envs))
}
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
/// manifest — the directory-derived parent for a nested group node.
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
let mut cur = dir.parent();
while let Some(d) = cur {
if let Some(slug) = group_dirs.get(d) {
return Some(slug.clone());
}
cur = d.parent();
}
None
}

View File

@@ -0,0 +1,120 @@
//! `.picloud/` link state — gitignored, per-project metadata the project tool
//! carries between CLI invocations. Today it holds just the bound-plan token:
//! `pic plan` records the fingerprint of the live state it diffed against, and
//! `pic apply` replays it so the server can refuse if the app moved underneath.
//!
//! All paths are relative to the manifest's directory (the project root).
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const DIR: &str = ".picloud";
const PLAN_FILE: &str = "plan.json";
const PROJECT_FILE: &str = "project.json";
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
/// ownership handle — the same repo keeps the same project across clones/CI
/// because the key travels in the working tree, never in a committed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLink {
pub key: String,
}
fn project_path(base: &Path) -> PathBuf {
base.join(DIR).join(PROJECT_FILE)
}
/// Read the project key, if one has been minted under `base/.picloud/`.
#[must_use]
pub fn read_project_key(base: &Path) -> Option<String> {
let body = fs::read(project_path(base)).ok()?;
serde_json::from_slice::<ProjectLink>(&body)
.ok()
.map(|l| l.key)
}
/// Read the project key, minting and persisting a fresh one if absent. Used by
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
/// a stable ownership identity on first use. The new key is a random v4 UUID.
pub fn ensure_project_key(base: &Path) -> Result<String> {
if let Some(k) = read_project_key(base) {
return Ok(k);
}
let key = uuid::Uuid::new_v4().to_string();
write_project_key(base, &key)?;
Ok(key)
}
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
/// if needed. Idempotent overwrite — callers usually go through
/// [`ensure_project_key`].
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let body = serde_json::to_vec_pretty(&ProjectLink {
key: key.to_string(),
})
.context("encoding .picloud/project.json")?;
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
Ok(())
}
/// The recorded result of the last `pic plan`, scoped to the app it was for.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanLink {
/// App slug the token belongs to — guards against replaying a token from a
/// different app if the manifest's `slug` changed.
pub app: String,
pub state_token: String,
}
fn plan_path(base: &Path) -> PathBuf {
base.join(DIR).join(PLAN_FILE)
}
/// Record the bound-plan token for `app` under `base/.picloud/`.
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
// dir out of git regardless of the project root's `.gitignore` — so this
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
// don't touch the root `.gitignore`).
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let link = PlanLink {
app: app.to_string(),
state_token: state_token.to_string(),
};
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
Ok(())
}
/// Read the recorded plan token, if any. Returns `None` when absent or
/// unreadable (treated as "no prior plan" — never an error).
#[must_use]
pub fn read_plan(base: &Path) -> Option<PlanLink> {
let body = fs::read(plan_path(base)).ok()?;
serde_json::from_slice(&body).ok()
}
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
/// Called after a successful apply consumes it, so the next apply requires a
/// fresh plan — without clobbering a token recorded for a different app that
/// happens to share the directory.
pub fn clear_plan(base: &Path, app: &str) {
if read_plan(base).is_some_and(|l| l.app == app) {
let _ = fs::remove_file(plan_path(base));
}
}

View File

@@ -12,6 +12,8 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
mod client;
mod cmds;
mod config;
mod discover;
mod linkstate;
mod manifest;
mod output;
@@ -50,6 +52,12 @@ enum Cmd {
cmd: AppsCmd,
},
/// Group (org-tree) management.
Groups {
#[command(subcommand)]
cmd: GroupsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
@@ -137,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 {
@@ -159,7 +175,8 @@ enum Cmd {
},
/// Reconcile the live app to a `picloud.toml` manifest in one
/// transaction (additive: creates + updates).
/// transaction (creates + updates; `--prune` also deletes resources
/// absent from the manifest).
Apply(ApplyArgs),
/// Diff a `picloud.toml` manifest against the live app and print the
@@ -170,6 +187,15 @@ enum Cmd {
/// (+ `scripts/<name>.rhai` sources) for declarative management with
/// `pic plan` / `pic apply`.
Pull(PullArgs),
/// Scaffold a new declarative project: a `picloud.toml` (minimal working
/// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with
/// `pic plan` then `pic apply`.
Init(InitArgs),
/// Show an app's resolved configuration. `--effective` lists secrets with
/// values masked (`<set>`/`<unset>`), cross-referenced against the manifest.
Config(ConfigArgs),
}
#[derive(Args)]
@@ -177,10 +203,36 @@ 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)]
prune: bool,
/// Skip the `--prune` confirmation prompt. Required to prune
/// non-interactively (CI).
#[arg(long)]
yes: bool,
/// Skip the bound-plan staleness check (apply even if the app changed
/// since the last `pic plan`).
#[arg(long)]
force: bool,
/// Tree apply only (`--dir`): seize declared groups currently owned by
/// another project (§7). Requires group-admin on each contested node.
#[arg(long, requires = "dir")]
takeover: bool,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before applying.
#[arg(long)]
env: Option<String>,
/// Tree apply only (`--dir`): explicitly approve applying to a
/// confirm-required environment (per the root manifest's `[project]`
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
#[arg(long = "approve", requires = "dir")]
approve: Vec<String>,
}
#[derive(Args)]
@@ -188,6 +240,14 @@ 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)]
env: Option<String>,
}
#[derive(Args)]
@@ -197,6 +257,43 @@ struct PullArgs {
/// Directory to write `picloud.toml` + `scripts/` into.
#[arg(long, default_value = ".")]
dir: PathBuf,
/// Overwrite an existing `picloud.toml` (and colliding `scripts/*.rhai`).
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct InitArgs {
/// App slug for the new project. Defaults to a slug derived from the
/// target directory's name.
slug: Option<String>,
/// Directory to scaffold into.
#[arg(long, default_value = ".")]
dir: PathBuf,
/// Display name for the app. Defaults to a title-cased slug.
#[arg(long)]
name: Option<String>,
/// Overwrite an existing `picloud.toml`.
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct ConfigArgs {
/// Path to the manifest.
#[arg(long, default_value = "picloud.toml")]
file: PathBuf,
/// 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)]
env: Option<String>,
}
#[derive(Subcommand)]
@@ -341,6 +438,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.
@@ -363,6 +463,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.
@@ -379,15 +548,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`,
@@ -401,8 +574,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)]
@@ -996,31 +1175,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>,
},
}
@@ -1086,9 +1348,54 @@ async fn main() -> ExitCode {
}
Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, mode).await,
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
Cmd::Apply(args) => match &args.dir {
Some(dir) => {
cmds::apply::run_tree(
dir,
args.prune,
args.yes,
args.force,
args.takeover,
args.env.as_deref(),
&args.approve,
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(),
mode,
)
.await
}
Cmd::Init(args) => cmds::init::run(
&args.dir,
args.slug.as_deref(),
args.name.as_deref(),
args.force,
mode,
),
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
Cmd::Apps {
cmd:
@@ -1096,8 +1403,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,
@@ -1121,16 +1438,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,
@@ -1173,7 +1564,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,
@@ -1500,14 +1892,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,
@@ -1520,6 +1937,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,16 @@ 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>,
/// `[project]` — project-level policy (M5), consumed only by `apply --dir`
/// and only on the tree's ROOT manifest (enforced in `build_tree`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ManifestProject>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ManifestScript>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -38,12 +48,74 @@ 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>,
/// `[[extension_points]]` — module names this node opens for per-tenant
/// resolution (§5.5). Allowed on both app and group manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<ManifestExtensionPoint>,
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
/// concrete route on every descendant app at apply time (§4.5, M4a). String
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub route_templates: Vec<ManifestRouteTemplate>,
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
/// `script = …`, plus the kind's params (string fields may carry
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trigger_templates: Vec<ManifestTriggerTemplate>,
}
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`.
@@ -58,6 +130,90 @@ impl Manifest {
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self).context("serializing manifest TOML")
}
/// 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, 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 {
let path = overlay_path(base_path, env);
let body = fs::read_to_string(&path).with_context(|| {
format!(
"reading overlay {} for env `{env}` (expected next to the base manifest)",
path.display()
)
})?;
let overlay: ManifestOverlay = toml::from_str(&body)
.with_context(|| format!("parsing overlay {}", path.display()))?;
base.apply_overlay(overlay);
}
Ok(base)
}
/// 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(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);
}
}
}
/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
let name = base_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| MANIFEST_FILE.to_string());
let stem = name.strip_suffix(".toml").unwrap_or(&name);
parent.join(format!("{stem}.{env}.toml"))
}
/// 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]`, `[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 {
#[serde(default)]
pub app: OverlayApp,
#[serde(default)]
pub secrets: ManifestSecrets,
#[serde(default)]
pub vars: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OverlayApp {
#[serde(default)]
pub slug: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -68,6 +224,42 @@ pub struct ManifestApp {
pub description: Option<String>,
}
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
/// the parent is inferred from the enclosing directory's group manifest (or the
/// instance root for a top-level group).
#[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>,
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
/// `[project]` — project-level policy (§4.2/§6, M5). Valid ONLY on the root
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestProject {
/// `[[project.environments]]` — per-env apply policy.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub environments: Vec<ManifestEnvironment>,
}
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
/// env behind an explicit `pic apply --approve <name>` (M5).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEnvironment {
pub name: String,
#[serde(default)]
pub confirm: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestScript {
pub name: String,
@@ -84,6 +276,13 @@ pub struct ManifestScript {
/// Per-script sandbox overrides; omitted entirely when no knob is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
/// invocable). Omitted ⇒ active; only serialized when disabled.
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -102,6 +301,50 @@ pub struct ManifestRoute {
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
/// Omitted ⇒ active; only serialized when disabled.
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// `[[route_templates]]` — a route declared once on a group, fanned out per
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
/// (the per-group identity/upsert key).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestRouteTemplate {
/// Per-group template name (identity/upsert key).
pub name: String,
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub host_kind: HostKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggerTemplate {
pub name: String,
#[serde(flatten)]
pub spec: toml::Value,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
@@ -234,6 +477,17 @@ impl ManifestSecrets {
}
}
/// `[[extension_points]]` — a module name opened for per-tenant resolution
/// (§5.5). A parent/group script importing `name` resolves it against the
/// inheriting app's module instead of the sealed lexical chain; `default`
/// names a local module used when an app provides none.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestExtensionPoint {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
// ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool {
@@ -244,6 +498,11 @@ fn is_sync(mode: &DispatchMode) -> bool {
*mode == DispatchMode::Sync
}
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
fn is_true(b: &bool) -> bool {
*b
}
fn default_timezone() -> String {
"UTC".to_string()
}
@@ -254,11 +513,13 @@ mod tests {
fn sample() -> Manifest {
Manifest {
app: ManifestApp {
app: Some(ManifestApp {
slug: "blog".into(),
name: "My Blog".into(),
description: Some("demo".into()),
},
}),
group: None,
project: None,
scripts: vec![
ManifestScript {
name: "create-post".into(),
@@ -268,6 +529,7 @@ mod tests {
timeout_seconds: Some(10),
memory_limit_mb: Some(256),
sandbox: None,
enabled: true,
},
ManifestScript {
name: "lib".into(),
@@ -280,6 +542,7 @@ mod tests {
max_operations: Some(5_000_000),
..ScriptSandbox::empty()
}),
enabled: false,
},
],
routes: vec![ManifestRoute {
@@ -291,6 +554,7 @@ mod tests {
path_kind: PathKind::Exact,
path: "/posts".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: ManifestTriggers {
cron: vec![CronTriggerSpec {
@@ -312,6 +576,16 @@ 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)),
]),
extension_points: vec![ManifestExtensionPoint {
name: "theme".into(),
default: Some("default-theme".into()),
}],
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}
@@ -337,26 +611,156 @@ mod tests {
);
// Module kind IS non-default → emitted.
assert!(text.contains("kind = \"module\""), "got:\n{text}");
// `enabled` defaults true → omitted for the active script/route, but
// the disabled `lib` script emits `enabled = false`.
assert!(
text.contains("enabled = false"),
"a disabled entity must emit enabled:\n{text}"
);
assert!(
!text.contains("enabled = true"),
"active entities must omit the default:\n{text}"
);
}
#[test]
fn overlay_merges_slug_and_unions_secrets() {
let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
let overlay: ManifestOverlay = toml::from_str(
"[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
)
.unwrap();
m.apply_overlay(overlay);
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()],
"secrets union, no dupes"
);
// Scripts/routes come from the base, untouched by the overlay.
assert_eq!(m.scripts.len(), 2);
}
#[test]
fn overlay_rejects_non_overlay_tables() {
// An overlay carries only [app]/[secrets]. Scripts/routes/triggers
// belong in the shared base, so a `[[scripts]]` table (or a typo'd
// key) in an overlay must error loudly, not be silently dropped.
let err = toml::from_str::<ManifestOverlay>(
"[app]\nslug = \"blog-staging\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
)
.expect_err("overlay with [[scripts]] must be rejected");
assert!(
err.to_string().contains("scripts") || err.to_string().contains("unknown"),
"error should point at the offending table: {err}"
);
// Typo'd key inside [app] is likewise rejected.
toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
.expect_err("overlay with a typo'd [app] key must be rejected");
}
#[test]
fn overlay_path_derivation() {
assert_eq!(
overlay_path(Path::new("proj/picloud.toml"), "staging"),
Path::new("proj/picloud.staging.toml")
);
assert_eq!(
overlay_path(Path::new("custom.toml"), "prod"),
Path::new("custom.prod.toml")
);
}
#[test]
fn empty_optional_sections_omitted() {
let m = Manifest {
app: ManifestApp {
app: Some(ManifestApp {
slug: "x".into(),
name: "X".into(),
description: None,
},
}),
group: None,
project: None,
scripts: vec![],
routes: vec![],
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::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}");
assert!(!text.contains("extension_points"), "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 project_block_parses_environment_policy() {
let m = Manifest::parse(
"[app]\nslug = \"a\"\nname = \"A\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\n",
)
.expect("manifest with [project] parses");
let p = m.project.expect("project present");
assert_eq!(p.environments.len(), 2);
assert_eq!(p.environments[0].name, "production");
assert!(p.environments[0].confirm);
// `confirm` defaults to false when omitted.
assert_eq!(p.environments[1].name, "staging");
assert!(!p.environments[1].confirm);
}
#[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

@@ -0,0 +1,169 @@
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
//! * a root manifest `[project]` block marks an environment confirm-required,
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
//! does not cover it) — refused non-interactively at the CLI,
//! * `--approve <env>` (as an admin) lets it through,
//! * a non-gated environment applies with plain `--yes`,
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
//! editor with `--approve` is refused server-side (403).
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
/// A single-app project dir whose root manifest declares a `[project]` policy:
/// `production` is confirm-required, `staging` is not.
fn project_dir(app: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
// A `[vars]` entry so the app bundle has write-requiring content: an `editor`
// member must hold (and exercise) AppVarsWrite to pass authz_tree — which
// makes the admin-gate test prove the approval gate is ABOVE editor-write,
// not merely "any non-admin is refused". (Vars cascade-delete with the app,
// unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.)
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
// `--env <e>` requires the overlay file to exist; empty overlays keep the
// base slug (same app across envs — we're testing the gate, not env routing).
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approval() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr-g");
let app = common::unique_slug("appr-a");
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 = project_dir(&app);
// --- production is confirm-required: --yes alone is refused. ---
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
assert!(
!out.status.success(),
"production apply without --approve must be refused"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("approve"),
"refusal should mention --approve:\n{err}"
);
// --- with --approve production, it applies. ---
let ok = apply(
&env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
ok.status.success(),
"approved production apply should succeed: {}",
String::from_utf8_lossy(&ok.stderr)
);
// --- staging is NOT gated: plain --yes applies. ---
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
assert!(
ok2.status.success(),
"non-gated staging apply should succeed: {}",
String::from_utf8_lossy(&ok2.stderr)
);
// --- single-node `apply --file` to a gated env is refused (no silent
// bypass): the admin-gated approval is a `--dir` feature. ---
let single = common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.args(["--env", "production", "--yes"])
.output()
.expect("apply --file");
assert!(
!single.status.success(),
"single-node apply to a confirm-required env must be refused"
);
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
assert!(
serr.contains("confirm-required") || serr.contains("--dir"),
"single-node refusal should point at --dir:\n{serr}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn approving_a_gated_apply_requires_admin() {
// §4.2: approving a confirm-required env is admin-gated — a second gate on
// top of the editor-level write caps an ordinary apply needs. An editor who
// CAN write the app still cannot approve a gated apply.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr2-g");
let app = common::unique_slug("appr2-a");
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();
// A member with `editor` (write) on the app — enough for an ordinary apply,
// not enough to approve a gated environment.
let m = member::member_user(fx, &common::unique_username("appr"));
member::grant_membership(fx, &app, &m.id, "editor");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let dir = project_dir(&app);
let out = apply(
&member_env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
!out.status.success(),
"a non-admin editor must not be able to approve a gated apply"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"approval denial should be an authz error:\n{err}"
);
}

View File

@@ -16,13 +16,24 @@ mod common;
mod admins;
mod api_keys;
mod apply;
mod approval;
mod apps;
mod auth;
mod config;
mod dead_letters;
mod email_queue;
mod enabled;
mod env_overlay;
mod extension_points;
mod group_modules;
mod group_scripts;
mod group_secrets;
mod groups;
mod init;
mod invoke;
mod logs;
mod output;
mod ownership;
mod plan;
mod prune;
mod pull;
@@ -30,4 +41,9 @@ mod roles;
mod routes;
mod scripts;
mod secrets;
mod staleness;
mod templates;
mod tree;
mod tree_shape;
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

@@ -78,6 +78,26 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
);
}
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!(
"{}/api/v1/admin/groups/{}/members",
fx.url, group_slug
))
.bearer_auth(&fx.admin_token)
.json(&json!({ "user_id": user_id, "role": role }))
.send()
.expect("grant group membership");
assert!(
resp.status().is_success(),
"grant group membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();

View File

@@ -0,0 +1,57 @@
//! `pic config --effective` — masked secret resolution against the manifest.
use std::fs;
use predicates::prelude::*;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn config_effective_masks_and_classifies_secrets() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("config");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("picloud.toml");
fs::write(
&manifest_path,
format!("[app]\nslug = \"{slug}\"\nname = \"Cfg\"\n\n[secrets]\nnames = [\"API_KEY\"]\n"),
)
.unwrap();
// Declared but not pushed → masked + flagged unset; value never shown.
common::pic_as(&env)
.args(["config", "--effective", "--file"])
.arg(&manifest_path)
.assert()
.success()
.stdout(predicate::str::contains("API_KEY"))
.stdout(predicate::str::contains("<unset>"))
.stdout(predicate::str::contains("not pushed"));
// Push it → now masked as <set> / managed, still never the value.
common::pic_as(&env)
.args(["secrets", "set", "--app", &slug, "API_KEY"])
.write_stdin("super-secret-value")
.assert()
.success();
common::pic_as(&env)
.args(["config", "--effective", "--file"])
.arg(&manifest_path)
.assert()
.success()
.stdout(predicate::str::contains("<set>"))
.stdout(predicate::str::contains("managed"))
.stdout(predicate::str::contains("super-secret-value").not());
}

View File

@@ -135,7 +135,7 @@ fn prune_refuses_to_orphan_email_trigger() {
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.arg("--prune")
.args(["--prune", "--yes"])
.output()
.expect("apply --prune");
assert!(

View File

@@ -0,0 +1,226 @@
//! `enabled` three-state lifecycle, end to end: disabling a script via the
//! manifest makes it non-invocable (404 on the execute-by-id bypass), and
//! re-enabling restores it — proving the data path + runtime honoring.
use std::fs;
use std::path::Path;
use serde_json::Value;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn disabling_a_script_makes_it_uninvocable_then_reenable() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("enabled");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
let manifest_path = dir.path().join("picloud.toml");
let manifest = |enabled_line: &str| {
format!(
"[app]\nslug = \"{slug}\"\nname = \"Enabled\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n{enabled_line}"
)
};
// Active → invocable.
fs::write(&manifest_path, manifest("")).unwrap();
apply(&env, &manifest_path);
let id = script_id(&env, &slug, "hello");
assert_eq!(invoke_status(&env, &id), 200, "active script must invoke");
// Disabled → 404 (not invocable), but still deployed (re-pull would show it).
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
apply(&env, &manifest_path);
assert_eq!(
invoke_status(&env, &id),
404,
"disabled script must 404 on execute-by-id"
);
// Re-enabled → invocable again.
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
apply(&env, &manifest_path);
assert_eq!(
invoke_status(&env, &id),
200,
"re-enabled script must invoke"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn disabling_a_route_makes_it_404_then_reenable() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("enbl-route");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
// The app must claim a Host before its routes are reachable (two-phase
// dispatch: Host → app → route). A unique strict host avoids colliding
// with other tests' instance-global claims.
let host = format!("{slug}.test");
common::pic_as(&env)
.args(["apps", "domains", "add", &slug, &host])
.assert()
.success();
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
let manifest_path = dir.path().join("picloud.toml");
let manifest = |enabled_line: &str| {
format!(
"[app]\nslug = \"{slug}\"\nname = \"EnabledRoute\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n\n\
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n{enabled_line}"
)
};
// Active → the route serves.
fs::write(&manifest_path, manifest("")).unwrap();
apply(&env, &manifest_path);
assert_eq!(
route_status(&env, &host, "/hello"),
200,
"active route serves"
);
// Disabled → 404, indistinguishable from absent.
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
apply(&env, &manifest_path);
assert_eq!(
route_status(&env, &host, "/hello"),
404,
"disabled route must 404"
);
// Re-enabled → serves again.
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
apply(&env, &manifest_path);
assert_eq!(
route_status(&env, &host, "/hello"),
200,
"re-enabled route serves"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn enabled_route_to_disabled_script_404s_flatly() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("enbl-os");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let host = format!("{slug}.test");
common::pic_as(&env)
.args(["apps", "domains", "add", &slug, &host])
.assert()
.success();
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
let manifest_path = dir.path().join("picloud.toml");
// Route stays enabled; the SCRIPT it binds is disabled (route-on/script-off).
fs::write(
&manifest_path,
format!(
"[app]\nslug = \"{slug}\"\nname = \"OS\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\nenabled = false\n\n\
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n"
),
)
.unwrap();
apply(&env, &manifest_path);
// 404, and the body must be the flat "no route matches" form — never the
// internal script id (no info leak; indistinguishable from absent).
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!("{}/hello", env.url))
.header(reqwest::header::HOST, &host)
.send()
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
let body = resp.text().unwrap();
assert!(body.contains("no route matches"), "flat 404 body: {body}");
}
fn apply(env: &common::TestEnv, manifest_path: &Path) {
common::pic_as(env)
.args(["apply", "--file"])
.arg(manifest_path)
.assert()
.success();
}
/// GET a user route under an explicit `Host` (the claimed domain) and return
/// the HTTP status code.
fn route_status(env: &common::TestEnv, host: &str, path: &str) -> u16 {
let client = reqwest::blocking::Client::new();
client
.get(format!("{}{path}", env.url))
.header(reqwest::header::HOST, host)
.send()
.unwrap()
.status()
.as_u16()
}
/// Resolve a script's id via the admin API (the manifest carries no ids).
fn script_id(env: &common::TestEnv, slug: &str, name: &str) -> String {
let client = reqwest::blocking::Client::new();
let scripts: Vec<Value> = client
.get(format!("{}/api/v1/admin/scripts?app={slug}", env.url))
.bearer_auth(&env.token)
.send()
.unwrap()
.json()
.unwrap();
scripts
.into_iter()
.find(|s| s["name"] == name)
.and_then(|s| s["id"].as_str().map(String::from))
.expect("script id")
}
/// POST the execute-by-id bypass and return the HTTP status code.
fn invoke_status(env: &common::TestEnv, id: &str) -> u16 {
let client = reqwest::blocking::Client::new();
client
.post(format!("{}/api/v1/execute/{id}", env.url))
.bearer_auth(&env.token)
.body("{}")
.send()
.unwrap()
.status()
.as_u16()
}

View File

@@ -0,0 +1,76 @@
//! Env-scoped overlays (§4.1): `pic apply --env <E>` merges the sparse
//! `picloud.<env>.toml` (per-env slug) onto the base and deploys to that
//! environment's app — leaving the base app untouched.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::AppGuard;
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
String::from_utf8(
common::pic_as(env)
.args(["scripts", "ls", "--app", slug])
.output()
.unwrap()
.stdout,
)
.unwrap()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apply_env_overlay_targets_the_env_app() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base_slug = common::unique_slug("ovl");
let staging_slug = format!("{base_slug}-staging");
for s in [&base_slug, &staging_slug] {
common::pic_as(&env)
.args(["apps", "create", s])
.assert()
.success();
}
let _g1 = AppGuard::new(&env.url, &env.token, &base_slug);
let _g2 = AppGuard::new(&env.url, &env.token, &staging_slug);
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
let base = dir.path().join("picloud.toml");
fs::write(
&base,
format!(
"[app]\nslug = \"{base_slug}\"\nname = \"Ovl\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
),
)
.unwrap();
// Overlay redirects to the staging app.
fs::write(
dir.path().join("picloud.staging.toml"),
format!("[app]\nslug = \"{staging_slug}\"\n"),
)
.unwrap();
// Apply to staging only.
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&base)
.args(["--env", "staging"])
.assert()
.success();
assert!(
scripts_ls(&env, &staging_slug).contains("hello"),
"overlay apply must deploy to the staging app"
);
assert!(
!scripts_ls(&env, &base_slug).contains("hello"),
"the base app must be untouched by an --env apply"
);
}

View File

@@ -0,0 +1,243 @@
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
//! * a GROUP declares a module name `theme` an extension point (with a
//! default body) and a group endpoint `render` that imports it,
//! * an app under the group provides its OWN `theme`; invoking the inherited
//! `render` in that app's context resolves the APP's `theme` (the
//! inversion) — NOT sealed to the group,
//! * an app that provides no `theme` falls back to the group's default body,
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
//! no-op, so `--prune` never silently drops it).
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 extension_point_resolves_per_app_with_default_fallback() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ep-grp");
let app_a = common::unique_slug("ep-a");
let app_b = common::unique_slug("ep-b");
// GroupGuard first so it drops LAST (after apps + 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 manifest: a default `theme` body, a `render` endpoint that
// imports `theme`, and the `[[extension_points]]` declaration. ---
fs::write(
dir.path().join("scripts/defaulttheme.rhai"),
r#"fn name() { "default" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/render.rhai"),
r#"import "theme" as t; t::name()"#,
)
.unwrap();
let group_manifest = format!(
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
);
let group_path = dir.path().join("group.toml");
fs::write(&group_path, &group_manifest).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&group_path)
.output()
.expect("group apply");
assert!(
out.status.success(),
"group apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Group scripts block group deletion (RESTRICT) — guard them.
let _gr = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "render"),
);
let _gd = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "defaulttheme"),
);
// --- App A under the group: provides its OWN `theme` + a caller that
// invokes the inherited `render`. ---
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &group])
.assert()
.success();
fs::write(
dir.path().join("scripts/theme-a.rhai"),
r#"fn name() { "app-a" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/caller.rhai"),
r#"invoke("render", #{})"#,
)
.unwrap();
let app_a_manifest = format!(
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_a_path = dir.path().join("a.toml");
fs::write(&app_a_path, &app_a_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_a_path)
.assert()
.success();
// The inherited `render` imports the ext point `theme`; in App A's context
// it resolves App A's OWN `theme`.
let caller_a = app_script_id(&env, &app_a, "caller");
assert_eq!(
invoke_body(&env, &caller_a),
serde_json::json!("app-a"),
"extension point must resolve the inheriting app's module"
);
// --- App B under the group: provides NO `theme` → render falls back to the
// group's default body. ---
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &group])
.assert()
.success();
let app_b_manifest = format!(
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_b_path = dir.path().join("b.toml");
fs::write(&app_b_path, &app_b_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_b_path)
.assert()
.success();
let caller_b = app_script_id(&env, &app_b, "caller");
assert_eq!(
invoke_body(&env, &caller_b),
serde_json::json!("default"),
"an app providing no module must fall back to the ext point's default"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_round_trips_through_pull() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("ep-pull");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
// Apply an app manifest that declares an extension point with a default.
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/theme.rhai"),
r#"fn name() { "x" }"#,
)
.unwrap();
let manifest = format!(
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
// Pull into a fresh dir, then re-plan: the extension point must round-trip
// (no Create/Delete), so `--prune` would never drop it.
let pulled = manifest_dir();
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(pulled.path())
.assert()
.success();
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
assert!(
toml.contains("theme_slot"),
"pulled manifest must export the extension point:\n{toml}"
);
let plan = common::pic_as(&env)
.args(["plan", "--file"])
.arg(pulled.path().join("picloud.toml"))
.output()
.expect("plan");
let stdout = String::from_utf8_lossy(&plan.stdout);
assert!(
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
"re-planning a pulled manifest must be a no-op:\n{stdout}"
);
}
/// 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)
}
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();
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,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"));
}

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