Compare commits

..

43 Commits

Author SHA1 Message Date
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
MechaCat02
3b650a2b14 feat: declarative project-tool foundation (pull/plan/apply/prune)
Add a server-side, atomic, declarative reconcile loop for a single app —
the foundation of the project-tool design. Developers describe an app's
scripts, routes, triggers, and secret-names in `picloud.toml`, then
`pic pull / plan / apply [--prune]` to converge live state to the manifest.

Server (manager-core):
- apply_service: a pure diff engine (compute_diff) shared by plan and
  apply, plus an ApplyService that composes the existing per-repo writes
  into ONE Postgres transaction. Identity keys mirror the DB UNIQUE
  constraints (script=lower(name); route=(method,host_kind,host,
  path_kind,path); trigger=per-kind semantic tuple; secret=name).
  Apply takes a per-app advisory lock, recomputes the diff in-tx, applies
  scripts -> routes -> triggers, prunes dependents-first, commits, then
  refreshes the route table once post-commit.
- apply_api: POST /apps/{id}/plan (AppRead) and /apps/{id}/apply.
  Apply requires the per-kind write caps the bundle exercises (all three
  when --prune), plus AppSecretsRead when it binds an email trigger.
- tx-accepting repo siblings (insert/update/delete *_tx) so the existing
  create/update/delete delegate to one SQL definition each.
- email triggers reference an inbound secret by NAME; the value is
  resolved, decrypted (AAD-bound), and re-sealed server-side at apply —
  it never travels in the manifest.

CLI (picloud-cli):
- manifest.rs (picloud.toml model), client plan/apply, and the pull/plan/
  apply commands. pull rejects filesystem-unsafe script names up front.

Safety properties enforced and tested:
- idempotent: a freshly-pulled manifest re-applies as all-NoOp.
- atomic: a mid-bundle failure rolls back with nothing written.
- routes delete-before-insert so a freed binding is reusable in one apply.
- queue one-consumer invariant held inside the shared tx.
- email triggers are never pruned, and a script that still owns an
  email/dead-letter trigger can't be pruned (the FK cascade would destroy
  the sealed secret) — refused with a pointer to `pic triggers rm`.
- plan and apply agree on unset email-secret references.

No migration: the existing schema's UNIQUE constraints serve as identity
keys. Groups, env-scoping, and the `enabled` toggle are later milestones.

Tested: manager-core lib (360) + CLI bins (27) + 8 project-tool journeys
(pull/plan/apply/prune/email+queue), all green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:52:21 +02:00
MechaCat02
c600177fd6 docs(design): groups & declarative project-tool spec
The architecture spec for the full vision — a declarative project tool
(picloud.toml + pull/plan/apply/prune) and a server-side nested-groups
hierarchy with live-resolve inheritance. Reviewed across several passes
for consistency, gaps, and contradictions; resolutions are folded in
beside the topics they settle.

This commit lands the spec only. The first slice of implementation (the
single-app reconcile foundation) follows; groups, env-scoping, and the
`enabled` toggle are later milestones called out in the doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:52:01 +02:00
MechaCat02
51f14fa2b1 feat: E2E #2 (Stash) gap remediation + S6 hardening
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 6m19s
CI / Dashboard — check (push) Successful in 9m48s
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.

Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
  comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
  their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
  skips authz when the principal is anonymous).

Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
  a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
  DEFAULT 'http' backfills history); a shared `build_execution_log` helper
  in executor-core; dispatcher logging for outbox triggers + queue
  consumers (skips sync-HTTP rows the orchestrator already logs). `pic
  logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
  (email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
  (Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
  make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
  `pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
  --sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
  email}` wrappers. All new client path segments percent-encoded via seg().

H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
  route at boot and on each route CRUD. A single stored route the new
  validation rejects (creatable while the S6 gap existed) made the whole
  compile Err and aborted startup. `compile_routes` is now lenient: it
  skips an un-compilable row with a warning instead of bricking boot
  (route creation still validates separately). Migration 0044 sweeps
  pre-existing reserved-path routes on upgrade (WHERE mirrors
  check_reserved exactly). Added regression tests for both.

Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:01:04 +02:00
123 changed files with 17487 additions and 607 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). Next: group-owned scripts/modules (§11 Phase 4) and the project tool mapping onto groups (§11 Phase 5).
**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable _config_ tables (`vars`, `secrets`) instead carry a **polymorphic owner** — nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes. Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg (the cross-app isolation boundary).
## Three-Service Architecture
@@ -117,7 +121,7 @@ Environment variables consumed by the `picloud` binary:
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
| `DATABASE_URL` | — | Required. Postgres connection string. |
| `PICLOUD_SECRET_KEY` | — | Master encryption key (base64). Required at startup unless dev mode is acknowledged (below). |
| `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. |
| `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. Also: when no SMTP relay is configured, `email::send` switches from disabled (`NotConfigured`) to an **in-memory dev sink** — sends succeed and the last 100 messages are readable at `GET /api/v1/admin/dev/emails` (instance Owner/Admin only; route exists only in this mode). Never in production. |
| `PICLOUD_DEV_INSECURE_KEY` | — | Set to the literal `i-understand-this-is-insecure` to let dev mode boot without `PICLOUD_SECRET_KEY`, using a deterministic, world-known dev master key. Never set in production — it would encrypt everything with a public value. |
| `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size. Matched to `PICLOUD_MAX_CONCURRENT_EXECUTIONS` so the data plane can't starve background workers. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |

138
E2E_STASH_REPORT.md Normal file
View File

@@ -0,0 +1,138 @@
# E2E Test #2 — "Stash" (paste + file-drop) + security matrix
**Date:** 2026-06-13 · **Build:** v1.1.9 · **Instance:** host `picloud` on `127.0.0.1:8099`
(dev mode, `PICLOUD_FILES_MAX_FILE_SIZE_BYTES=1048576`, SSRF guard on, SMTP unset), Postgres in
Docker. Driven entirely through the `pic` CLI; data-plane traffic via `curl`.
## Goal
Second end-to-end pass. The first test (To-Do, `E2E_TODO_REPORT.md`) covered users/docs/pubsub/
cron/routes/domains/topics. This one builds a **new** app to exercise the *untested* half of the
platform — **kv, files, queue, invoke, secrets, api-keys, dead-letters, async + two-param routes**
— and then runs a **10-probe security matrix**. Findings: feature/CLI gaps + security verdicts.
## Verdict
**Every feature worked** end-to-end, and **every security control held except one Low-severity
gap** (reserved-path validation is case-sensitive). Highlights of what's *good*: cross-app
isolation is airtight, the SSRF guard blocks cloud-metadata, size/op caps fire before authz, and
internal script errors are scrubbed from responses with a correlation id. Two notable
**observability/ergonomics gaps** (trigger executions invisible to `pic logs`; the `.trim()`/`.replace()`
return-`()` footgun) and the expected CLI-coverage gaps round it out.
---
## The app — "Stash" (built CLI-only, no raw admin curl)
A pastebin with file attachments and a background word-count worker. 9 scripts, 6 routes, a queue
consumer trigger, a dead-letter trigger, 2 API keys, 1 secret — all via `pic`.
| Feature exercised | How | Result |
|---|---|---|
| **kv** | paste store, hit counter, `/stats` aggregates, event log | ✅ get/set/list round-trip |
| **files** | `files::create` from base64 blob + `get`/`head`, two-param download route | ✅ bytes round-trip (`"hello attachment bytes"`, size 22) |
| **queue** | `queue::enqueue("ingest")` + `create-from-json --kind queue` consumer | ✅ worker fired |
| **invoke** | worker → `invoke("analyzer", #{text})` function-to-function | ✅ returns word count |
| **secrets** | `pic secrets set` (stdin) + `secrets::get` to gate `/stats` | ✅ 401 w/o token, 200 with |
| **async route** | `routes create --dispatch async` for `POST /events` | ✅ **202** + execution_id |
| **dead-letters** | poison job → 3 attempts → DL row; `pic dead-letters replay` | ✅ captured + replayed (+9 words) |
| **api-keys** | `pic api-keys mint --scope … [--app …]` | ✅ minted, scopes enforced (below) |
| **two-param route** | `/pastes/:code/files/:fid` | ✅ both params captured |
End-to-end stats after the run: `{total_pastes: 2, total_words: 13}` — proving queue → worker →
invoke → kv all fired on real Postgres rows + on-disk file bytes.
---
## Security matrix (10 probes, all reproduced live)
| # | Probe | Verdict | Evidence |
|---|---|---|---|
| **S1** | Cross-app isolation | ✅ HOLDS | A script in app `evil` doing `kv…list()` / `secrets::get("admin-token")` / `files…list()` returned `{paste_keys:[], attachment_count:0, stolen_secret:null, my_secret_names:[]}`. `app_id` derives from `cx.app_id`; no SDK call takes a script-passed app_id. |
| **S2** | Secret confidentiality | ✅ HOLDS | `pic secrets ls` shows names only; the plaintext `sup3r-s3cret-admin` does **not** appear anywhere in the server log even though `/stats` reads it each request. |
| **S3** | API-key scope + app-binding | ✅ HOLDS | `script:read` key → **403** on `POST /apps` and `GET /admins`, **200** on in-scope `GET /scripts`. `app:admin` key bound to `stash`**403** on app `evil`. `instance:admin` + `--app`**422**. Unknown scope `bogus:scope` → rejected by CLI. |
| **S4** | Sandbox op-budget | ✅ HOLDS | `loop {}`**HTTP 507 "execution exceeded operation budget"** in **0.30 s**; `/healthz` still `ok` (no hang). |
| **S5** | Value/size caps | ✅ HOLDS | Oversized kv write rejected (HTTP 502). Notably the detail (`"Length of string too large"`) was **scrubbed from the response** and logged with `correlation_id` — good error hygiene. Caps are checked before authz, so anonymous callers can't DoS. |
| **S6** | Reserved-path prefixes | ⚠️ **GAP (Low)** | `/api/x`, `/admin/x`, `/healthz`, `/version` → correctly **422**. But case variants `/API/v2/x`, `/Admin/x`, `/HEALTHZ` were **accepted**. See finding below. |
| **S7** | Files path traversal | ✅ HOLDS | `files::collection("../../../etc")``"invalid collection name: must not contain '/', '\\', '..', or NUL"`; a non-UUID file id → not found. No FS escape. |
| **S8** | docs filter injection | ✅ HOLDS | `docs::find(#{ "x'); DROP TABLE docs;--": 1 })` → ran safely, `count=0`; the `docs` table still exists (`to_regclass` = true). Values/paths are bound as `$N` params. |
| **S9** | SSRF via `http::` | ✅ HOLDS | `http::get("http://127.0.0.1:8099/healthz")``"blocked by SSRF policy: loopback"`; `http://169.254.169.254/…` (cloud metadata) → `"blocked by SSRF policy: link-local"`. |
| **S10** | Anonymous data-plane access | By design (caveat) | Every Stash route is public (no auth) yet freely uses kv/secrets/files/queue. `script_gate` returns `Ok` when `principal.is_none()` — the *script* is the gate. Correct per design, but a sharp threat-model edge (see note). |
### S6 — reserved-path validation is case-sensitive (Low)
**What:** route-creation rejects the exact reserved prefixes but accepts case variants:
```
pic routes create --path /api/x -> 422 "path '/api/x' is reserved"
pic routes create --path /API/v2/x -> Created route … (GET * /API/v2/x) # accepted
pic routes create --path /HEALTHZ -> Created route … # accepted
pic routes create --path /Admin/x -> Created route … # accepted
```
**Current impact — Low, *not* a full bypass:** path matching is case-*sensitive*, so a request to
the real lowercase `/api/v2/x` still 404s (the system namespace is safe); only the exact
mixed-case path serves the script (`GET /API/v2/x` → 200, `/HEALTHZ` → 200). Real `/healthz`
still returns `ok` from the top-level handler.
**Why it still matters:** (1) inconsistent enforcement of a stated security boundary; (2) lets a
tenant publish convincing look-alike paths (`/Admin/login`, `/API/v1/…`) for phishing or log
confusion; (3) **fragile** — method matching is already case-insensitive (`matcher.rs:275`); if
path matching is ever made case-insensitive too, this instantly becomes a real shadowing bypass
of `/api/`, `/admin/`, `/healthz`. The validation should be the durable guard.
**Fix:** normalize case before the reserved check in `orchestrator-core/src/routing/pattern.rs`
(`check_reserved` ~line 110) — compare `raw.to_ascii_lowercase()` against the reserved list (or
reject any case-insensitive match).
### S10 — anonymous public scripts have full app data-plane access (design caveat)
Not a bug, but worth surfacing for users: a *public* (unauthenticated) route's script can read/write
**all** of the app's kv, docs, files, secrets, and queue — the platform only gates *authenticated*
principals; for anonymous ingress the script itself must enforce access. A developer who assumes
"this route is public" ≠ "this code can read every secret in my app" could over-expose data. Worth
a prominent doc callout near the SDK auth model.
---
## Feature / CLI gaps
- **G1 — trigger executions are invisible to `pic logs`.** After successful queue-worker and
analyzer runs, `pic logs <worker>` and `pic logs <analyzer>` were **empty**. Trigger-dispatched
executions (queue/cron/dead-letter/invoke) don't surface in the per-script log tail — the only
built-in visibility into worker activity is **dead-letters (failures only)** or the script's own
side effects. This is a real observability gap for background workloads. *Suggest: include
trigger executions in the logs surface, or add a `pic logs --trigger`/events view.*
- **G2 — no `pic kv` / `files` / `queues` / `members` commands.** Data-plane stores are
script/HTTP-only (no admin CLI to inspect kv/files/queue contents; queues are read-only HTTP).
App **membership** (`/apps/{id}/members`) also has no CLI — multi-user app roles can't be managed
with `pic`. *(Confirmed absent via `pic <cmd> --help`.)*
- **G3 — no per-script sandbox/timeout flags in `pic deploy`/`scripts`.** `timeout_seconds`,
`memory_limit_mb`, and sandbox overrides are only settable via the raw scripts API or instance
env (`PICLOUD_SANDBOX_MAX_*`). A developer can't cap a single script's runtime from the CLI.
- **G4 — uneven trigger wrappers.** Only `kv`, `cron`, `dead-letter` have first-class
`pic triggers create-*` wrappers; `docs`/`files`/`pubsub`/`queue`/`email` require hand-built
`create-from-json --kind … --body '{…}'`. Works, but the developer must know each body schema.
- **G5 — `email::send` unusable in dev.** Returns `"email is not configured: set
PICLOUD_SMTP_HOST/USER/PASSWORD"`. Expected (no silent drop), but the email feature can't be
exercised at all without an SMTP relay — worth a documented local-dev fake/sink.
- **G6 — Rhai `.trim()` returns `()` (in-place mutation footgun).** Same family as `.replace()`
(already documented). `let t = text.trim()` sets `t` to unit; my analyzer dead-lettered with
`Function not found: split((), …)` until rewritten to `text.trim();` as a statement. *Suggest:
extend the existing stdlib footgun note to list `trim`/`to_upper`/`to_lower` etc., not just
`replace`.*
## Things done especially well (no action)
- Cross-app isolation has no script-controlled `app_id` anywhere — the boundary is structural.
- SSRF guard covers loopback **and** link-local (cloud-metadata `169.254.169.254`), and blocks at
every redirect hop.
- Size/operation caps are enforced **before** authz, so anonymous public scripts can't DoS the DB.
- Script runtime errors are **scrubbed** from the HTTP response and logged with a `correlation_id`
— no internal detail leaks to the caller.
- `deploy`/`apps create` honor `--output json` (the earlier F1 fix), so the whole build scripts
cleanly with captured ids.
## Reproduction / teardown
Scripts under `/tmp/stash/*.rhai`; apps `stash` + `evil` and their data persist in the dev
Postgres. Server: `target/debug/picloud` on `:8099` (see Phase 0 env). Teardown: kill the host
`picloud`; `docker compose down [-v]`. (Note: the S6 probe left harmless `/API/v2/x`, `/HEALTHZ`,
`/Admin/x`, `/Api/y` routes on `stash` — they vanish with the app.)
## Priority
**S6** is the only code-level finding (Low — confirm-and-harden the reserved-path check). **G1**
(trigger-execution observability) is the most impactful *developer-experience* gap for anyone
running background workers. Everything else is documented-behavior or known CLI-coverage gaps.

View File

@@ -19,5 +19,6 @@ pub use module_resolver::{
};
pub use sandbox::Limits;
pub use types::{
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
build_execution_log, ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry,
LogLevel,
};

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

@@ -2,10 +2,12 @@ use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use picloud_shared::{
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
ScriptId, ScriptSandbox, TriggerEvent,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@@ -167,3 +169,77 @@ pub enum ExecError {
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
Overloaded { retry_after_secs: u32 },
}
/// Build an `ExecutionLog` row from one invocation's outcome.
///
/// Shared by every execution path so the log shape stays identical
/// regardless of who ran the script: the orchestrator's sync/direct HTTP
/// handlers pass `ExecutionSource::Http`, while the manager's trigger
/// dispatcher passes the trigger's kind (`Kv`, `Cron`, `Invoke`, …). That
/// `source` is what lets `pic logs` surface background runs that were
/// previously invisible. `Overloaded` is never logged — admission is
/// refused before a row exists — but it maps to `Error` defensively.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn build_execution_log(
app_id: AppId,
script_id: ScriptId,
request_id: RequestId,
request_path: String,
request_headers: BTreeMap<String, String>,
request_body: serde_json::Value,
source: ExecutionSource,
outcome: &Result<ExecResponse, ExecError>,
started: DateTime<Utc>,
finished: DateTime<Utc>,
) -> ExecutionLog {
let duration_ms = u64::try_from(
finished
.signed_duration_since(started)
.num_milliseconds()
.max(0),
)
.unwrap_or(0);
let (status, response_code, response_body, script_logs) = match outcome {
Ok(resp) => {
let logs = serde_json::to_value(&resp.logs).unwrap_or(serde_json::Value::Array(vec![]));
(
ExecutionStatus::Success,
Some(resp.status_code),
Some(resp.body.clone()),
logs,
)
}
Err(e) => {
let status = match e {
ExecError::Timeout(_) => ExecutionStatus::Timeout,
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
(
status,
None,
Some(serde_json::json!({ "error": e.to_string() })),
serde_json::Value::Array(vec![]),
)
}
};
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,
request_headers,
request_body,
response_code,
response_body,
script_logs,
duration_ms,
status,
source,
created_at: started,
}
}

View File

@@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() {
Arc::new(picloud_shared::NoopUsersService),
Arc::new(picloud_shared::NoopQueueService),
Arc::new(picloud_shared::NoopInvokeService),
Arc::new(picloud_shared::NoopVarsService),
);
let engine = Engine::new(Limits::default(), services);

View File

@@ -104,6 +104,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),
)
}

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

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

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

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

View File

@@ -90,6 +90,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));

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

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

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

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

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

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

View File

@@ -0,0 +1,20 @@
-- G1 (E2E #2 "Stash"): trigger executions were invisible to `pic logs`.
--
-- Only HTTP-route executions ever wrote an `execution_logs` row; queue,
-- cron, dead-letter, and `invoke()` runs left no trace, so background
-- workers were observable only via dead-letters (failures) or their own
-- side effects. The dispatcher now logs every trigger run too — this
-- column records which kind of event dispatched each execution so the
-- logs surface can show, and filter by, the origin.
--
-- DEFAULT 'http' backfills every pre-existing row: before this change the
-- only thing that logged was the HTTP path, so 'http' is correct history.
-- The CHECK list mirrors `manager-core::OutboxSourceKind` /
-- `shared::ExecutionSource`; keep all three in sync.
ALTER TABLE execution_logs
ADD COLUMN source TEXT NOT NULL DEFAULT 'http'
CHECK (source IN (
'http', 'kv', 'docs', 'dead_letter', 'cron',
'files', 'pubsub', 'email', 'invoke', 'queue'
));

View File

@@ -0,0 +1,22 @@
-- H1 (re-review of the S6 reserved-path fix): the reserved-prefix check is
-- now case-insensitive, both at route creation AND when the route table is
-- compiled at boot. Routes created before the fix — while validation was
-- case-sensitive — could hold paths like `/API/v2/x`, `/Admin/x`, or
-- `/HEALTHZ`. `compile_routes` now skips such rows with a warning instead of
-- aborting startup (so an un-upgraded boot can't be bricked), but those
-- routes violate the reserved namespace and can never be served safely, so
-- sweep them here on upgrade.
--
-- Mirrors `orchestrator-core::routing::pattern::check_reserved` exactly,
-- case-insensitively: a path is reserved if its lowercased form equals one
-- of the bare names (`/api` `/admin` `/healthz` `/version`) or starts with
-- one of the prefixes. Note `/api/` and `/admin/` reserve on the trailing
-- slash, while `/healthz` and `/version` reserve on bare prefix — matching
-- the RESERVED_PATH_PREFIXES list. (Idempotent: a no-op once swept.)
DELETE FROM routes
WHERE lower(path) IN ('/api', '/admin', '/healthz', '/version')
OR lower(path) LIKE '/api/%'
OR lower(path) LIKE '/admin/%'
OR lower(path) LIKE '/healthz%'
OR lower(path) LIKE '/version%';

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

@@ -12,8 +12,8 @@ use axum::{
Extension, Json, Router,
};
use picloud_shared::{
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox,
ScriptValidator, ValidatedScript, ValidationError,
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
};
use serde::Deserialize;
@@ -246,6 +246,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 +260,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",
@@ -345,6 +347,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,
},
)
@@ -385,6 +388,10 @@ pub struct LogsQuery {
#[serde(default, rename = "offset")]
#[allow(dead_code)]
pub legacy_offset: Option<i64>,
/// Optional origin filter (`http`, `kv`, `cron`, `invoke`, …). Absent
/// → all sources. An unrecognized value is a 422 (see `list_logs`).
#[serde(default)]
pub source: Option<String>,
}
const fn default_limit() -> i64 {
@@ -411,7 +418,17 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
.cursor
.as_deref()
.and_then(crate::repo::ExecutionLogCursor::decode);
let logs = state.logs.list_for_script(id, limit, cursor).await?;
let source = match q.source.as_deref() {
None | Some("" | "all") => None,
Some(s) => Some(
ExecutionSource::from_wire(s)
.ok_or_else(|| ApiError::BadRequest(format!("unknown log source: {s:?}")))?,
),
};
let logs = state
.logs
.list_for_script(id, limit, cursor, source)
.await?;
Ok(Json(logs))
}
@@ -427,6 +444,9 @@ pub enum ApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("conflict: {0}")]
Conflict(String),
@@ -459,11 +479,10 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
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) => {
tracing::error!(error = %e, "authz repo error");

View File

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

@@ -0,0 +1,173 @@
//! Admin HTTP surface for the declarative reconcile engine.
//!
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
//! against the app's live state and return the plan. Read-only; requires
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
Extension, Json, Router,
};
use picloud_shared::{AppId, Principal};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::apply_service::{
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
};
use crate::authz::{require, AuthzDenied, Capability};
/// 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))
.with_state(service)
}
#[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(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
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)?;
}
let report = svc
.apply(
app_id,
&req.bundle,
req.prune,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
Ok(Json(report))
}
async fn 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 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
// at every tier (same `script:read` scope, both in the viewer role). If a
// future authz split puts `AppSecretsRead` on its own tier, this handler
// must additionally require it — otherwise it leaks names a principal
// couldn't enumerate via the secrets API.
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
let plan = svc.plan(app_id, &bundle).await?;
Ok(Json(plan))
}
/// 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> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
}
fn map_authz(denied: AuthzDenied) -> ApplyError {
match denied {
AuthzDenied::Denied => ApplyError::Forbidden,
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
}
}
impl IntoResponse for ApplyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::StateMoved => (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");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "apply backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

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,25 @@ pub enum Capability {
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
/// Granted to `editor`+, maps to `script:write` on API keys.
AppSecretsWrite(AppId),
/// Read this app's resolved config vars (Phase 3). Same trust shape as
/// secrets-read — granted to `viewer`+, maps to `script:read`.
AppVarsRead(AppId),
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
/// `editor`+, maps to `script:write`.
AppVarsWrite(AppId),
/// Read a group's config vars (Phase 3). Resolved via the group
/// ancestor walk; viewer+ on the group.
GroupVarsRead(GroupId),
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
/// the group.
GroupVarsWrite(GroupId),
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
/// group_admin on the owning group — distinct from runtime injection,
/// which an inheriting app does without this check.
GroupSecretsRead(GroupId),
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
/// group.
GroupSecretsWrite(GroupId),
/// Send an outbound email from a script in this app (v1.1.7). Maps
/// to `script:write` on API keys (sending mail is an outbound
/// side-effect like an HTTP request). Granted to `editor`+.
@@ -154,9 +186,20 @@ 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(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -174,6 +217,8 @@ impl Capability {
| Self::AppQueueEnqueue(id)
| Self::AppSecretsRead(id)
| Self::AppSecretsWrite(id)
| Self::AppVarsRead(id)
| Self::AppVarsWrite(id)
| Self::AppEmailSend(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id)
@@ -193,15 +238,19 @@ 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(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -213,13 +262,23 @@ impl Capability {
| Self::AppEmailSend(_)
| Self::AppUsersWrite(_)
| Self::AppUsersAdmin(_)
| Self::AppVarsWrite(_)
// Group-secret WRITE is editor-role-gated (same tier as
// GroupVarsWrite), so its API-key scope matches: script:write,
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
// the admin tier below.
| Self::GroupSecretsWrite(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
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 +289,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 +442,26 @@ 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) => {
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 +485,51 @@ 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 and config vars.
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
// editor+ writes config vars/secrets.
Capability::GroupWrite(_)
| Capability::GroupVarsWrite(_)
| Capability::GroupSecretsWrite(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the
// human-read gate, distinct from an app's runtime injection).
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
matches!(role, AppRole::AppAdmin)
}
_ => false,
}
}
/// 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 +544,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppFilesRead(_)
| Capability::AppSecretsRead(_)
| Capability::AppUsersRead(_)
| Capability::AppVarsRead(_)
);
let in_editor = in_viewer
|| matches!(
@@ -412,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppSecretsWrite(_)
| Capability::AppEmailSend(_)
| Capability::AppUsersWrite(_)
| Capability::AppVarsWrite(_)
| Capability::AppInvoke(_)
);
let in_app_admin = in_editor
@@ -471,16 +620,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 +691,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 +1074,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,456 @@
//! The §3 configuration-resolution engine: env-filtered, proximity-first
//! inheritance down the group tree.
//!
//! To resolve a key for app A in environment E (docs/design §3):
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
//! Env is *eligibility*, not a precedence tier.
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
//! closest level that defines the (filtered) key wins. Proximity beats
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
//! explicit tombstone** that suppresses the inherited key.
//!
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
//! a window-function pick can't express — happen in the pure `resolve`
//! function below, so they're unit-tested without Postgres.
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use picloud_shared::AppId;
/// Shared chain-walk CTE: emits one row per owner-level for an app —
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
/// nearest-first (`group_owner` set), each carrying the app's environment.
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
/// resolvers, which each append their own owner-keyed JOIN.
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
FROM apps a WHERE a.id = $1 \
UNION ALL \
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Owner kind of a resolved value, for `--explain` provenance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerKind {
App,
Group,
}
impl OwnerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Group => "group",
}
}
}
/// One eligible (env-filtered) candidate row pulled by the resolver, before
/// §3 proximity/merge resolution.
#[derive(Debug, Clone)]
pub struct Candidate {
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
pub depth: i32,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
/// `*` (env-agnostic) or a concrete environment name.
pub scope: String,
pub key: String,
pub value: Value,
pub is_tombstone: bool,
}
/// Where a resolved key came from (for `config --effective --explain`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub depth: i32,
pub scope: String,
/// For a deep-merged map, the `(depth, scope)` of every layer that
/// contributed, nearest-first. Empty for a plain scalar/array winner.
pub merged_from: Vec<(i32, String)>,
}
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
fn env_priority(scope: &str) -> u8 {
u8::from(scope != "*")
}
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
/// everything else is replaced by `src`. Caller merges farthest→nearest so
/// nearer layers overwrite.
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
for (k, sv) in src {
match (dst.get_mut(k), sv) {
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
_ => {
dst.insert(k.clone(), sv.clone());
}
}
}
}
/// Resolve one key's candidate rows (any order in) to its effective value,
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
rows.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
});
// §3 step 1: env is eligibility, not a merge tier — within a single level
// `@E` *suppresses* `*` (it does not layer on top of it). Each level is one
// owner (single-parent tree) with at most one `@E` and one `*` row per key
// after env-filtering; keep only the level's winner (the `@E` row sorts
// first). Without this, a level holding both an `@E` map and a `*` map would
// deep-merge them instead of the `@E` shadowing the `*`.
rows.dedup_by_key(|r| r.depth);
// Collect the contiguous run of map-valued rows from the nearest. A
// tombstone or scalar/array boundary stops the run (and, if it's the
// nearest row, decides the result outright).
let mut maps: Vec<&Candidate> = Vec::new();
let mut boundary: Option<&Candidate> = None;
for r in &rows {
if r.is_tombstone {
boundary = Some(r);
break;
}
if r.value.is_object() {
maps.push(r);
} else {
boundary = Some(r);
break;
}
}
if let Some(first) = maps.first() {
// Map run wins; deep-merge farthest→nearest so nearest keys win.
let mut acc = Map::new();
for m in maps.iter().rev() {
if let Value::Object(obj) = &m.value {
deep_merge(&mut acc, obj);
}
}
let prov = Provenance {
owner_kind: first.owner_kind,
owner_id: first.owner_id,
depth: first.depth,
scope: first.scope.clone(),
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
};
return Some((Value::Object(acc), prov));
}
// No leading map run: the nearest row is the boundary.
match boundary {
// Nearest is a tombstone → key deleted.
Some(b) if b.is_tombstone => None,
// Nearest is a scalar/array → take it verbatim.
Some(b) => Some((
b.value.clone(),
Provenance {
owner_kind: b.owner_kind,
owner_id: b.owner_id,
depth: b.depth,
scope: b.scope.clone(),
merged_from: Vec::new(),
},
)),
// No rows at all (caller only passes non-empty groups).
None => None,
}
}
/// Resolve a full candidate set (mixed keys, any order) into the effective
/// config map + per-key provenance. Pure — unit-tested without Postgres.
#[must_use]
pub fn resolve(
candidates: Vec<Candidate>,
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
for c in candidates {
by_key.entry(c.key.clone()).or_default().push(c);
}
let mut values = BTreeMap::new();
let mut provenance = BTreeMap::new();
for (key, rows) in by_key {
if let Some((v, p)) = resolve_one(rows) {
values.insert(key.clone(), v);
provenance.insert(key, p);
}
}
(values, provenance)
}
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
/// rows + every ancestor group's rows, filtered to `*` or the app's
/// environment, ordered nearest-first.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT c.depth, \
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(v.app_id, v.group_id) AS owner_id, \
v.environment_scope, v.key, v.value, v.is_tombstone \
FROM chain c \
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
ORDER BY c.depth ASC"
);
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// The masked, resolved view of one inherited secret for `config/effective`:
/// which owner/level/scope supplies it — **never** the value.
#[derive(Debug, Clone)]
pub struct EffectiveSecretMeta {
pub name: String,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub scope: String,
pub depth: i32,
}
/// Resolve the *names* of every secret an app effectively sees — its own
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
/// masked metadata only (owner/level/scope), so it's safe for an app-level
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
/// per-name secret resolver guarantees the same winner.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_effective_secret_meta(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT ON (s.name) s.name, \
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(s.app_id, s.group_id) AS owner_id, \
s.environment_scope, c.depth \
FROM chain c \
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
);
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
name,
owner_kind: if owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id,
scope,
depth,
},
)
.collect())
}
#[derive(sqlx::FromRow)]
struct VarCandidateRow {
depth: i32,
owner_kind: String,
owner_id: Uuid,
environment_scope: String,
key: String,
value: Value,
is_tombstone: bool,
}
impl From<VarCandidateRow> for Candidate {
fn from(r: VarCandidateRow) -> Self {
Self {
depth: r.depth,
owner_kind: if r.owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: r.owner_id,
scope: r.environment_scope,
key: r.key,
value: r.value,
is_tombstone: r.is_tombstone,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
Candidate {
depth,
owner_kind: if depth == 0 {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: Uuid::nil(),
scope: scope.into(),
key: key.into(),
value,
is_tombstone: false,
}
}
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
Candidate {
is_tombstone: true,
value: Value::Null,
..cand(depth, scope, key, Value::Null)
}
}
#[test]
fn nearest_level_wins() {
// app (depth 0) overrides group (depth 2).
let (v, p) = resolve(vec![
cand(2, "*", "region", json!("eu")),
cand(0, "*", "region", json!("us")),
]);
assert_eq!(v["region"], json!("us"));
assert_eq!(p["region"].depth, 0);
}
#[test]
fn env_scoped_beats_agnostic_within_a_level() {
let (v, p) = resolve(vec![
cand(1, "*", "db_url", json!("default")),
cand(1, "staging", "db_url", json!("staging-db")),
]);
assert_eq!(v["db_url"], json!("staging-db"));
assert_eq!(p["db_url"].scope, "staging");
}
#[test]
fn proximity_beats_env_specificity_across_levels() {
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
let (v, _) = resolve(vec![
cand(2, "production", "db_url", json!("anc-prod")),
cand(0, "*", "db_url", json!("leaf-default")),
]);
assert_eq!(v["db_url"], json!("leaf-default"));
}
#[test]
fn maps_deep_merge_nearest_wins() {
// ancestor sets {title, region}; leaf overrides title, adds locale.
let (v, p) = resolve(vec![
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
]);
assert_eq!(
v["ui"],
json!({"title": "Leaf", "region": "eu", "locale": "en"})
);
assert_eq!(p["ui"].merged_from.len(), 2);
}
#[test]
fn same_level_env_map_suppresses_agnostic_map_no_merge() {
// §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it
// shadows it. A level holding both an `@staging` map and a `*` map must
// resolve to the `@staging` map alone, never a deep-merge of the two.
let (v, p) = resolve(vec![
cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})),
cand(1, "staging", "cfg", json!({"shared": "staging"})),
]);
assert_eq!(v["cfg"], json!({"shared": "staging"}));
assert_eq!(p["cfg"].scope, "staging");
// Provenance must not list the suppressed `*` layer.
assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]);
}
#[test]
fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() {
// The suppressed same-level `*` must also be invisible to cross-level
// merge: leaf `@staging` map merges onto the ancestor map, but the
// leaf's own `*` map (shadowed at its level) never contributes.
let (v, _) = resolve(vec![
cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})),
cand(0, "*", "cfg", json!({"leak": "should-not-appear"})),
cand(0, "staging", "cfg", json!({"tier": "leaf"})),
]);
assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"}));
assert!(!v["cfg"].as_object().unwrap().contains_key("leak"));
}
#[test]
fn nearer_scalar_replaces_whole_inherited_map() {
let (v, _) = resolve(vec![
cand(2, "*", "x", json!({"a": 1})),
cand(0, "*", "x", json!("scalar")),
]);
assert_eq!(v["x"], json!("scalar"));
}
#[test]
fn tombstone_suppresses_inherited_key() {
let (v, _) = resolve(vec![
cand(2, "*", "secret_flag", json!(true)),
tomb(0, "*", "secret_flag"),
]);
assert!(!v.contains_key("secret_flag"));
}
#[test]
fn json_null_is_a_value_not_a_deletion() {
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
assert!(v.contains_key("k"));
assert_eq!(v["k"], Value::Null);
}
#[test]
fn map_merge_stops_at_a_nearer_scalar_boundary() {
// nearest map, then a scalar, then a farther map: only the
// contiguous top map run merges; the scalar bounds it.
let (v, _) = resolve(vec![
cand(3, "*", "m", json!({"deep": 1})),
cand(2, "*", "m", json!("scalar-boundary")),
cand(0, "*", "m", json!({"near": 2})),
]);
// depth 0 map is the only one above the depth-2 scalar boundary.
assert_eq!(v["m"], json!({"near": 2}));
}
}

View File

@@ -0,0 +1,48 @@
//! `GET /api/v1/admin/dev/emails` — dev-only inspection of mail captured
//! by the in-memory email sink (G5).
//!
//! Mounted **only** when the email service is running in dev-capture mode
//! (`PICLOUD_DEV_MODE=true` and no SMTP relay configured). In every other
//! configuration the route does not exist, so there is no production
//! surface here. Capture is instance-wide (the SMTP transport seam can't
//! see a script's `app_id`), so the endpoint is instance-wide too and is
//! restricted to instance Owners/Admins.
use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{InstanceRole, Principal};
use crate::email_service::{CapturedEmail, DevEmailSink};
#[derive(Clone)]
pub struct DevEmailState {
pub sink: Arc<DevEmailSink>,
}
/// Build the dev-email router. Callers mount this only when dev-capture
/// mode is active (i.e. they hold a `Some(sink)`).
pub fn dev_emails_router(state: DevEmailState) -> Router {
Router::new()
.route("/dev/emails", get(list_dev_emails))
.with_state(state)
}
async fn list_dev_emails(
Extension(principal): Extension<Principal>,
State(state): State<DevEmailState>,
) -> Result<Json<Vec<CapturedEmail>>, StatusCode> {
// Instance-wide data → require an instance Owner/Admin. A Member
// (app-scoped) principal has no business reading every app's mail.
if !matches!(
principal.instance_role,
InstanceRole::Owner | InstanceRole::Admin
) {
return Err(StatusCode::FORBIDDEN);
}
Ok(Json(state.sink.snapshot()))
}

File diff suppressed because it is too large Load Diff

View File

@@ -248,6 +248,15 @@ fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
/// `PICLOUD_DEV_MODE=true` (case-insensitive). Matches the detection in
/// `shared::crypto` so the dev email sink and the dev master key turn on
/// together.
fn dev_mode_enabled() -> bool {
std::env::var("PICLOUD_DEV_MODE")
.map(|v| v.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// Internal transport seam so the service can be tested without a live
/// SMTP server. The production impl is [`LettreEmailTransport`]; tests
/// use a recording fake.
@@ -299,6 +308,97 @@ impl EmailTransport for LettreEmailTransport {
}
}
/// G5: how many recently-captured dev emails the in-memory sink keeps.
/// Old entries are evicted FIFO; this is a debugging aid, not storage.
pub const DEV_EMAIL_CAPACITY: usize = 100;
/// One email captured by the dev sink instead of being relayed. Serialized
/// straight onto the dev-only inspection endpoint.
#[derive(Clone, serde::Serialize)]
pub struct CapturedEmail {
pub captured_at: chrono::DateTime<chrono::Utc>,
pub from: Option<String>,
pub to: Vec<String>,
/// The full RFC 5322 message (headers + body), exactly as it would
/// have hit the relay — enough to eyeball subject/body in dev.
pub raw: String,
}
/// In-memory ring buffer of captured dev emails. Shared (`Arc`) between
/// the [`DevEmailTransport`] that writes and the dev endpoint that reads.
pub struct DevEmailSink {
captured: std::sync::Mutex<std::collections::VecDeque<CapturedEmail>>,
capacity: usize,
}
impl DevEmailSink {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
captured: std::sync::Mutex::new(std::collections::VecDeque::new()),
capacity: capacity.max(1),
}
}
fn push(&self, email: CapturedEmail) {
let mut q = self
.captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while q.len() >= self.capacity {
q.pop_front();
}
q.push_back(email);
}
/// 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);
q.iter().rev().cloned().collect()
}
}
/// Dev transport: instead of relaying, capture the message in memory and
/// log it. Wired only when `PICLOUD_DEV_MODE=true` and no SMTP relay is
/// configured, so `email::send` is exercisable locally without a relay.
/// NEVER constructed in production (no dev mode → disabled mode instead).
pub struct DevEmailTransport {
sink: Arc<DevEmailSink>,
}
impl DevEmailTransport {
#[must_use]
pub fn new(sink: Arc<DevEmailSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl EmailTransport for DevEmailTransport {
async fn send(&self, message: &Message) -> Result<(), EmailError> {
let envelope = message.envelope();
let from = envelope.from().map(ToString::to_string);
let to: Vec<String> = envelope.to().iter().map(ToString::to_string).collect();
let raw = String::from_utf8_lossy(&message.formatted()).into_owned();
tracing::info!(
?from,
?to,
"email DEV CAPTURE: message captured in memory (not relayed)"
);
self.sink.push(CapturedEmail {
captured_at: chrono::Utc::now(),
from,
to,
raw,
});
Ok(())
}
}
pub struct EmailServiceImpl {
/// `None` → disabled mode (every send returns `NotConfigured`).
transport: Option<Arc<dyn EmailTransport>>,
@@ -328,27 +428,58 @@ impl EmailServiceImpl {
/// — email is non-critical and must not block startup.
#[must_use]
pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self {
Self::from_env_with_dev_capture(authz).0
}
/// Like [`from_env`](Self::from_env), but in **dev mode with no SMTP
/// relay** it wires a [`DevEmailTransport`] that captures mail in
/// memory instead of returning `NotConfigured` — so `email::send` is
/// exercisable locally (G5). Returns the sink handle (`Some`) when
/// capture mode is active, so the caller can expose it via the
/// dev-only inspection endpoint.
///
/// Production is unaffected: without `PICLOUD_DEV_MODE=true` an unset
/// relay still yields disabled mode (`NotConfigured`), never capture.
#[must_use]
pub fn from_env_with_dev_capture(
authz: Arc<dyn AuthzRepo>,
) -> (Self, Option<Arc<DevEmailSink>>) {
let config = EmailConfig::from_env();
let transport: Option<Arc<dyn EmailTransport>> = match SmtpConfig::from_env() {
match SmtpConfig::from_env() {
Some(cfg) => {
let transport: Option<Arc<dyn EmailTransport>> = match LettreEmailTransport::build(
&cfg,
) {
Ok(t) => {
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
Some(Arc::new(t))
}
Err(e) => {
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
None
}
};
(Self::new(transport, authz, config), None)
}
None if dev_mode_enabled() => {
tracing::warn!(
"email DEV CAPTURE: PICLOUD_DEV_MODE=true and no SMTP relay configured — \
email::send will SUCCEED and capture messages in memory (last {DEV_EMAIL_CAPACITY}, \
readable at GET /api/v1/admin/dev/emails). NEVER use this in production."
);
let sink = Arc::new(DevEmailSink::new(DEV_EMAIL_CAPACITY));
let transport: Arc<dyn EmailTransport> =
Arc::new(DevEmailTransport::new(sink.clone()));
(Self::new(Some(transport), authz, config), Some(sink))
}
None => {
tracing::warn!(
"email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \
email::send. Scripts calling email::send will get an error."
);
None
(Self::new(None, authz, config), None)
}
Some(cfg) => match LettreEmailTransport::build(&cfg) {
Ok(t) => {
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
Some(Arc::new(t))
}
Err(e) => {
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
None
}
},
};
Self::new(transport, authz, config)
}
}
async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> {

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -83,6 +83,11 @@ impl InvokeServiceImpl {
if script.app_id != 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,
@@ -103,6 +108,9 @@ impl InvokeServiceImpl {
.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,
@@ -313,6 +321,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

@@ -0,0 +1,162 @@
//! `/api/v1/admin/apps/{id}/kv*` — read-only KV inspection (G2).
//!
//! Mirrors the minimal `files_api` / `queues_api` admin surface so the
//! `pic kv` CLI (and a future dashboard tab) can browse stored keys
//! without a script. **Read-only by design** — KV writes go through
//! `kv::set` in scripts, which emit change events the trigger framework
//! depends on; an admin write would bypass that and could break app
//! invariants, so it is deliberately out of scope here (matching the
//! read-only queues precedent).
//!
//! Two operations:
//! * `GET /apps/{id}/kv?collection=<c>&cursor=&limit=` — list keys in a
//! collection (cursor-paginated).
//! * `GET /apps/{id}/kv/{collection}/{key}` — fetch one value.
//!
//! Capability: `AppKvRead`, resolved against the app loaded from the
//! path (same tier the SDK read path uses).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::kv_repo::KvRepo;
#[derive(Clone)]
pub struct KvAdminState {
pub kv: Arc<dyn KvRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn kv_admin_router(state: KvAdminState) -> Router {
Router::new()
.route("/apps/{app_id}/kv", get(list_keys))
.route("/apps/{app_id}/kv/{collection}/{key}", get(get_value))
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListKvQuery {
pub collection: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
struct GetValueResponse {
value: serde_json::Value,
}
/// Serialize mirror of `shared::KvListPage` (which is not `Serialize`).
#[derive(Debug, Serialize)]
struct ListKeysResponse {
keys: Vec<String>,
next_cursor: Option<String>,
}
async fn list_keys(
State(s): State<KvAdminState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListKvQuery>,
) -> Result<Json<ListKeysResponse>, KvApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
let page =
s.kv.list(
app_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await
.map_err(|e| KvApiError::Backend(e.to_string()))?;
Ok(Json(ListKeysResponse {
keys: page.keys,
next_cursor: page.next_cursor,
}))
}
async fn get_value(
State(s): State<KvAdminState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, collection, key)): Path<(String, String, String)>,
) -> Result<Json<GetValueResponse>, KvApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
let value =
s.kv.get(app_id, &collection, &key)
.await
.map_err(|e| KvApiError::Backend(e.to_string()))?
.ok_or(KvApiError::NotFound)?;
Ok(Json(GetValueResponse { value }))
}
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, KvApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| KvApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(KvApiError::AppNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum KvApiError {
#[error("app not found")]
AppNotFound,
#[error("key not found")]
NotFound,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("kv backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for KvApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for KvApiError {
fn into_response(self) -> Response {
use axum::http::StatusCode;
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "kv admin authz error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "kv admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -23,16 +23,21 @@ pub mod app_user_repo;
pub mod app_user_role_repo;
pub mod app_user_session_repo;
pub mod app_user_verification_repo;
pub mod apply_api;
pub mod apply_service;
pub mod apps_api;
pub mod auth;
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;
pub mod dead_letters_api;
pub mod dev_email_api;
pub mod dispatcher;
pub mod docs_filter;
pub mod docs_repo;
@@ -44,8 +49,12 @@ 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 groups_api;
pub mod http_service;
pub mod invoke_service;
pub mod kv_api;
pub mod kv_repo;
pub mod kv_service;
pub mod log_sink;
@@ -77,6 +86,9 @@ pub mod trigger_repo;
pub mod triggers_api;
pub mod users_admin_api;
pub mod users_service;
pub mod vars_api;
pub mod vars_repo;
pub mod vars_service;
pub use abandoned_repo::{
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
@@ -126,6 +138,8 @@ pub use app_user_session_repo::{
pub use app_user_verification_repo::{
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
};
pub use apply_api::apply_router;
pub use apply_service::{ApplyError, ApplyService, Bundle, Plan};
pub use apps_api::{apps_router, AppsState};
pub use auth_api::auth_router;
pub use auth_bootstrap::{
@@ -137,12 +151,14 @@ 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,
};
pub use dead_letter_service::PostgresDeadLetterService;
pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
pub use dev_email_api::{dev_emails_router, DevEmailState};
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
pub use docs_service::DocsServiceImpl;
@@ -150,15 +166,25 @@ pub use email_inbound_api::{
email_inbound_router, EmailInboundError, EmailInboundState, InboundNonceDedup,
};
pub use email_service::{
EmailConfig, EmailServiceImpl, EmailTransport, LettreEmailTransport, SmtpConfig, SmtpTls,
DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
CapturedEmail, DevEmailSink, DevEmailTransport, EmailConfig, EmailServiceImpl, EmailTransport,
LettreEmailTransport, SmtpConfig, SmtpTls, DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
};
pub use files_api::{files_admin_router, FilesAdminState};
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 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};
pub use kv_service::KvServiceImpl;
pub use log_sink::PostgresExecutionLogSink;
@@ -180,11 +206,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
pub use sandbox::{CeilingError, SandboxCeiling};
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
pub use secrets_repo::{
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
SecretsRepoError, StoredSecret,
};
pub use secrets_service::{
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl,
DEFAULT_SECRET_MAX_VALUE_BYTES,
};
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
@@ -199,3 +225,6 @@ pub use trigger_repo::{
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
pub use vars_service::VarsServiceImpl;

View File

@@ -31,9 +31,9 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
logs, duration_ms, status, source, created_at \
) VALUES ( \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 \
)",
)
.bind(log.id)
@@ -48,6 +48,7 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
.bind(&log.script_logs)
.bind(duration_ms)
.bind(log.status.as_str())
.bind(log.source.as_str())
.bind(log.created_at)
.execute(&self.pool)
.await

View File

@@ -3,8 +3,8 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptKind,
ScriptSandbox,
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
ScriptId, ScriptKind, ScriptSandbox,
};
use sqlx::PgPool;
@@ -154,6 +154,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 +178,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
@@ -203,7 +207,8 @@ impl PostgresScriptRepository {
/// 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";
timeout_seconds, memory_limit_mb, sandbox, enabled, \
created_at, updated_at";
#[async_trait]
impl ScriptRepository for PostgresScriptRepository {
@@ -273,42 +278,8 @@ impl ScriptRepository for PostgresScriptRepository {
}
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({}));
let mut tx = self.pool.begin().await?;
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) \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(input.app_id.into_inner())
.bind(&input.name)
.bind(input.description.as_deref())
.bind(&input.source)
.bind(input.kind.as_str())
.bind(input.timeout_seconds)
.bind(input.memory_limit_mb)
.bind(sandbox_json)
.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",
input.name
)));
}
Err(e) => return Err(e.into()),
};
// Dep-graph: write any literal-path imports declared in the
// source. Unresolved names (the referenced module doesn't
// exist yet) are silently skipped — best-effort.
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
let script = insert_script_tx(&mut tx, &input).await?;
tx.commit().await?;
Ok(script)
}
@@ -318,62 +289,8 @@ impl ScriptRepository for PostgresScriptRepository {
id: ScriptId,
patch: ScriptPatch,
) -> Result<Script, ScriptRepositoryError> {
// COALESCE-based partial update: `NULL` parameters leave columns
// untouched. Description is double-Optioned so callers can
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
// Sandbox is replaced wholesale when present; per-field merging
// happens in the API layer (clearer semantics for a "PUT a new
// sandbox config" call). app_id is immutable — moving a script
// to another app is a copy-and-delete, not an in-place edit.
let sandbox_json = patch
.sandbox
.as_ref()
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
let mut tx = self.pool.begin().await?;
let res = sqlx::query_as::<_, ScriptRow>(&format!(
"UPDATE scripts SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
source = COALESCE($5, source), \
timeout_seconds = COALESCE($6, timeout_seconds), \
memory_limit_mb = COALESCE($7, memory_limit_mb), \
sandbox = COALESCE($8, sandbox), \
kind = COALESCE($9, kind), \
version = version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(id.into_inner())
.bind(patch.name.as_deref())
.bind(patch.description.is_some())
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
.bind(patch.source.as_deref())
.bind(patch.timeout_seconds)
.bind(patch.memory_limit_mb)
.bind(sandbox_json)
.bind(patch.kind.map(ScriptKind::as_str))
.fetch_optional(&mut *tx)
.await;
let script: Script = match res {
Ok(Some(row)) => row.into(),
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(),
));
}
Err(e) => return Err(e.into()),
};
// Replace imports only when the caller has a fresh list (i.e.
// the source actually changed and the validator re-extracted
// imports). A name-only or description-only edit leaves the
// dep graph alone.
if let Some(imports) = patch.imports.as_deref() {
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
}
let script = update_script_tx(&mut tx, id, &patch).await?;
tx.commit().await?;
Ok(script)
}
@@ -469,6 +386,117 @@ async fn replace_imports_tx(
Ok(())
}
/// Insert a script within an existing transaction — the declarative
/// `apply` engine composes scripts + routes + triggers into one tx.
/// Mirrors `create` minus the `begin`/`commit`.
pub(crate) async fn insert_script_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
input: &NewScript,
) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.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, enabled \
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(input.app_id.into_inner())
.bind(&input.name)
.bind(input.description.as_deref())
.bind(&input.source)
.bind(input.kind.as_str())
.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",
input.name
)));
}
Err(e) => return Err(e.into()),
};
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
Ok(script)
}
/// Update a script within an existing transaction. Mirrors `update`
/// minus the `begin`/`commit`.
pub(crate) async fn update_script_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: ScriptId,
patch: &ScriptPatch,
) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = patch
.sandbox
.as_ref()
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
let res = sqlx::query_as::<_, ScriptRow>(&format!(
"UPDATE scripts SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
source = COALESCE($5, source), \
timeout_seconds = COALESCE($6, timeout_seconds), \
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 \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(id.into_inner())
.bind(patch.name.as_deref())
.bind(patch.description.is_some())
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
.bind(patch.source.as_deref())
.bind(patch.timeout_seconds)
.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 {
Ok(Some(row)) => row.into(),
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(),
));
}
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?;
}
Ok(script)
}
/// Delete a script within an existing transaction (its routes/triggers
/// cascade via their FKs). Mirrors `delete` minus the pool.
pub(crate) async fn delete_script_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: ScriptId,
) -> Result<(), ScriptRepositoryError> {
let res = sqlx::query("DELETE FROM scripts WHERE id = $1")
.bind(id.into_inner())
.execute(&mut **tx)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::NotFound(id));
}
Ok(())
}
/// Row shape mirroring the `scripts` table for sqlx FromRow.
#[derive(sqlx::FromRow)]
struct ScriptRow {
@@ -485,6 +513,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>,
}
@@ -511,6 +540,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,
}
@@ -584,6 +614,7 @@ pub trait ExecutionLogRepository: Send + Sync {
script_id: ScriptId,
limit: i64,
cursor: Option<ExecutionLogCursor>,
source: Option<ExecutionSource>,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError>;
}
@@ -605,23 +636,30 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
script_id: ScriptId,
limit: i64,
cursor: Option<ExecutionLogCursor>,
source: Option<ExecutionSource>,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
// The optional `source` filter is folded into one bind via
// `$N::text IS NULL OR source = $N` so we don't fan out into four
// query strings. `None` → the predicate is always true (no filter).
let source = source.map(ExecutionSource::as_str);
let rows = match cursor {
Some(c) => {
sqlx::query_as::<_, ExecutionLogRow>(
"SELECT id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
logs, duration_ms, status, source, created_at \
FROM execution_logs \
WHERE script_id = $1 \
AND (created_at, id) < ($2, $3) \
AND ($4::text IS NULL OR source = $4) \
ORDER BY created_at DESC, id DESC \
LIMIT $4",
LIMIT $5",
)
.bind(script_id.into_inner())
.bind(c.created_at)
.bind(c.id)
.bind(source)
.bind(limit)
.fetch_all(&self.pool)
.await?
@@ -631,13 +669,15 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
"SELECT id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
logs, duration_ms, status, source, created_at \
FROM execution_logs \
WHERE script_id = $1 \
AND ($2::text IS NULL OR source = $2) \
ORDER BY created_at DESC, id DESC \
LIMIT $2",
LIMIT $3",
)
.bind(script_id.into_inner())
.bind(source)
.bind(limit)
.fetch_all(&self.pool)
.await?
@@ -662,6 +702,7 @@ struct ExecutionLogRow {
logs: serde_json::Value,
duration_ms: i32,
status: String,
source: String,
created_at: chrono::DateTime<chrono::Utc>,
}
@@ -675,6 +716,9 @@ impl From<ExecutionLogRow> for ExecutionLog {
"budget_exceeded" => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
// Unknown values can't occur (CHECK constraint) but default to
// Http rather than panicking on a forward-compat surprise.
let source = ExecutionSource::from_wire(&r.source).unwrap_or_default();
Self {
id: r.id,
app_id: r.app_id.into(),
@@ -688,6 +732,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
script_logs: r.logs,
duration_ms: u64::try_from(r.duration_ms).unwrap_or(0),
status,
source,
created_at: r.created_at,
}
}

View File

@@ -229,6 +229,8 @@ 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,
})
.await?;
refresh_table(&state).await?;
@@ -373,31 +375,63 @@ async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?;
let compiled = compile_routes(&rows);
state.table.replace_all(compiled);
Ok(())
}
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> {
/// Compile stored route rows into the in-memory match table.
///
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
/// a warning*, not propagated as an error. The motivating case: a path
/// that was valid when created but became reserved under a later, stricter
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
/// also covers any other parse failure. A single un-compilable legacy row
/// must never take down the entire data plane: this function runs at
/// startup (where a hard error aborts boot) and on every table rebuild
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
/// route simply doesn't match; the warning tells the operator to delete or
/// fix it (and migration 0044 sweeps the reserved-path offenders on
/// upgrade).
#[must_use]
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
rows.iter()
.map(|r| {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
method: r.method.clone(),
dispatch_mode: r.dispatch_mode,
})
// 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) => {
tracing::warn!(
route_id = %r.id,
app_id = %r.app_id,
path = %r.path,
error = %e,
"skipping un-compilable stored route — it will not match; \
delete or fix it"
);
None
}
})
.collect()
}
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
method: r.method.clone(),
dispatch_mode: r.dispatch_mode,
})
}
/// Validate that a new route's (host_kind, host) is consistent with at
/// least one of the parent app's domain claims. `HostKind::Any` is
/// always permitted — it catches every host the app already owns.
async fn validate_route_host_against_app(
pub(crate) async fn validate_route_host_against_app(
domains: &dyn AppDomainRepository,
app_id: AppId,
host_kind: HostKind,
@@ -577,3 +611,62 @@ impl IntoResponse for RouteApiError {
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use picloud_shared::DispatchMode;
use uuid::Uuid;
fn route_with_path(path: &str) -> Route {
Route {
id: Uuid::new_v4(),
app_id: AppId::from(Uuid::new_v4()),
script_id: ScriptId::from(Uuid::new_v4()),
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: path.to_string(),
method: None,
dispatch_mode: DispatchMode::default(),
enabled: true,
created_at: chrono::Utc::now(),
}
}
#[test]
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
// H1 regression guard: a stored route whose path is now reserved
// (creatable before the case-insensitive reserved-prefix fix) must
// be skipped, not abort the whole compile — otherwise one legacy
// row bricks startup (`compile_routes` runs in `build_app`).
let good_a = route_with_path("/ok");
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
let good_b = route_with_path("/items");
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
let compiled = compile_routes(&rows);
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
assert!(ids.contains(&good_a.id));
assert!(ids.contains(&good_b.id));
assert!(
!ids.contains(&bad.id),
"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,8 @@ 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,
}
#[async_trait]
@@ -63,7 +65,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 +76,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 +88,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 +103,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())
@@ -111,36 +113,10 @@ impl RouteRepository for PostgresRouteRepository {
}
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
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) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind))
.bind(&input.host)
.bind(input.host_param_name.as_deref())
.bind(path_kind_str(input.path_kind))
.bind(&input.path)
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
Err(ScriptRepositoryError::NotFound(input.script_id))
}
Err(e) => Err(e.into()),
}
let mut tx = self.pool.begin().await?;
let route = insert_route_tx(&mut tx, &input).await?;
tx.commit().await?;
Ok(route)
}
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
@@ -189,6 +165,63 @@ const fn path_kind_str(k: PathKind) -> &'static str {
}
}
/// Insert a route within an existing transaction (declarative apply
/// composes scripts + routes + triggers into one tx). Mirrors `create`
/// minus the `begin`/`commit`.
pub(crate) async fn insert_route_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
input: &NewRoute,
) -> Result<Route, ScriptRepositoryError> {
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, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, enabled, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind))
.bind(&input.host)
.bind(input.host_param_name.as_deref())
.bind(path_kind_str(input.path_kind))
.bind(&input.path)
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.fetch_one(&mut **tx)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
Err(ScriptRepositoryError::NotFound(input.script_id))
}
Err(e) => Err(e.into()),
}
}
/// 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,
) -> Result<(), ScriptRepositoryError> {
sqlx::query("DELETE FROM routes WHERE id = $1")
.bind(route_id)
.execute(&mut **tx)
.await?;
Ok(())
}
#[derive(sqlx::FromRow)]
struct RouteRow {
id: Uuid,
@@ -201,6 +234,7 @@ struct RouteRow {
path: String,
method: Option<String>,
dispatch_mode: String,
enabled: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@@ -225,6 +259,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

@@ -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,
@@ -502,6 +504,220 @@ impl PostgresTriggerRepo {
}
}
/// Insert a trigger (parent row + per-kind detail) within an existing
/// transaction — used by the declarative `apply` engine. Supports the
/// five settled kinds; `email`/`queue`/`dead_letter` have their own
/// create paths and are rejected here.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(crate) async fn insert_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
script_id: ScriptId,
registered_by: AdminUserId,
dispatch_mode: TriggerDispatchMode,
retry_max_attempts: u32,
retry_backoff: BackoffShape,
retry_base_ms: u32,
details: &TriggerDetails,
) -> Result<TriggerId, TriggerRepoError> {
let kind = match details {
TriggerDetails::Kv { .. } => "kv",
TriggerDetails::Docs { .. } => "docs",
TriggerDetails::Files { .. } => "files",
TriggerDetails::Cron { .. } => "cron",
TriggerDetails::Pubsub { .. } => "pubsub",
TriggerDetails::Queue { .. } => "queue",
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
return Err(TriggerRepoError::Invalid(
"trigger kind not supported by declarative apply".into(),
));
}
};
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
// the same advisory-lock + existence guard the interactive
// `create_queue_trigger` uses. Without this, a concurrent apply +
// interactive create on disjoint locks could double-register a queue
// consumer (there is no DB unique constraint backing the invariant).
if let TriggerDetails::Queue { queue_name, .. } = details {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(advisory_lock_key(app_id, queue_name))
.execute(&mut **tx)
.await?;
let existing: Option<(Uuid,)> = sqlx::query_as(
"SELECT t.id FROM triggers t \
JOIN queue_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'queue' AND d.queue_name = $2",
)
.bind(app_id.into_inner())
.bind(queue_name)
.fetch_optional(&mut **tx)
.await?;
if existing.is_some() {
return Err(TriggerRepoError::Invalid(format!(
"queue '{queue_name}' already has a consumer trigger; remove the existing one first"
)));
}
}
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, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.bind(kind)
.bind(dispatch_mode.as_str())
.bind(i32::try_from(retry_max_attempts).unwrap_or(3))
.bind(retry_backoff.as_str())
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
.bind(registered_by.into_inner())
.fetch_one(&mut **tx)
.await?;
let tid = row.0;
match details {
TriggerDetails::Kv {
collection_glob,
ops,
} => {
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(tid)
.bind(collection_glob)
.bind(&ops_str)
.execute(&mut **tx)
.await?;
}
TriggerDetails::Docs {
collection_glob,
ops,
} => {
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(tid)
.bind(collection_glob)
.bind(&ops_str)
.execute(&mut **tx)
.await?;
}
TriggerDetails::Files {
collection_glob,
ops,
} => {
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(tid)
.bind(collection_glob)
.bind(&ops_str)
.execute(&mut **tx)
.await?;
}
TriggerDetails::Cron {
schedule, timezone, ..
} => {
sqlx::query(
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
VALUES ($1, $2, $3)",
)
.bind(tid)
.bind(schedule)
.bind(timezone)
.execute(&mut **tx)
.await?;
}
TriggerDetails::Pubsub { topic_pattern } => {
sqlx::query(
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
)
.bind(tid)
.bind(topic_pattern)
.execute(&mut **tx)
.await?;
}
TriggerDetails::Queue {
queue_name,
visibility_timeout_secs,
..
} => {
sqlx::query(
"INSERT INTO queue_trigger_details \
(trigger_id, queue_name, visibility_timeout_secs) \
VALUES ($1, $2, $3)",
)
.bind(tid)
.bind(queue_name)
.bind(i32::try_from(*visibility_timeout_secs).unwrap_or(30))
.execute(&mut **tx)
.await?;
}
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
unreachable!("guarded above")
}
}
Ok(tid.into())
}
/// Insert an email trigger within a transaction. The inbound HMAC secret
/// is sealed by the apply engine (resolved from the app's secret store);
/// this writes the ciphertext. Parent retry settings match the
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
pub(crate) async fn insert_email_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
script_id: ScriptId,
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
) -> 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",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.bind(registered_by.into_inner())
.fetch_one(&mut **tx)
.await?;
sqlx::query(
"INSERT INTO email_trigger_details \
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
VALUES ($1, $2, $3)",
)
.bind(row.0)
.bind(inbound_secret_encrypted)
.bind(inbound_secret_nonce)
.execute(&mut **tx)
.await?;
Ok(row.0.into())
}
/// Delete a trigger by id within an existing transaction (its detail row
/// cascades via the FK). Used by `apply --prune`.
pub(crate) async fn delete_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: TriggerId,
) -> Result<(), TriggerRepoError> {
sqlx::query("DELETE FROM triggers WHERE id = $1")
.bind(id.into_inner())
.execute(&mut **tx)
.await?;
Ok(())
}
#[async_trait]
impl TriggerRepo for PostgresTriggerRepo {
async fn create_kv_trigger(
@@ -521,7 +737,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",
)
@@ -552,6 +768,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),
@@ -586,7 +803,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",
)
@@ -617,6 +834,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),
@@ -649,7 +867,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",
)
@@ -677,6 +895,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),
@@ -713,7 +932,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",
)
@@ -743,6 +962,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),
@@ -778,7 +998,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",
)
@@ -809,6 +1029,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),
@@ -842,7 +1063,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",
)
@@ -870,6 +1091,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),
@@ -902,7 +1124,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",
)
@@ -929,6 +1151,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),
@@ -971,7 +1194,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",
@@ -999,7 +1222,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",
@@ -1249,7 +1472,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",
)
@@ -1280,6 +1503,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),
@@ -1308,7 +1532,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?;
@@ -1486,6 +1711,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),
@@ -1528,6 +1754,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
@@ -892,6 +892,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 +921,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 +950,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 +980,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 +1025,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 +1055,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 +1084,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 +1161,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 +1209,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 +1227,7 @@ mod tests {
_slug: &str,
_name: &str,
_description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
@@ -1226,6 +1236,7 @@ mod tests {
_slug: &str,
_name: &str,
_description: Option<&str>,
_group_id: picloud_shared::GroupId,
) -> Result<App, crate::repo::ScriptRepositoryError> {
unimplemented!()
}
@@ -1244,6 +1255,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,
@@ -1347,6 +1364,7 @@ mod tests {
timeout_seconds: 30,
sandbox: picloud_shared::ScriptSandbox::default(),
memory_limit_mb: 256,
enabled: true,
created_at: now,
updated_at: now,
},
@@ -1716,6 +1734,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 +1746,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

@@ -194,6 +194,7 @@ table: execution_logs
status: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
source: text NOT NULL default='http'::text
table: files
app_id: uuid NOT NULL
@@ -590,6 +591,7 @@ constraints on email_trigger_details:
[PRIMARY KEY] email_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on execution_logs:
[CHECK] execution_logs_source_check: CHECK ((source = ANY (ARRAY['http'::text, 'kv'::text, 'docs'::text, 'dead_letter'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'invoke'::text, 'queue'::text])))
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
@@ -711,3 +713,5 @@ constraints on triggers:
0040: execution logs keep history
0041: dead letters composite idx
0042: secrets envelope version
0043: execution logs source
0044: delete reserved path routes

View File

@@ -15,11 +15,13 @@ use axum::{
Extension, Json, Router,
};
use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_executor_core::{
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
};
use picloud_shared::{
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus,
HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox, OutboxWriter, Principal,
RequestId, ScriptId,
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionSource,
ExecutionStatus, HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox,
OutboxWriter, Principal, RequestId, ScriptId,
};
use serde_json::Value as Json_;
use uuid::Uuid;
@@ -119,6 +121,11 @@ 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));
}
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
req.sandbox_overrides = script.sandbox;
@@ -150,6 +157,7 @@ where
request_path,
request_headers,
request_body,
ExecutionSource::Http,
&outcome,
started,
finished,
@@ -161,6 +169,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>>,
@@ -218,6 +227,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.
@@ -530,6 +552,7 @@ fn build_inbox_execution_log(
script_logs: Json_::Array(vec![]),
duration_ms,
status,
source: ExecutionSource::Http,
created_at: started,
}
}
@@ -630,67 +653,9 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
(status, http_headers, Json(resp.body)).into_response()
}
#[allow(clippy::too_many_arguments)]
fn build_execution_log(
app_id: AppId,
script_id: ScriptId,
request_id: RequestId,
request_path: String,
request_headers: BTreeMap<String, String>,
request_body: Json_,
outcome: &Result<ExecResponse, ExecError>,
started: chrono::DateTime<Utc>,
finished: chrono::DateTime<Utc>,
) -> ExecutionLog {
let duration_ms = u64::try_from(
finished
.signed_duration_since(started)
.num_milliseconds()
.max(0),
)
.unwrap_or(0);
let (status, response_code, response_body, script_logs) = match outcome {
Ok(resp) => {
let logs = serde_json::to_value(&resp.logs).unwrap_or(Json_::Array(vec![]));
(
ExecutionStatus::Success,
Some(resp.status_code),
Some(resp.body.clone()),
logs,
)
}
Err(e) => {
let status = match e {
ExecError::Timeout(_) => ExecutionStatus::Timeout,
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
(
status,
None,
Some(serde_json::json!({ "error": e.to_string() })),
Json_::Array(vec![]),
)
}
};
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,
request_headers,
request_body,
response_code,
response_body,
script_logs,
duration_ms,
status,
created_at: started,
}
}
// `build_execution_log` moved to `picloud_executor_core` so the manager's
// trigger dispatcher can build identically-shaped rows (G1 — trigger
// executions are now logged with their `ExecutionSource`).
// ----------------------------------------------------------------------------
// Errors

View File

@@ -108,8 +108,15 @@ pub fn parse_path(kind: PathKind, raw: &str) -> Result<PathPattern, ParseError>
}
fn check_reserved(raw: &str) -> Result<(), ParseError> {
// Case-fold before comparing: request-time path matching is case-sensitive,
// but the reserved namespace must be rejected regardless of case so a tenant
// can't publish look-alikes like `/Admin/login` or `/API/v2/x`. Method/host
// matching are already case-insensitive; this keeps the validation guard
// durable even if path matching ever follows.
let lowered = raw.to_ascii_lowercase();
for r in RESERVED_PATH_PREFIXES {
if raw == r.trim_end_matches('/') || raw.starts_with(r) {
if lowered == r.trim_end_matches('/') || lowered.starts_with(r) {
// Preserve the caller's original case in the error for clarity.
return Err(ParseError::ReservedPath(raw.to_string()));
}
}
@@ -456,6 +463,29 @@ mod tests {
}
}
#[test]
fn rejects_reserved_paths_case_insensitively() {
// Case variants of every reserved prefix must be rejected: the reserved
// namespace is a security boundary and must not be bypassable by casing.
for raw in [
"/API/v2/foo",
"/Api/v2/foo",
"/aPi/x",
"/Admin/dashboard",
"/ADMIN/x",
"/HEALTHZ",
"/HealthZ",
"/Version",
"/VERSION",
] {
let e = parse_path(PathKind::Exact, raw).unwrap_err();
assert!(
matches!(e, ParseError::ReservedPath(_)),
"expected reserved for {raw:?}, got {e:?}"
);
}
}
#[test]
fn rejects_missing_leading_slash() {
let e = parse_path(PathKind::Exact, "greet").unwrap_err();

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,
Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
ScriptSandbox,
};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
@@ -149,6 +150,144 @@ impl Client {
decode_status(resp).await
}
// --- Groups (Phase 2) -------------------------------------------------
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
/// client-side from `parent_id`).
pub async fn groups_list(&self) -> Result<Vec<Group>> {
let resp = self
.request(Method::GET, "/api/v1/admin/groups")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
let ident = seg(ident);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups`
pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result<Group> {
let resp = self
.request(Method::POST, "/api/v1/admin/groups")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only
/// (the slug is frozen).
pub async fn groups_rename(
&self,
ident: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<Group> {
let ident = seg(ident);
let body = serde_json::json!({ "name": name, "description": description });
let resp = self
.request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a
/// slug/id, or `None` to move to root.
pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result<Group> {
let ident = seg(ident);
let body = serde_json::json!({ "parent": parent });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/groups/{ident}/reparent"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty.
pub async fn groups_delete(&self, ident: &str) -> Result<()> {
let ident = seg(ident);
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}"))
.send()
.await?;
decode_status(resp).await
}
pub async fn group_members_list(&self, group: &str) -> Result<Vec<AppMemberDto>> {
let group = seg(group);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/groups/{group}/members"),
)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_grant(
&self,
group: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let group = seg(group);
let body = serde_json::json!({ "user_id": user_id, "role": role });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/groups/{group}/members"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_set_role(
&self,
group: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let (group, user_id) = (seg(group), seg(user_id));
let body = serde_json::json!({ "role": role });
let resp = self
.request(
Method::PATCH,
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> {
let (group, user_id) = (seg(group), seg(user_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
)
.send()
.await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
@@ -171,10 +310,23 @@ impl Client {
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics.
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
/// uses PUT despite the field-level update semantics. `cfg` carries
/// optional per-script runtime overrides (G3); unset fields are
/// omitted so they keep their stored value.
pub async fn scripts_update_source(
&self,
id: &str,
source: &str,
cfg: &ScriptConfig,
) -> Result<Script> {
let id = seg(id);
let body = UpdateScriptBody { source };
let body = UpdateScriptBody {
source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
let resp = self
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
.json(&body)
@@ -222,15 +374,19 @@ impl Client {
}
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
pub async fn logs_list(
&self,
script_id: &str,
limit: u32,
source: Option<&str>,
) -> Result<Vec<ExecutionLog>> {
let script_id = seg(script_id);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
)
.send()
.await?;
let mut path = format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}");
if let Some(src) = source {
path.push_str("&source=");
path.push_str(&seg(src));
}
let resp = self.request(Method::GET, &path).send().await?;
decode(resp).await
}
@@ -501,39 +657,138 @@ impl Client {
decode_status(resp).await
}
/// `GET /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_list(&self, app: &str) -> Result<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 ----------
@@ -709,6 +964,228 @@ impl Client {
.await?;
decode(resp).await
}
// --- App membership (G2) ----------------------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/members`
pub async fn members_list(&self, app: &str) -> Result<Vec<AppMemberDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/members"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id_or_slug}/members`
pub async fn members_grant(
&self,
app: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let app = seg(app);
let body = serde_json::json!({ "user_id": user_id, "role": role });
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/members"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
pub async fn members_set_role(
&self,
app: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let (app, user_id) = (seg(app), seg(user_id));
let body = serde_json::json!({ "role": role });
let resp = self
.request(
Method::PATCH,
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
pub async fn members_remove(&self, app: &str, user_id: &str) -> Result<()> {
let (app, user_id) = (seg(app), seg(user_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
)
.send()
.await?;
decode_status(resp).await
}
// --- Files (G2, read-only admin surface) ------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/files?collection=&limit=`
pub async fn files_list(
&self,
app: &str,
collection: &str,
limit: u32,
) -> Result<ListFilesResponse> {
let app = seg(app);
let collection = seg(collection);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/files?collection={collection}&limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}` —
/// streams the raw bytes (download). Returns the body verbatim.
pub async fn files_get_bytes(
&self,
app: &str,
collection: &str,
file_id: &str,
) -> Result<Vec<u8>> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
)
.send()
.await?;
if resp.status().is_success() {
Ok(resp.bytes().await.context("reading file bytes")?.to_vec())
} else {
Err(server_error(resp).await)
}
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}`
pub async fn files_delete(&self, app: &str, collection: &str, file_id: &str) -> Result<()> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
)
.send()
.await?;
decode_status(resp).await
}
// --- KV (G2, read-only admin surface) ---------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/kv?collection=&limit=`
pub async fn kv_list(&self, app: &str, collection: &str, limit: u32) -> Result<KvListPageDto> {
let app = seg(app);
let collection = seg(collection);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/kv?collection={collection}&limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/kv/{collection}/{key}`
pub async fn kv_get(&self, app: &str, collection: &str, key: &str) -> Result<Value> {
let (app, collection, key) = (seg(app), seg(collection), seg(key));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/kv/{collection}/{key}"),
)
.send()
.await?;
let wrapped: KvGetResponse = decode(resp).await?;
Ok(wrapped.value)
}
// --- Queues (G2, read-only admin surface) -----------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
pub async fn queues_list(&self, app: &str) -> Result<Vec<QueueSummaryDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/queues"))
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/queues/{queue_name}`
pub async fn queue_get(&self, app: &str, queue_name: &str) -> Result<QueueDetailDto> {
let (app, queue_name) = (seg(app), seg(queue_name));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/queues/{queue_name}"),
)
.send()
.await?;
decode(resp).await
}
/// `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);
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
.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(
&self,
app: &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,
"expected_token": expected_token,
});
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
.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 app 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/auth/login` — sits outside the `Client` because
@@ -733,6 +1210,54 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
#[derive(Debug, Deserialize)]
pub struct PlanDto {
#[serde(default)]
pub scripts: Vec<ChangeDto>,
#[serde(default)]
pub routes: Vec<ChangeDto>,
#[serde(default)]
pub triggers: Vec<ChangeDto>,
#[serde(default)]
pub secrets: 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)]
pub struct ChangeDto {
pub op: String,
pub key: String,
#[serde(default)]
pub detail: Option<String>,
}
/// Response of `POST .../apply`: counts of what changed.
#[derive(Debug, Default, Deserialize)]
pub struct ApplyReportDto {
#[serde(default)]
pub scripts_created: u32,
#[serde(default)]
pub scripts_updated: u32,
#[serde(default)]
pub scripts_deleted: u32,
#[serde(default)]
pub routes_created: u32,
#[serde(default)]
pub routes_updated: u32,
#[serde(default)]
pub routes_deleted: u32,
#[serde(default)]
pub triggers_created: u32,
#[serde(default)]
pub triggers_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct AuthMeDto {
@@ -762,6 +1287,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)]
@@ -943,6 +1493,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>,
@@ -954,9 +1535,83 @@ 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).
#[derive(Debug, Default, Clone)]
pub struct ScriptConfig {
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
pub kind: Option<ScriptKind>,
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
pub struct CreateScriptBody<'a> {
pub app_id: AppId,
@@ -964,11 +1619,104 @@ pub struct CreateScriptBody<'a> {
#[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,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox: Option<ScriptSandbox>,
}
// --- G2 response DTOs (deserialize-only mirrors of the server shapes) ---
// `#[allow(dead_code)]` on a couple of fields below: these structs mirror
// the full server response shape (so the surface is documented and future
// columns are a one-line add), but the TSV/KvBlock renderers don't print
// every field today.
#[derive(Debug, Deserialize)]
pub struct AppMemberDto {
pub user_id: AdminUserId,
pub username: String,
#[allow(dead_code)]
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct FileMetaDto {
pub id: String,
#[allow(dead_code)]
pub collection: String,
pub name: String,
pub content_type: String,
pub size: u64,
#[allow(dead_code)]
pub checksum: String,
#[allow(dead_code)]
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct ListFilesResponse {
pub files: Vec<FileMetaDto>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct KvListPageDto {
pub keys: Vec<String>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct KvGetResponse {
value: Value,
}
#[derive(Debug, Deserialize)]
pub struct QueueSummaryDto {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
}
#[derive(Debug, Deserialize)]
pub struct QueueConsumerDto {
pub trigger_id: String,
pub script_id: ScriptId,
pub script_name: String,
pub visibility_timeout_secs: u32,
pub last_fired_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct QueueDetailDto {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
pub consumer: Option<QueueConsumerDto>,
}
#[derive(Debug, Serialize)]

View File

@@ -0,0 +1,111 @@
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
//! 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::{Context, Result};
use crate::client::Client;
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,
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_with_env(manifest_path, env)?;
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
if prune && !yes {
confirm_prune(&manifest.app.slug)?;
}
// Bound-plan check: replay the token from the last `pic plan` (for this
// app) so the server refuses if the app changed since it was reviewed.
// `--force` skips it; no recorded plan means no check (apply still works
// standalone). The token is single-use — cleared after a successful apply.
let expected_token = if force {
None
} else {
crate::linkstate::read_plan(base_dir)
.filter(|l| l.app == manifest.app.slug)
.map(|l| l.state_token)
};
let report = client
.apply(
&manifest.app.slug,
&bundle,
prune,
expected_token.as_deref(),
)
.await?;
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
let mut block = KvBlock::new();
block
.field("app", manifest.app.slug.clone())
.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),
);
for w in &report.warnings {
block.field("warning", w.clone());
}
block.print(mode);
Ok(())
}
/// `--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. 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,100 @@
//! `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)?;
let eff = client.config_effective(&manifest.app.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,71 @@
//! `pic files ls | get | rm` — operator-facing files inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/files*` surface. There is
//! no `set`/`upload` — blob writes go through scripts (`files::create`);
//! the admin surface is inspect + delete only, matching the dashboard.
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.files_list(app, collection, limit).await?;
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
for f in page.files {
table.row([
f.id,
f.name,
f.content_type,
f.size.to_string(),
f.updated_at,
]);
}
table.print(mode);
if page.next_cursor.is_some() {
let _ = writeln!(
std::io::stderr(),
"(more results available — raise --limit to see them)"
);
}
Ok(())
}
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
/// streams raw bytes to stdout (pipe to a file or `xxd`).
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let bytes = client.files_get_bytes(app, collection, file_id).await?;
match out {
Some(path) => {
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
let _ = writeln!(
std::io::stderr(),
"Wrote {} bytes to {}",
bytes.len(),
path.display()
);
}
None => {
std::io::stdout()
.write_all(&bytes)
.context("writing bytes to stdout")?;
}
}
Ok(())
}
pub async fn rm(app: &str, collection: &str, file_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.files_delete(app, collection, file_id).await?;
println!("Deleted file {file_id} from {collection}");
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,278 @@
//! `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)?;
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: ManifestApp {
slug: slug.to_string(),
name: name.to_string(),
description: 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(),
}
}
/// 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

@@ -0,0 +1,42 @@
//! `pic kv ls | get` — read-only KV inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/kv*` surface. There is no
//! `set`/`rm` on purpose: KV writes go through `kv::set` in scripts (which
//! emit the change events triggers depend on); an admin write would bypass
//! that, so the CLI stays read-only (matching the queues precedent).
use std::io::Write;
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.kv_list(app, collection, limit).await?;
let mut table = Table::new(["key"]);
for k in page.keys {
table.row([k]);
}
table.print(mode);
if page.next_cursor.is_some() {
let _ = writeln!(
std::io::stderr(),
"(more keys available — raise --limit to see them)"
);
}
Ok(())
}
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let value = client.kv_get(app, collection, key).await?;
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
println!("{pretty}");
Ok(())
}

View File

@@ -12,10 +12,15 @@ use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
pub async fn run(
script_id: &str,
limit: u32,
source: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?;
let entries = client.logs_list(script_id, limit, source).await?;
match mode {
OutputMode::Tsv => render_tsv(&entries),
OutputMode::Json => render_json(&entries),
@@ -24,11 +29,14 @@ pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
}
fn render_tsv(entries: &[ExecutionLog]) {
let mut table = Table::new(["created_at", "status", "summary"]);
// `source` is shown so background runs (queue/cron/invoke/…) are
// distinguishable from HTTP at a glance — the whole point of G1.
let mut table = Table::new(["created_at", "source", "status", "summary"]);
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
table.row([
e.created_at.to_rfc3339(),
e.source.as_str().to_string(),
status_label(&e.status).to_string(),
truncate(&summary, 120),
]);

View File

@@ -0,0 +1,86 @@
//! `pic members ls | add | set | rm` — manage app membership (G2).
//!
//! Wraps `/api/v1/admin/apps/{id}/members*`. All gated on
//! `AppAdmin(app)` server-side; Editors/Viewers get a 403.
use anyhow::Result;
use picloud_shared::AppRole;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let members = client.members_list(app).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,
app_role_str(m.role).to_string(),
format!("{:?}", m.instance_role).to_lowercase(),
m.is_active.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn add(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.members_grant(app, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn set(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.members_set_role(app, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn rm(app: &str, user_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.members_remove(app, user_id).await?;
println!("Removed {user_id} from {app}");
Ok(())
}
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", app_role_str(m.role))
.field("created_at", m.created_at.to_rfc3339());
block.print(mode);
}
fn parse_role(s: &str) -> Result<AppRole> {
match s.to_ascii_lowercase().as_str() {
"app_admin" | "admin" => Ok(AppRole::AppAdmin),
"editor" => Ok(AppRole::Editor),
"viewer" => Ok(AppRole::Viewer),
other => Err(anyhow::anyhow!(
"unknown role {other:?} (want app_admin | editor | viewer)"
)),
}
}
fn app_role_str(r: AppRole) -> &'static str {
match r {
AppRole::AppAdmin => "app_admin",
AppRole::Editor => "editor",
AppRole::Viewer => "viewer",
}
}

View File

@@ -1,15 +1,26 @@
pub mod admins;
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;
pub mod logs;
pub mod members;
pub mod plan;
pub mod pull;
pub mod queues;
pub mod routes;
pub mod scripts;
pub mod secrets;
pub mod topics;
pub mod triggers;
pub mod users;
pub mod vars;
pub mod whoami;

View File

@@ -0,0 +1,134 @@
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
//! against the live app and print the per-resource changes. Read-only:
//! builds a bundle (manifest + script sources) and POSTs it to the
//! server's plan endpoint, which computes the diff.
use std::path::Path;
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::client::{ChangeDto, Client, PlanDto};
use crate::config;
use crate::manifest::Manifest;
use crate::output::{OutputMode, Table};
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_with_env(manifest_path, env)?;
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let plan = client.plan(&manifest.app.slug, &bundle).await?;
// Record the bound-plan token so a subsequent `pic apply` can detect the
// app changing underneath the reviewed plan (best-effort — a read-only
// plan still succeeds if the project dir isn't writable).
if !plan.state_token.is_empty() {
if let Err(e) =
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
{
eprintln!("warning: could not record plan state for `pic apply`: {e}");
}
}
render(&plan, mode);
Ok(())
}
/// 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.
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
let mut scripts = Vec::with_capacity(manifest.scripts.len());
for s in &manifest.scripts {
let source = std::fs::read_to_string(base_dir.join(&s.file))
.with_context(|| format!("reading script source {}", s.file))?;
let mut obj = Map::new();
obj.insert("name".into(), json!(s.name));
obj.insert("source".into(), json!(source));
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
if let Some(d) = &s.description {
obj.insert("description".into(), json!(d));
}
if let Some(t) = s.timeout_seconds {
obj.insert("timeout_seconds".into(), json!(t));
}
if let Some(m) = s.memory_limit_mb {
obj.insert("memory_limit_mb".into(), json!(m));
}
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));
}
let routes = manifest
.routes
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
let t = &manifest.triggers;
let mut triggers = Vec::new();
for s in &t.kv {
triggers.push(tagged("kv", s)?);
}
for s in &t.docs {
triggers.push(tagged("docs", s)?);
}
for s in &t.files {
triggers.push(tagged("files", s)?);
}
for s in &t.cron {
triggers.push(tagged("cron", s)?);
}
for s in &t.pubsub {
triggers.push(tagged("pubsub", s)?);
}
for s in &t.email {
triggers.push(tagged("email", s)?);
}
for s in &t.queue {
triggers.push(tagged("queue", s)?);
}
Ok(json!({
"scripts": scripts,
"routes": routes,
"triggers": triggers,
"secrets": manifest.secrets.names,
}))
}
/// Serialize a trigger spec and stamp its `kind` discriminator.
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
let mut v = serde_json::to_value(spec)?;
if let Value::Object(map) = &mut v {
map.insert("kind".into(), Value::String(kind.to_string()));
}
Ok(v)
}
fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]);
let groups: [(&str, &Vec<ChangeDto>); 4] = [
("script", &plan.scripts),
("route", &plan.routes),
("trigger", &plan.triggers),
("secret", &plan.secrets),
];
for (kind, changes) in groups {
for c in changes {
table.row([
kind.to_string(),
c.op.clone(),
c.key.clone(),
c.detail.clone().unwrap_or_default(),
]);
}
}
table.print(mode);
}

View File

@@ -0,0 +1,348 @@
//! `pic pull <app> [--dir .]` — export an app's current server state into
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
//! declarative management with `pic plan` / `pic apply`.
//!
//! Read-only: issues `GET`s only and writes local files. Every trigger
//! kind is exported except `email` — the server stores the *sealed secret
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
//! can't be reconstructed (email triggers must be set up by hand).
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
use serde::Deserialize;
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,
};
use crate::output::{KvBlock, OutputMode};
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(VarOwnerArg::App(app_ident), None)
.await?
.secrets;
let name_by_id: HashMap<ScriptId, String> =
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
// Routes: the admin surface lists them per script.
let mut routes = Vec::new();
for s in &scripts {
for r in client.routes_list_for_script(&s.id.to_string()).await? {
routes.push(ManifestRoute {
script: s.name.clone(),
method: r.method,
host_kind: r.host_kind,
host: r.host,
host_param_name: r.host_param_name,
path_kind: r.path_kind,
path: r.path,
dispatch_mode: r.dispatch_mode,
enabled: r.enabled,
});
}
}
// The server does not constrain script names to a filesystem-safe
// charset, so a name containing a path separator or `..` would let `pull`
// write outside the project dir. Validate ALL names up front, before any
// file is written, so a single bad name can't leave a half-written dir.
// Reject rather than sanitize: a silent rename would desync the manifest
// `name` from its `file`.
for s in &scripts {
if !is_safe_filename(&s.name) {
anyhow::bail!(
"script name {:?} is not filesystem-safe (a path separator, \
`..`, a leading dot, a control character, or longer than 200 \
bytes); cannot pull",
s.name
);
}
}
// Scripts: write each source next to the manifest and record a path ref.
let scripts_dir = dir.join("scripts");
std::fs::create_dir_all(&scripts_dir)
.with_context(|| format!("creating {}", scripts_dir.display()))?;
let mut manifest_scripts = Vec::with_capacity(scripts.len());
for s in &scripts {
let rel = format!("scripts/{}.rhai", s.name);
std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
manifest_scripts.push(ManifestScript {
name: s.name.clone(),
file: rel,
kind: s.kind,
description: s.description.clone(),
timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
sandbox: if s.sandbox.is_empty() {
None
} else {
Some(s.sandbox)
},
enabled: s.enabled,
});
}
// Triggers: map the five settled kinds; warn + skip the rest.
let mut manifest_triggers = ManifestTriggers::default();
let mut skipped: Vec<String> = Vec::new();
for t in &triggers {
let script = name_by_id
.get(&t.script_id)
.cloned()
.unwrap_or_else(|| t.script_id.to_string());
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
let retry_max_attempts = Some(t.retry_max_attempts);
match t.kind.as_str() {
"kv" => {
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
manifest_triggers.kv.push(KvTriggerSpec {
script,
collection_glob: d.collection_glob,
ops: d.ops,
dispatch_mode,
retry_max_attempts,
});
}
"docs" => {
let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
manifest_triggers.docs.push(DocsTriggerSpec {
script,
collection_glob: d.collection_glob,
ops: d.ops,
dispatch_mode,
retry_max_attempts,
});
}
"files" => {
let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
manifest_triggers.files.push(FilesTriggerSpec {
script,
collection_glob: d.collection_glob,
ops: d.ops,
dispatch_mode,
retry_max_attempts,
});
}
"cron" => {
let d: CronDetails = decode_details(&t.details, &t.kind)?;
manifest_triggers.cron.push(CronTriggerSpec {
script,
schedule: d.schedule,
timezone: d.timezone,
dispatch_mode,
retry_max_attempts,
});
}
"pubsub" => {
let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
manifest_triggers.pubsub.push(PubsubTriggerSpec {
script,
topic_pattern: d.topic_pattern,
dispatch_mode,
retry_max_attempts,
});
}
"queue" => {
let d: QueueDetails = decode_details(&t.details, &t.kind)?;
manifest_triggers.queue.push(QueueTriggerSpec {
script,
queue_name: d.queue_name,
visibility_timeout_secs: Some(d.visibility_timeout_secs),
dispatch_mode,
retry_max_attempts,
});
}
// `email` is skipped: the server stores the sealed secret value,
// not the secret *name*, so the manifest's `inbound_secret_ref`
// can't be reconstructed — set it up by hand.
other => skipped.push(format!("{other} ({})", t.id)),
}
}
for s in &skipped {
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
}
let manifest = Manifest {
app: ManifestApp {
slug: app.app.slug.clone(),
name: app.app.name.clone(),
description: app.app.description.clone(),
},
scripts: manifest_scripts,
routes,
triggers: manifest_triggers,
secrets: ManifestSecrets {
names: secrets.iter().map(|s| s.name.clone()).collect(),
},
};
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("scripts", manifest.scripts.len().to_string())
.field("routes", manifest.routes.len().to_string())
.field("triggers", trigger_count(&manifest.triggers).to_string())
.field("secrets", manifest.secrets.names.len().to_string());
block.print(mode);
Ok(())
}
fn trigger_count(t: &ManifestTriggers) -> usize {
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
}
/// True if `name` is safe to use as a single path component in `scripts/`.
/// 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.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.
/// The server tags details with a `kind` field which these structs ignore.
fn decode_details<T: for<'de> Deserialize<'de>>(
details: &serde_json::Value,
kind: &str,
) -> Result<T> {
serde_json::from_value(details.clone())
.with_context(|| format!("decoding {kind} trigger details"))
}
#[derive(Deserialize)]
struct CollectionDetails<Op> {
collection_glob: String,
#[serde(default = "Vec::new")]
ops: Vec<Op>,
}
#[derive(Deserialize)]
struct CronDetails {
schedule: String,
#[serde(default = "default_timezone")]
timezone: String,
}
#[derive(Deserialize)]
struct PubsubDetails {
topic_pattern: String,
}
#[derive(Deserialize)]
struct QueueDetails {
queue_name: String,
visibility_timeout_secs: u32,
}
fn default_timezone() -> String {
"UTC".to_string()
}
#[cfg(test)]
mod tests {
use super::is_safe_filename;
#[test]
fn rejects_traversal_and_separators() {
for bad in [
"",
".",
"..",
"../etc/passwd",
"a/b",
"a\\b",
".hidden",
"with\0nul",
] {
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
}
}
#[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"] {
assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
}
}
}

View File

@@ -0,0 +1,62 @@
//! `pic queues ls | show` — read-only queue inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/queues*` surface (no
//! purge/requeue — that is v1.2). Gated on `AppLogRead` server-side.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let queues = client.queues_list(app).await?;
let mut table = Table::new(["queue", "total", "pending", "claimed"]);
for q in queues {
table.row([
q.queue_name,
q.total.to_string(),
q.pending.to_string(),
q.claimed.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn show(app: &str, queue_name: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let q = client.queue_get(app, queue_name).await?;
let mut block = KvBlock::new();
block
.field("queue", q.queue_name)
.field("total", q.total.to_string())
.field("pending", q.pending.to_string())
.field("claimed", q.claimed.to_string());
match q.consumer {
Some(c) => {
block
.field("consumer_script", c.script_name)
.field("consumer_script_id", c.script_id.to_string())
.field("consumer_trigger_id", c.trigger_id)
.field(
"visibility_timeout_secs",
c.visibility_timeout_secs.to_string(),
)
.field(
"last_fired_at",
c.last_fired_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".to_string()),
);
}
None => {
block.field("consumer", "(none registered)");
}
}
block.print(mode);
Ok(())
}

View File

@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;
use crate::client::{Client, CreateScriptBody};
use crate::client::{Client, CreateScriptBody, ScriptConfig};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
@@ -62,6 +62,7 @@ pub async fn deploy(
app_ident: &str,
name_override: Option<&str>,
description: Option<&str>,
cfg: &ScriptConfig,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
@@ -90,7 +91,7 @@ pub async fn deploy(
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)
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
@@ -99,6 +100,10 @@ pub async fn deploy(
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")
};

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

@@ -124,6 +124,114 @@ pub async fn create_dead_letter(
Ok(())
}
/// docs/files share KV's `{collection_glob, ops?}` shape.
async fn create_collection_trigger(
kind: &str,
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["collection_glob"] = json!(collection_glob);
if !ops.is_empty() {
body["ops"] = json!(ops);
}
let created = client.triggers_create(app, kind, &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_docs(
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
create_collection_trigger("docs", app, script_id, collection_glob, ops, dispatch, mode).await
}
pub async fn create_files(
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
create_collection_trigger(
"files",
app,
script_id,
collection_glob,
ops,
dispatch,
mode,
)
.await
}
pub async fn create_pubsub(
app: &str,
script_id: &str,
topic_pattern: &str,
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["topic_pattern"] = json!(topic_pattern);
let created = client.triggers_create(app, "pubsub", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_queue(
app: &str,
script_id: &str,
queue_name: &str,
visibility_timeout_secs: Option<u32>,
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["queue_name"] = json!(queue_name);
if let Some(v) = visibility_timeout_secs {
body["visibility_timeout_secs"] = json!(v);
}
let created = client.triggers_create(app, "queue", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_email(
app: &str,
script_id: &str,
inbound_secret: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
// Email triggers take no dispatch_mode (inbound webhook only) and an
// optional shared HMAC secret the provider signs POSTs with.
let mut body = json!({ "script_id": script_id });
if let Some(secret) = inbound_secret {
body["inbound_secret"] = json!(secret);
}
let created = client.triggers_create(app, "email", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;

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,67 @@
//! `.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";
/// 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));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,523 @@
//! Declarative project manifest (`picloud.toml`).
//!
//! One manifest describes the desired state of a **single app** — its
//! scripts, routes, triggers, and the *names* of the secrets it expects
//! (values are pushed out-of-band via `pic secret set`, never committed).
//!
//! This is the foundation of the declarative project tool (`pic pull` /
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
//! identical to the admin API — the CLI never depends on `manager-core`.
//!
//! All eight trigger kinds are representable except `dead_letter` (not
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
//! (a secret name) resolved server-side at apply.
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use picloud_shared::{
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
ScriptSandbox,
};
use serde::{Deserialize, Serialize};
/// Conventional manifest filename at a project root.
pub const MANIFEST_FILE: &str = "picloud.toml";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub app: ManifestApp,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ManifestScript>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routes: Vec<ManifestRoute>,
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
pub triggers: ManifestTriggers,
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
pub secrets: ManifestSecrets,
}
impl Manifest {
/// Parse a manifest from TOML text.
pub fn parse(text: &str) -> Result<Self> {
toml::from_str(text).context("parsing manifest TOML")
}
/// Load and parse the manifest at `path`.
pub fn load(path: &Path) -> Result<Self> {
let body =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Self::parse(&body)
}
/// Render to TOML text. Tables are emitted after scalars (the struct
/// field order already satisfies TOML's "values before tables" rule).
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; scripts/routes/triggers stay in the shared base. (Rich
/// per-key `vars` resolution is Phase 3.)
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(slug) = overlay.app.slug {
self.app.slug = slug;
}
if let Some(name) = overlay.app.name {
self.app.name = name;
}
for n in overlay.secrets.names {
if !self.secrets.names.contains(&n) {
self.secrets.names.push(n);
}
}
}
}
/// `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]` and `[secrets]`.
/// Scripts/routes/triggers belong in the shared base manifest, so a
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
/// overlay is a mistake — error loudly rather than silently dropping it.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestOverlay {
#[serde(default)]
pub app: OverlayApp,
#[serde(default)]
pub secrets: ManifestSecrets,
}
#[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)]
pub struct ManifestApp {
pub slug: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestScript {
pub name: String,
/// Path to the `.rhai` source, relative to the manifest's directory.
pub file: String,
#[serde(default, skip_serializing_if = "is_endpoint")]
pub kind: ScriptKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_limit_mb: Option<i32>,
/// 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)]
pub struct ManifestRoute {
/// Name of the script this route binds to.
pub script: String,
/// HTTP method; omit for ANY.
#[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,
/// 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,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub kv: Vec<KvTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub docs: Vec<DocsTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FilesTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cron: Vec<CronTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pubsub: Vec<PubsubTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub email: Vec<EmailTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queue: Vec<QueueTriggerSpec>,
}
impl ManifestTriggers {
#[must_use]
pub fn is_empty(&self) -> bool {
self.kv.is_empty()
&& self.docs.is_empty()
&& self.files.is_empty()
&& self.cron.is_empty()
&& self.pubsub.is_empty()
&& self.email.is_empty()
&& self.queue.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KvTriggerSpec {
pub script: String,
pub collection_glob: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<KvEventOp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DocsTriggerSpec {
pub script: String,
pub collection_glob: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<DocsEventOp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FilesTriggerSpec {
pub script: String,
pub collection_glob: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ops: Vec<FilesEventOp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CronTriggerSpec {
pub script: String,
/// 6-field cron expression (with seconds).
pub schedule: String,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PubsubTriggerSpec {
pub script: String,
pub topic_pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EmailTriggerSpec {
pub script: String,
/// Name of the secret (set via `pic secret set`) holding the inbound
/// HMAC value — resolved + sealed server-side at apply. Never the value.
pub inbound_secret_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueueTriggerSpec {
pub script: String,
pub queue_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility_timeout_secs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
}
/// `[secrets] names = [...]` — declares which secrets the app expects.
/// Values are never in the manifest; `pic secret set` pushes them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestSecrets {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<String>,
}
impl ManifestSecrets {
#[must_use]
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
// ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool {
*kind == ScriptKind::Endpoint
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Manifest {
Manifest {
app: ManifestApp {
slug: "blog".into(),
name: "My Blog".into(),
description: Some("demo".into()),
},
scripts: vec![
ManifestScript {
name: "create-post".into(),
file: "scripts/create-post.rhai".into(),
kind: ScriptKind::Endpoint,
description: None,
timeout_seconds: Some(10),
memory_limit_mb: Some(256),
sandbox: None,
enabled: true,
},
ManifestScript {
name: "lib".into(),
file: "scripts/lib.rhai".into(),
kind: ScriptKind::Module,
description: None,
timeout_seconds: None,
memory_limit_mb: None,
sandbox: Some(ScriptSandbox {
max_operations: Some(5_000_000),
..ScriptSandbox::empty()
}),
enabled: false,
},
],
routes: vec![ManifestRoute {
script: "create-post".into(),
method: Some("POST".into()),
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/posts".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: ManifestTriggers {
cron: vec![CronTriggerSpec {
script: "create-post".into(),
schedule: "0 6 * * * *".into(),
timezone: "UTC".into(),
dispatch_mode: None,
retry_max_attempts: None,
}],
kv: vec![KvTriggerSpec {
script: "create-post".into(),
collection_glob: "users".into(),
ops: vec![KvEventOp::Insert, KvEventOp::Update],
dispatch_mode: Some(DispatchMode::Async),
retry_max_attempts: Some(5),
}],
..ManifestTriggers::default()
},
secrets: ManifestSecrets {
names: vec!["STRIPE_KEY".into()],
},
}
}
#[test]
fn round_trips_through_toml() {
let m = sample();
let text = m.to_toml().expect("serialize");
let back = Manifest::parse(&text).expect("parse");
assert_eq!(m, back, "manifest must survive a TOML round-trip");
}
#[test]
fn omits_defaulted_fields() {
let text = sample().to_toml().unwrap();
// Endpoint kind + sync dispatch are defaults → not emitted.
assert!(
!text.contains("kind = \"endpoint\""),
"default kind should be omitted:\n{text}"
);
assert!(
!text.contains("dispatch_mode = \"sync\""),
"default route dispatch should be omitted:\n{text}"
);
// 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);
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
assert_eq!(
m.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 {
slug: "x".into(),
name: "X".into(),
description: None,
},
scripts: vec![],
routes: vec![],
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
};
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}");
// Still round-trips.
assert_eq!(m, Manifest::parse(&text).unwrap());
}
}

View File

@@ -0,0 +1,153 @@
//! `pic apply` journey: apply a manifest to an empty app (atomic create),
//! re-apply is an idempotent no-op, and a bundle containing any invalid
//! resource applies nothing (all-or-nothing).
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::AppGuard;
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 apply_creates_then_noop() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apply");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/greet.rhai"),
"let body = #{ ok: true }; body",
)
.unwrap();
let manifest = format!(
"[app]\nslug = \"{slug}\"\nname = \"Apply Test\"\n\n\
[[scripts]]\nname = \"greet\"\nfile = \"scripts/greet.rhai\"\n\n\
[[routes]]\nscript = \"greet\"\nmethod = \"POST\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n\n\
[[triggers.cron]]\nscript = \"greet\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
// First apply: creates script + route + trigger.
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 stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("+1"),
"expected creations in report:\n{stdout}"
);
// The resources now exist.
let s = String::from_utf8(
common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(s.contains("greet"), "script not created:\n{s}");
// Plan is now clean (apply reached desired state).
let p = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest_path)
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!p.contains("create") && !p.contains("update"),
"expected clean plan after apply:\n{p}"
);
// Re-apply: idempotent — nothing created/updated.
let r = String::from_utf8(
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apply_rejects_bad_bundle_atomically() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apply-atomic");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = manifest_dir();
fs::write(dir.path().join("scripts/good.rhai"), "let x = 1; x").unwrap();
// Invalid Rhai — fails validation, so the whole apply must abort.
fs::write(dir.path().join("scripts/bad.rhai"), "let x = ;").unwrap();
let manifest = format!(
"[app]\nslug = \"{slug}\"\nname = \"Atomic Test\"\n\n\
[[scripts]]\nname = \"good\"\nfile = \"scripts/good.rhai\"\n\n\
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.output()
.expect("apply");
assert!(
!out.status.success(),
"apply with an invalid script should fail"
);
// Atomic: the valid script must NOT have been created.
let s = String::from_utf8(
common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!s.contains("good"),
"a failed apply must leave nothing behind:\n{s}"
);
}

View File

@@ -15,14 +15,27 @@ mod common;
mod admins;
mod api_keys;
mod apply;
mod apps;
mod auth;
mod config;
mod dead_letters;
mod email_queue;
mod enabled;
mod env_overlay;
mod group_secrets;
mod groups;
mod init;
mod invoke;
mod logs;
mod output;
mod plan;
mod prune;
mod pull;
mod roles;
mod routes;
mod scripts;
mod secrets;
mod staleness;
mod triggers;
mod vars;

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,222 @@
//! M5: `pic apply` creates email + queue triggers. The email trigger's
//! inbound secret is referenced by name (pushed via `pic secret set`) and
//! resolved + re-sealed server-side — never written into the manifest.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apply_email_and_queue_triggers() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("m5");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
// The email trigger references this secret by name; push its value
// out-of-band first.
common::pic_as(&env)
.args(["secrets", "set", "--app", &slug, "email-hmac"])
.write_stdin("super-secret-hmac")
.assert()
.success();
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
let manifest = format!(
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
[secrets]\nnames = [\"email-hmac\"]\n\n\
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
[[triggers.queue]]\nscript = \"handler\"\nqueue_name = \"jobs\"\n\n\
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).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)
);
// Both triggers exist.
let s = String::from_utf8(
common::pic_as(&env)
.args(["triggers", "ls", "--app", &slug])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
s.lines().any(|l| l.contains("queue")),
"queue trigger missing:\n{s}"
);
assert!(
s.lines().any(|l| l.contains("email")),
"email trigger missing:\n{s}"
);
// Re-apply is a no-op (both triggers match by identity).
let r = String::from_utf8(
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_refuses_to_orphan_email_trigger() {
// `pull` can't represent email triggers, so a manifest that omits the
// script owning one would, under `--prune`, cascade-delete the trigger
// (and its sealed secret) when the script is dropped. Apply must refuse.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("m5-orphan");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
common::pic_as(&env)
.args(["secrets", "set", "--app", &slug, "email-hmac"])
.write_stdin("super-secret-hmac")
.assert()
.success();
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
let manifest_path = dir.path().join("picloud.toml");
// v1: a script with an email trigger.
let v1 = format!(
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
[secrets]\nnames = [\"email-hmac\"]\n\n\
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
);
fs::write(&manifest_path, &v1).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
// v2: drop the script (and, implicitly, its un-representable email
// trigger). A prune apply must REFUSE rather than cascade-destroy it.
let v2 = format!("[app]\nslug = \"{slug}\"\nname = \"M5\"\n");
fs::write(&manifest_path, &v2).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.args(["--prune", "--yes"])
.output()
.expect("apply --prune");
assert!(
!out.status.success(),
"prune must refuse to orphan an email trigger"
);
// The script and its email trigger both survive the refused apply.
let scripts = String::from_utf8(
common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
scripts.contains("handler"),
"script must survive:\n{scripts}"
);
let triggers = String::from_utf8(
common::pic_as(&env)
.args(["triggers", "ls", "--app", &slug])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
triggers.lines().any(|l| l.contains("email")),
"email trigger must survive:\n{triggers}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apply_email_unset_secret_fails() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("m5-nosecret");
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/handler.rhai"), "let x = 1; x").unwrap();
let manifest = format!(
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"never-set\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
// The referenced secret was never set → apply must fail atomically.
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.output()
.expect("apply");
assert!(
!out.status.success(),
"apply must fail when an email secret is unset"
);
// Atomic: neither the script nor the email trigger was created.
let s = String::from_utf8(
common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!s.contains("handler"),
"failed apply must leave nothing behind:\n{s}"
);
}

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,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,187 @@
//! Phase-3 group secrets, end to end via `pic`:
//!
//! 1. **Inheritance + proximity** — a group-owned secret is injected into a
//! descendant app's script via `secrets::get` (decrypted under the
//! group AAD), and an app-owned secret of the same name shadows it
//! (decrypted under the app AAD). Exercises the resolver + the dual
//! owner-AAD open path against real Postgres.
//! 2. **Masked-read boundary** — a `group_admin` reads the secret VALUE,
//! an app-only dev is denied the value (403) yet still sees the secret
//! EXISTS (masked) in `config/effective`. That is the headline §5.3
//! property: an app runs with config its own devs cannot read.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_secret_is_injected_then_app_value_overrides() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("gs-acme");
let app = common::unique_slug("gs-app");
// Group `acme` with a `stripe-key` group secret (value via stdin).
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["secrets", "set", "--group", &acme, "stripe-key"])
.write_stdin("sk_group")
.assert()
.success();
// App under acme with a script that reads + returns the resolved secret.
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
let fixture = common::fixture_path("read-secret.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.success();
let ls = common::pic_as(&env)
.args(["scripts", "ls", "--app", &app])
.output()
.expect("scripts ls");
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("scripts ls should produce one row");
// Inherited: the app has no own `stripe-key`, so the group's value is
// injected (decrypted under the GROUP AAD).
assert_eq!(
invoke_body(&env, &id),
serde_json::json!("sk_group"),
"inherited group secret"
);
// Proximity override: an app-owned `stripe-key` shadows the group value
// (decrypted under the APP AAD — proving both AAD namespaces open).
common::pic_as(&env)
.args(["secrets", "set", "--app", &app, "stripe-key"])
.write_stdin("sk_app")
.assert()
.success();
assert_eq!(
invoke_body(&env, &id),
serde_json::json!("sk_app"),
"app override"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_secret_value_is_masked_from_app_devs() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("gsm-acme");
let app = common::unique_slug("gsm-app");
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["secrets", "set", "--group", &acme, "stripe-key"])
.write_stdin("sk_live_masked")
.assert()
.success();
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
// An app dev: a Member granted `editor` on the app, but NO group role.
let dev = member::member_user(fx, &common::unique_username("appdev"));
let dev_env = common::custom_env(&fx.url, &dev.token);
common::seed_credentials(&dev_env, &dev.username);
member::grant_membership(fx, &app, &dev.id, "editor");
// Denied the VALUE: the value endpoint is gated GroupSecretsRead at the
// owning group, which the app dev does not hold.
common::pic_as(&dev_env)
.args(["secrets", "read", "--group", &acme, "stripe-key"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 403"));
// But the dev DOES see it EXISTS (masked) in the app's effective config —
// status `set`, owner group, never the value. Asserted directly against
// the endpoint to avoid the CLI's manifest requirement.
let effective = reqwest::blocking::Client::new()
.get(format!(
"{}/api/v1/admin/apps/{}/config/effective",
fx.url, app
))
.bearer_auth(&dev.token)
.send()
.expect("config effective");
assert!(
effective.status().is_success(),
"dev can read effective config"
);
let body: serde_json::Value = effective.json().expect("effective json");
let masked = &body["secrets"]["stripe-key"];
assert_eq!(masked["status"], "set", "secret shown as set");
assert_eq!(masked["owner"]["kind"], "group", "owned by the group");
assert!(
masked.get("value").is_none(),
"value must never appear in effective config: {masked}"
);
// A group_admin CAN read the value.
let gadmin = member::member_user(fx, &common::unique_username("gadmin"));
let gadmin_env = common::custom_env(&fx.url, &gadmin.token);
common::seed_credentials(&gadmin_env, &gadmin.username);
common::pic_as(&env)
.args([
"groups",
"members",
"add",
&acme,
&gadmin.id,
"--role",
"app_admin",
])
.assert()
.success();
common::pic_as(&gadmin_env)
.args(["secrets", "read", "--group", &acme, "stripe-key"])
.assert()
.success()
.stdout(predicate::str::contains("sk_live_masked"));
}
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
/// and parse its JSON body.
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

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

View File

@@ -0,0 +1,99 @@
//! `pic init` journey — offline scaffolding, no server/DB needed (so these
//! tests are NOT gated on `DATABASE_URL` and never touch `common::fixture`).
use std::fs;
use assert_cmd::Command;
use tempfile::TempDir;
fn pic() -> Command {
Command::cargo_bin("pic").expect("pic binary")
}
#[test]
fn init_scaffolds_a_deployable_project() {
let dir = TempDir::new().unwrap();
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.success();
let toml = fs::read_to_string(dir.path().join("picloud.toml")).unwrap();
assert!(toml.contains("slug = \"demo-app\""), "got:\n{toml}");
assert!(
toml.contains("name = \"Demo App\""),
"name defaults to title-cased slug:\n{toml}"
);
assert!(
dir.path().join("scripts/hello.rhai").exists(),
"scaffold writes the example script"
);
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gitignore.lines().any(|l| l.trim() == ".picloud/"),
"init must gitignore .picloud/:\n{gitignore}"
);
}
#[test]
fn init_derives_slug_from_a_not_yet_created_dir() {
// The natural `pic init --dir new-project` flow: the target doesn't exist
// yet, and the slug is derived from its name (not via canonicalize, which
// would fail on a missing path).
let parent = TempDir::new().unwrap();
let target = parent.path().join("new-project");
pic()
.args(["init", "--dir"])
.arg(&target)
.assert()
.success();
let toml = fs::read_to_string(target.join("picloud.toml")).unwrap();
assert!(
toml.contains("slug = \"new-project\""),
"slug should derive from the dir name:\n{toml}"
);
}
#[test]
fn init_refuses_to_overwrite_without_force() {
let dir = TempDir::new().unwrap();
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.success();
// A second run must refuse rather than clobber edits.
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.failure();
// `--force` overrides.
pic()
.current_dir(dir.path())
.args(["init", "demo-app", "--force"])
.assert()
.success();
}
#[test]
fn init_appends_to_an_existing_gitignore_once() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
pic()
.current_dir(dir.path())
.args(["init", "demo-app"])
.assert()
.success();
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("target/"),
"must preserve existing rules"
);
assert_eq!(
gitignore.matches(".picloud/").count(),
1,
"must add the ignore exactly once:\n{gitignore}"
);
}

View File

@@ -7,7 +7,7 @@ use predicates::prelude::*;
use crate::common;
/// Pick out the data rows from `pic logs` TSV output — the header line
/// (`created_at\tstatus\tsummary`) is now always present, so the old
/// (`created_at\tsource\tstatus\tsummary`) is now always present, so the old
/// "no non-empty lines means no logs" check needs to skip it.
fn data_rows(stdout: &str) -> Vec<&str> {
stdout
@@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() {
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
assert_eq!(
cols.len(),
3,
"row should be 3 tab-delimited cells: {rows:?}"
4,
"row should be 4 tab-delimited cells (created_at, source, status, summary): {rows:?}"
);
assert_eq!(cols[1], "success");
assert_eq!(cols[2], "success");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() {
.next()
.expect("at least one data row");
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
assert_eq!(cols[2], "error", "expected error status, got row: {row}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -166,7 +166,7 @@ fn logs_truncates_long_summary() {
.into_iter()
.next()
.expect("at least one data row");
let summary = row.split('\t').nth(2).expect("summary column");
let summary = row.split('\t').nth(3).expect("summary column");
assert!(
summary.ends_with('…'),
"summary should be truncated with `…`, got: {summary}"

View File

@@ -0,0 +1,81 @@
//! `pic plan` journey: a freshly-pulled manifest must diff to all-no-op
//! (pull→plan is idempotent), and editing a script source must surface
//! as an update.
use tempfile::TempDir;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn plan_roundtrips_then_detects_change() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (script_id, guard) = common::deploy_fixture(&env, "plan", "hello.rhai");
let app = guard.slug().to_string();
common::pic_as(&env)
.args([
"routes", "create", "--script", &script_id, "--path", "/p", "--method", "GET",
])
.assert()
.success();
// Pull the live state, then plan it back — must be a clean no-op.
let dir = TempDir::new().expect("tempdir");
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(dir.path())
.assert()
.success();
let manifest = dir.path().join("picloud.toml");
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest)
.output()
.expect("plan");
assert!(
out.status.success(),
"plan failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
let hello = stdout
.lines()
.find(|l| l.contains("hello"))
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
assert!(
hello.contains("noop"),
"expected hello no-op, got:\n{stdout}"
);
assert!(
!stdout.contains("create") && !stdout.contains("delete"),
"fresh pull should diff clean, got:\n{stdout}"
);
// Edit the script source on disk → plan must report an update.
std::fs::write(
dir.path().join("scripts/hello.rhai"),
"let body = #{ ok: false }; body",
)
.expect("rewrite source");
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest)
.output()
.expect("plan after edit");
let stdout = String::from_utf8(out.stdout).unwrap();
let hello = stdout
.lines()
.find(|l| l.contains("hello"))
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
assert!(
hello.contains("update"),
"expected hello update after source edit, got:\n{stdout}"
);
drop(guard);
}

View File

@@ -0,0 +1,177 @@
//! `pic apply --prune` journey: a resource dropped from the manifest
//! survives a plain (additive) apply but is deleted with `--prune`.
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 prune_deletes_stale_resources() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("prune");
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/keep.rhai"), "let x = 1; x").unwrap();
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
let manifest_path = dir.path().join("picloud.toml");
// v1: two scripts + a route on `drop`.
let v1 = format!(
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n\n\
[[routes]]\nscript = \"drop\"\nmethod = \"GET\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/drop\"\n"
);
fs::write(&manifest_path, &v1).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
// v2: drop `drop` and its route.
let v2 = format!(
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
);
fs::write(&manifest_path, &v2).unwrap();
// Plain apply is additive — `drop` survives.
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
assert!(
scripts_ls(&env, &slug).contains("drop"),
"additive apply must not delete"
);
// Prune apply removes `drop` and its route. `--yes` skips the
// confirmation prompt (this test runs non-interactively).
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.args(["--prune", "--yes"])
.output()
.expect("apply --prune");
assert!(
out.status.success(),
"prune failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let report = String::from_utf8(out.stdout).unwrap();
assert!(
report.contains("-1"),
"expected deletions in report:\n{report}"
);
let s = scripts_ls(&env, &slug);
assert!(!s.contains("drop"), "prune should delete `drop`:\n{s}");
assert!(s.contains("keep"), "prune must keep `keep`:\n{s}");
// Plan is clean after prune.
let p = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--file"])
.arg(&manifest_path)
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!p.contains("delete"),
"plan should be clean after prune:\n{p}"
);
}
/// The prune confirmation gate: `--prune` without `--yes`, run
/// non-interactively (no TTY, as in CI), must refuse and delete nothing.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_without_yes_refuses_noninteractively() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("prune-gate");
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/keep.rhai"), "let x = 1; x").unwrap();
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
let manifest_path = dir.path().join("picloud.toml");
// Deploy two scripts, then drop one from the manifest.
let v1 = format!(
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n"
);
fs::write(&manifest_path, &v1).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
let v2 = format!(
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
);
fs::write(&manifest_path, &v2).unwrap();
// `--prune` with no `--yes` and no TTY → refuse, non-zero exit.
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.arg("--prune")
.output()
.expect("apply --prune");
assert!(
!out.status.success(),
"prune without --yes must refuse non-interactively"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("--yes"),
"refusal should mention --yes:\n{err}"
);
// The dropped script must still be there — the gate blocked the delete.
let s = scripts_ls(&env, &slug);
assert!(
s.contains("drop"),
"refused prune must not delete anything:\n{s}"
);
}

View File

@@ -0,0 +1,91 @@
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
//! and a secret, then export it and assert the manifest + script file.
use tempfile::TempDir;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn pull_exports_manifest_and_sources() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// App + script "hello" (deploy derives the name from the file stem).
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
let app = guard.slug().to_string();
// Route → script.
common::pic_as(&env)
.args([
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
])
.assert()
.success();
// Cron trigger → script.
common::pic_as(&env)
.args([
"triggers",
"create-cron",
"--app",
&app,
"--script",
&script_id,
"--schedule",
"0 0 * * * *",
])
.assert()
.success();
// Secret (name only ends up in the manifest; value stays server-side).
common::pic_as(&env)
.args(["secrets", "set", "--app", &app, "api_key"])
.write_stdin("xyzzy")
.assert()
.success();
// Pull into a scratch dir.
let out_dir = TempDir::new().expect("pull tempdir");
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(out_dir.path())
.assert()
.success();
// Manifest exists and captures every resource.
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
.expect("picloud.toml should be written");
assert!(
manifest.contains(&format!("slug = \"{app}\"")),
"manifest missing app slug:\n{manifest}"
);
assert!(
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
"manifest missing script entry:\n{manifest}"
);
assert!(
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
"manifest missing route:\n{manifest}"
);
assert!(
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
"manifest missing cron trigger:\n{manifest}"
);
assert!(
manifest.contains("api_key"),
"manifest missing secret name:\n{manifest}"
);
// Script source was written out faithfully.
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
.expect("scripts/hello.rhai should be written");
assert!(
src.contains("hello from pic"),
"exported source mismatch:\n{src}"
);
drop(guard);
}

View File

@@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() {
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]

View File

@@ -7,6 +7,16 @@ use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::AppGuard;
/// Extract a field value from a `pic` KvBlock (`key<pad>\tvalue` per line).
/// `pic scripts deploy` prints `name`/`version`/`action` rows, not a prose
/// "Created X vN" line, so version-bump tests assert on these fields.
fn kv_field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> {
stdout.lines().find_map(|l| {
let (k, v) = l.split_once('\t')?;
(k.trim() == key).then(|| v.trim())
})
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_against_unknown_app_errors() {
@@ -48,7 +58,7 @@ fn deploy_with_name_override() {
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
let out = common::pic_as(&env)
.args([
"scripts",
"deploy",
@@ -58,11 +68,14 @@ fn deploy_with_name_override() {
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Created custom-name v1"));
.output()
.expect("deploy v1");
let v1 = String::from_utf8(out.stdout).unwrap();
assert_eq!(kv_field(&v1, "name"), Some("custom-name"), "v1 name: {v1}");
assert_eq!(kv_field(&v1, "version"), Some("1"), "v1 version: {v1}");
assert_eq!(kv_field(&v1, "action"), Some("created"), "v1 action: {v1}");
common::pic_as(&env)
let out = common::pic_as(&env)
.args([
"scripts",
"deploy",
@@ -72,9 +85,11 @@ fn deploy_with_name_override() {
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Updated custom-name v2"));
.output()
.expect("deploy v2");
let v2 = String::from_utf8(out.stdout).unwrap();
assert_eq!(kv_field(&v2, "version"), Some("2"), "v2 version: {v2}");
assert_eq!(kv_field(&v2, "action"), Some("updated"), "v2 action: {v2}");
let out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
@@ -106,8 +121,8 @@ fn deploy_bumps_version_each_redeploy() {
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
common::pic_as(&env)
for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] {
let out = common::pic_as(&env)
.args([
"scripts",
"deploy",
@@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() {
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains(expected));
.output()
.expect("deploy");
assert!(out.status.success(), "deploy v{version} failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
kv_field(&stdout, "version"),
Some(version),
"version: {stdout}"
);
assert_eq!(
kv_field(&stdout, "action"),
Some(action),
"action: {stdout}"
);
}
}

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