62 Commits

Author SHA1 Message Date
MechaCat02
5d08974876 style(cli): re-fmt one stray format! line in the integration test
A trailing fmt drift on tests/cli.rs:95 — `format!()` arg was wrapped
across three lines where rustfmt wants one. Running `cargo fmt --all`
collapses it; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:57:50 +02:00
MechaCat02
ca278bddc8 test(cli): bare-metal end-to-end integration test
Spawns the pre-built `picloud` binary against DATABASE_URL on a
private port, logs in over HTTP to mint a bearer token, then drives
`pic` through the full edit-deploy-invoke-tail loop with a unique
app slug per run and a `Drop`-based cleanup. Gated on DATABASE_URL
and tagged `#[ignore]` to match the existing integration-test
pattern in `crates/picloud/tests/api.rs`.

The test uses the dev `admin/admin` credentials (overridable via
PICLOUD_CLI_E2E_USERNAME / _PASSWORD) because the bootstrap env
vars are inert once the DB has any admin row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:53:56 +02:00
MechaCat02
7b50047730 feat(cli): add pic command-line client (login, apps, scripts, logs)
Adds a new workspace crate `picloud-cli` shipping a `pic` binary that
drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin
and execute HTTP surface. Eight subcommands cover the minimum a
developer needs to never open the dashboard:

  pic login                    (paste URL + bearer token, validates via /auth/me)
  pic whoami                   (re-validates and prints principal)
  pic apps ls | create
  pic scripts ls | deploy | invoke
  pic logs <id>

Credentials persist as TOML under the platform config dir (resolved
via `directories`); on POSIX the file is forced to mode 0600.
PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts
for CI and integration tests.

The CLI redeclares minimal request/response structs in `client.rs`
rather than depending on `manager-core` — keeps the blast radius
contained without touching the existing crate boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:53:49 +02:00
MechaCat02
b42e273479 fix(test): admin_is_implicit_app_admin uses force=true on app delete
The test creates a script in the default app earlier in the body, so a
plain DELETE /apps/default hits the soft no-cascade guard and 409s
before the capability check runs. The intent is to validate that admin
holds AppAdmin everywhere, not to exercise the cascade contract — pass
?force=true so we reach the gate we're trying to test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:21:38 +02:00
MechaCat02
f32ed73561 fix(e2e): surface cleanup HTTP failures instead of swallowing them
CleanupRegistry's catch-all was masking every kind of teardown error,
not just the intended "resource already gone" 404. A backend returning
500 on delete would leak orphans run after run without ever surfacing.

Now treat 2xx and 404 as success, log any other status (and any
thrown network error) to stderr with the resource label, and keep
running the remaining items. The suite stays best-effort but no
longer hides accumulating leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:45 +02:00
MechaCat02
64799b73ff chore(docker): caddy restart unless-stopped
Other services in the prod overlay already have it. Without it, a
`docker compose stop caddy` followed by `docker compose up -d` doesn't
bring caddy back up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:51 +02:00
MechaCat02
beb3bcb97c fix(e2e): use uniqueUsername helper in integration.spec
Date.now() can collide across workers running on the same millisecond
boundary. The worker-aware helper that the rest of the suite uses
side-steps that without changing the test's intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:47 +02:00
MechaCat02
79c8db2cb7 fix(e2e): non-mutating reverse in CleanupRegistry
Array.reverse mutates in place — a defensive double-run() would have
re-reversed the items. Iterate over a copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:42 +02:00
MechaCat02
f4cd883d76 test(e2e): drive the new deactivate confirm modal
Cancels once to assert the modal can be dismissed without side
effects, then confirms to flip the user to inactive, then reactivates
to assert that direction remains one-click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:40:52 +02:00
MechaCat02
b459b99fe9 test(e2e): role-shadowing specs in apps + scripts suites
Lifts loginAsUserToken + pageWithUserToken out of members.spec.ts into
fixtures/role-page.ts (third file that needs them). Adds shadowing
coverage: viewer member sees no New-app / Add-domain / Settings / Save
/ +Add-route, editor sees Save but no Delete header, and CodeMirror
renders contenteditable=false for viewers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:40:09 +02:00
MechaCat02
f694a6d504 test(e2e): orphan sweep at globalSetup
Wipes e2e-* apps and e2e* admin users before the suite starts so a
prior crashed run doesn't accumulate state across runs (45 rows
observed on 2026-05-28). Per-row try/catch keeps it best-effort; a
sweep failure never blocks the suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:37:57 +02:00
MechaCat02
70b66451d6 fix(dashboard): rejection-sample password-gen to remove modulo bias
Switches to Uint8 rejection sampling against the largest multiple of
the charset length that fits in a byte. Eliminates the ~16 ppm
overweight the previous `% N` over Uint32 would otherwise leave on the
first 38 chars. Adds a vitest distribution check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:37:18 +02:00
MechaCat02
c4fa53052d feat(dashboard): confirm modal for user deactivate
Deactivation signs the user out and expires every API key they hold —
warrants a styled confirm. Reactivation stays one-click since it's
non-destructive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:36:17 +02:00
MechaCat02
2f6840fe3e feat(dashboard): confirm modal for script delete
Replaces window.confirm + alert() with the in-dashboard ConfirmModal
(danger variant, name-retype). Body summarises what gets removed
(routes + execution logs) and embeds the API error inline rather than
firing a native alert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:35:14 +02:00
MechaCat02
75c815d02a feat(dashboard): shadow script-detail surfaces by role
Captures my_role off the existing parent-app fetch (no extra HTTP call)
and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save +
Format, Routing +Add route + per-row remove, and the Settings tab.
CodeEditor renders read-only for viewers. An effect bounces a stale
Settings tab back to Edit for non-admins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:48 +02:00
MechaCat02
d9c3d4d661 feat(dashboard): shadow apps + app-detail surfaces by role
Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:31:56 +02:00
MechaCat02
bef4d34c43 feat(dashboard): CodeEditor readOnly prop
Threads readOnly through to EditorState.readOnly + EditorView.editable so
script-detail can render a viewer-only editor without intercepting
keystrokes upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:29:55 +02:00
MechaCat02
99a3ed1b6b feat(dashboard): capabilities helper for role-aware UI shadowing
Pure-function module that mirrors crates/manager-core/src/authz.rs and
lets dashboard pages decide which create / edit / delete affordances to
render. Widens the vitest include so the truth-table test runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:28:45 +02:00
MechaCat02
4644ea4919 feat(manager-core): admin is implicit app_admin; delete-script needs AppAdmin
Aligns the canonical capability rules with how the dashboard now shadows
its UI. Instance admins become implicit app_admin on every app (only
InstanceManageSettings stays owner-only), and the script-delete handler
moves from AppWriteScript to AppAdmin so editors can save but not delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:27:32 +02:00
MechaCat02
ec3c768262 test(dashboard): add full-stack integration specs
Two scenarios that span the dashboard UI and the data/control plane
end-to-end:

- App + domain claim + script + route all created via the dashboard,
  then the script is invoked through the public URL with the
  matching Host header. Verifies the dashboard actions actually
  reach the orchestrator's route trie.
- API key minted via the dashboard, then used as a bearer token
  against /api/v1/admin/* (the CLI surface). Confirms the scope is
  enforced (script:read passes /scripts, 403s /admins) and that
  revoking via the dashboard immediately invalidates the token.

Also: the B7 copy-token test selected the mint-form Name input via
getByLabel('Name'), which became ambiguous once the integration
test created an app and the Binding dropdown was no longer empty.
Switched both B7 mint flows to placeholder-based selectors.

Suite: 57/57 passing in ~18s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:56:24 +02:00
MechaCat02
3e72ddde78 test(dashboard): stabilize the e2e suite under parallel runs
Three issues found while running the full B1–B8 suite together:

- The B1 logout test was driving the shared admin storageState
  token, invalidating it for every subsequent test. Switched it to
  a fresh login so its session is disposable.
- Bumped navigationTimeout to 30s and capped local workers at 4 to
  cope with the Vite dev server's first-compile cost under
  parallel load. Local also gets one retry to absorb intermittent
  warmup flakiness.
- Cleared a few lint warnings (unused appId / _adminPage vars) and
  belt-and-braces gitignore for playwright artifacts written to
  the repo root when the CLI is invoked from there by accident.

Suite now: 55/55 passing in ~21s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:44:07 +02:00
MechaCat02
cd20ffb580 test(dashboard): add e2e cross-cutting security spec (B8)
Five tests covering platform-wide guarantees: expired-token
redirect, HttpOnly session cookie, bootstrap password not leaked
into the DOM after login, missing-app slug fails gracefully, and
an XSS-sink probe across the main authed routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:43:51 +02:00
MechaCat02
cddd479fd2 test(dashboard): add e2e profile + API keys spec (B7)
Six tests covering /admin/profile: mint instance-wide key with the
reveal/ack flow, the app-binding mutual-exclusion guard (instance
scopes auto-disabled), revoke via the ConfirmModal, the
?denied=users banner, plus adversarial cases (empty-name button
disabled, copy-token writes the full token to the clipboard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:31:58 +02:00
MechaCat02
8bbcdd86aa test(dashboard): add e2e instance users spec (B6)
Eight tests covering the Users admin page: invite happy path (form
→ reveal modal → ack-gated dismiss → row in table), live username
validation, search filter, deactivate/reactivate, delete with phrase
modal, member-role redirect to /profile?denied=users, plus
adversarial inputs (too-short username, script-tag email).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:28:04 +02:00
MechaCat02
2d56e42699 test(dashboard): add e2e app members spec (B5)
Four tests covering the Members tab: invite + remove (action-menu +
phrase modal), role change, the non-app-admin viewer who never sees
the Members tab at all (cross-context via a second admin login),
and an adversarial that the role dropdown only exposes the
documented set of values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:23:01 +02:00
MechaCat02
f9d9ed8cb4 test(dashboard): add e2e routing spec (B4)
Seven tests covering the Routing tab inside the script editor: add
+ list + remove (handling the window.confirm dialog), match-preview
round trip, path-kind mismatch warning, unclaimed-host warning,
duplicate-route 409, plus reserved-prefix rejection and a path-XSS
adversarial that checks no script tag escapes into the route list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:18:01 +02:00
MechaCat02
c17f8a5bd9 test(dashboard): add e2e script CRUD + editor spec (B3)
Seven tests covering script creation via the Scripts tab, the source
editor (CodeMirror typing + save + reload), Format-button error
surfaces for both Rhai and the test-invoke JSON body, the test-invoke
happy path, settings input validation, and an infinite-loop adversarial
that asserts the sandbox timeout reports cleanly and the editor stays
interactive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:14:09 +02:00
MechaCat02
7198fb4d0e test(dashboard): add e2e apps lifecycle spec (B2)
Seven tests covering app CRUD via the dashboard: create with
slug auto-derive, settings rename, delete with phrase-confirmation
modal, historical-slug takeover via the create form, plus adversarial
inputs (slug normalization, XSS in name/description, oversized name).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:10:47 +02:00
MechaCat02
029a4a199f test(dashboard): add e2e auth & navigation spec (B1)
Eight tests covering the login form, layout-level redirects, logout,
and the obvious adversarial inputs (XSS in username, empty submit,
password field type, leaked tokens). All targeted at /admin/login and
the bounce-back behaviors implemented in +layout.svelte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:06:13 +02:00
MechaCat02
74f7b3b631 test(dashboard): add Playwright e2e scaffolding with smoke spec
Milestone A of the frontend test plan. Sets up the test rig — config,
globalSetup that probes the backend and seeds an admin session into
storageState, lightweight fixtures, and a 3-test smoke spec — without
yet covering any user journeys (those land in Milestone B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:03:44 +02:00
MechaCat02
e6fc6e6a0e test(picloud): close two app_members test gaps
- `membership_makes_app_appear_in_members_app_list` previously seeded
  the membership via the repo helper; switch to the public POST
  endpoint so the test actually exercises the full HTTP round-trip
  the dashboard depends on.
- Add `add_member_with_missing_user_id_is_rejected` to pin the
  Axum-JsonRejection 4xx contract on malformed POST bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:28 +02:00
MechaCat02
66b84abf6d refactor(manager-core): share resolve_app helper across handlers
apps_api.rs and app_members_api.rs each grew a near-identical local
`resolve_app` that parses an id-or-slug param and translates None into
their own AppNotFound variant. Promote the lookup half to
`app_repo::resolve_app` (returns `Result<Option<AppLookup>, ...>`) and
let callers handle the None → not-found mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:21 +02:00
MechaCat02
a9fc838577 fix(dashboard): redirect after a member removes themselves
A member-with-app_admin who removes their own membership keeps a now-
broken Members tab open until reload — `myRole` is only computed once
in `loadApp`, and the next `/apps/{slug}` fetch would 403 anyway.

After the DELETE succeeds, if the removed user is the caller, navigate
back to /apps instead of refreshing the local member list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:13 +02:00
MechaCat02
2948875a96 fix(api): make app_members POST and PATCH atomic
The previous handlers did `find()` then `upsert()` in two round-trips:

- POST: two concurrent grants both pass the duplicate check; the
  second `upsert` silently rewrites the role instead of returning
  409, weakening the "409 on duplicate" contract under load.
- PATCH: a concurrent DELETE between `find` and `upsert` makes PATCH
  silently re-create a row instead of returning 404, weakening the
  "404 if no existing membership" contract.

Adds two repo primitives that fold the check into the write:

- `try_insert` — `INSERT ... ON CONFLICT DO NOTHING RETURNING`; None
  return ⇒ already exists ⇒ 409.
- `update_role` — `UPDATE ... WHERE app_id AND user_id RETURNING`;
  None return ⇒ no row ⇒ 404.

Handlers use these directly; existing `upsert` stays for test helpers
that genuinely want upsert semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:04 +02:00
MechaCat02
b7175cc581 chore: rustfmt fixups for app_members files
Trailing-comma format! cleanup from `cargo fmt --all`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:39:25 +02:00
MechaCat02
d40ebf65a2 docs(blueprint): document app members CRUD endpoints in §11.6
Adds a new "App Member Management Endpoints" subsection covering the
shipped CRUD surface, the `my_role` field on the app lookup response,
and the no-last-app-admin-guard decision (with the corrected rationale
that owners — not admins — are what makes orphaning impossible).

Also updates the deferred-surfaces line so it stops claiming dashboard
member management is still curl-only, and bumps the Last Updated header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:38:36 +02:00
MechaCat02
816a13b920 feat(dashboard): Members tab on the app detail page
A new "Members" tab is rendered between Domains and Settings for
callers whose `my_role` on the app is `app_admin` (owners always;
explicit member-app_admins; admins do not see it — they're only
implicit editors and can't manage memberships).

The tab lets the caller:

- See every explicit member of the app with username, email, instance-
  role chip, app-role chip, and joined date. Inactive users render
  greyed-out so admins know the row exists.
- Pick a `member`-instance user from a dropdown and grant viewer /
  editor / app_admin access. The dropdown is populated from
  `/admin/admins` filtered to active members not already on the app.
- Promote / demote / remove existing members via the shared
  `ActionMenu` kebab. Removal goes through `ConfirmModal`.

Member-with-app_admin callers see a disabled add form with an
explanatory message — they have authority to manage memberships but
can't browse the user directory (gated on `InstanceManageUsers`),
which is a known phase-3.5 caveat to revisit in a follow-up.

Also extends `RoleChip` with an `appRole` prop and palette for app
roles, and adds an `appMembers` namespace to api.ts mirroring the
`domains` shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:37:36 +02:00
MechaCat02
248571dcde test(picloud): authz coverage for app members CRUD
Adds 16 integration tests against a real Postgres covering the new
/api/v1/admin/apps/{id_or_slug}/members surface:

- list / add / patch / remove against an explicit member row
- 409 on duplicate, 422 on inactive target, 422 on owner/admin target
- 404 on PATCH without an existing row; 204 idempotent DELETE
- viewer-as-bob receives 403 on every mutating verb
- both slug and UUID paths resolve to the same body
- bob-with-app_admin can manage the member list, including removing
  himself (load-bearing for the no-last-app-admin-guard decision)
- granting a `member` user a viewer membership makes the app appear
  in their `GET /admin/apps` list (was empty before)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:59 +02:00
MechaCat02
85bbabcbdf feat(api): app members CRUD endpoints
Adds /api/v1/admin/apps/{id_or_slug}/members[/{user_id}]:

- GET    list members (joined with admin_users via list_for_app_enriched)
- POST   grant membership — 201 with enriched DTO
         409 on duplicate (promotions go through PATCH on purpose so
         the UI can surface "already a member" cleanly)
         422 if the target user is deactivated
         422 if the target's instance_role isn't `member` — owners and
         admins already have implicit authority, so an explicit row
         would be dead weight
- PATCH  change role — 200 with enriched DTO
         404 if no existing membership (use POST to create)
- DELETE remove — 204, idempotent (matches the repo's `remove`
         contract; 204 also when the row never existed)

All four gated on `Capability::AppAdmin(app_id)`. Editors and viewers
get 403 from list and never see the dashboard's Members tab.

No last-app-admin guard: owners implicitly satisfy AppAdmin via
`role_grants`, so removing the last explicit app_admin row cannot
permanently orphan an app — an owner can always re-issue grants.

Wires through picloud/src/lib.rs by splitting the Postgres app_members
repo Arc into two trait views (AppMembersRepository for CRUD, AuthzRepo
for the existing capability lookups) without re-instantiating against
the pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:31:08 +02:00
MechaCat02
1314420fca feat(repo): join app_members with admin_users via list_for_app_enriched
Adds `AppMembershipDetail` (membership row + joined username, email,
instance_role, is_active) and `list_for_app_enriched` on
`AppMembersRepository`. The Postgres impl does a single JOIN on
admin_users ordered by username, so the upcoming `GET
/apps/{id}/members` handler can render its table without an N+1 fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:27:02 +02:00
MechaCat02
33697a2766 feat(api): expose caller's effective app role via my_role
GET /api/v1/admin/apps/{id_or_slug} now returns an `AppRole`-typed
`my_role` alongside the existing app fields, computed server-side from
the Principal: `Owner → app_admin` and `Admin → editor` (both
implicit per blueprint §11.6), `Member → app_members.role` (looked up
via the existing `AuthzRepo::membership` already in `AppsState`).

The dashboard uses this single field to decide whether to render
admin-only surfaces (Members tab, etc.) instead of duplicating the
implicit-grant rules on the client side — keeps API and UI gate logic
identical with one round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:25:23 +02:00
MechaCat02
6eb32a78bf feat(dashboard): adopt ActionMenu for user row actions
Replaces the inline row-action buttons on the Users page with the new
shared ActionMenu kebab. Drops the redundant `is_active` toggle from the
edit form (Activate/Deactivate already lives in the kebab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:21:08 +02:00
MechaCat02
fc35d59236 fix(dashboard): show pic_ prefix on API-key rows
The backend's ApiKeyDto.prefix is just the 8-char public head
(e.g. "PKXPCPH3"); the actual token the user pastes into their
CLI is "pic_PKXPCPH3…". Display the full visible identifier so
operators can match a row against the token in their notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:55 +02:00
MechaCat02
0c9f11558a feat(manager-core,picloud): accept email on admin create + patch
The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:52 +02:00
MechaCat02
39a6df2bfe fix(picloud): use is_some_and in /auth/me test (clippy)
clippy::map_unwrap_or — drop the map().unwrap_or(false) for the
flatter is_some_and(Value::is_null).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:08:09 +02:00
MechaCat02
d21cbdb164 chore(dashboard): remove superseded /admins page
/admin/users is a strict superset of the pre-3.5 /admin/admins
page (adds role chip, email column, search, role-aware affordance
hiding, and the password-reveal flow), so the old page would only
split traffic and confuse muscle memory.

Also drops the AdminUserRecord type alias kept in place to ease
the transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:05:57 +02:00
MechaCat02
700ae7b7d1 feat(dashboard): users admin page with invite/edit/delete + password reveal
/admin/users is the owner+admin surface for managing the platform's
user list. Members get bounced to /profile?denied=users.

Invite generates a random 16-char password client-side, POSTs the
new user, and surfaces the cleartext exactly once in a yellow-
bordered reveal modal with a Copy button and an "I've shared it"
acknowledgement gate. Owner role is intentionally not in the create
form — promote via Edit after creation, matching the backend's
deliberate-step comment.

Edit handles username, email, role (with affordance hiding: admins
see admin/member only), is_active toggle, and a separate "Reset
password" button that re-uses the same reveal flow. Delete uses
ConfirmModal with confirmPhrase=username and explains the
last-owner/last-admin 422s up front.

Username + email validated client-side against the same patterns
the backend enforces so the form fails fast rather than always
on the round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:05:02 +02:00
MechaCat02
f16ff22a5a feat(dashboard): profile page with API-key list, mint, and revoke
/admin/profile is the per-principal page available to every
authenticated user (owner, admin, member). Shows the caller's
identity (username, role chip, email, id) plus a full API-key
list/mint/revoke surface.

Minting reveals the raw token exactly once in a yellow-bordered
panel with a Copy button and an "I've saved it" acknowledgement
gate before the Done button enables, matching the spec's one-shot
secret-display pattern.

Live mirrors the backend bound-key guard: picking an app from the
binding dropdown drops any instance:* scopes from the selection and
greys out their checkboxes with a tooltip, so submit never hits a
422 on that case.

Also surfaces a one-shot info banner when /admin/users redirects a
member here with ?denied=users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:02:40 +02:00
MechaCat02
bd2258499e feat(dashboard): role-gated Users link and profile chip in nav
The header nav now shows a Users link only for owners/admins, and
the username block becomes a profile-link chip rendering the role
pill next to the name. Both react to the currentUser store, so they
update on login without an extra fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:00:20 +02:00
MechaCat02
df691038d7 feat(dashboard): add MeDto, AdminDto, apiKeys + role/password helpers
Extends api.ts with the Phase 3.5 wire types (InstanceRole, Scope,
MeDto, AdminDto, ApiKeyDto, MintApiKey*) and the matching apiKeys
namespace. AdminUser in auth.ts now carries instance_role and email,
so layout/store consumers see the role without a separate fetch.

Adds two tiny lib helpers used by the upcoming profile/users pages:
RoleChip.svelte for the colored owner/admin/member pill, and
password-gen.ts for crypto.getRandomValues-backed temporary
passwords used in user-invite + reset-password reveals.

AdminUserRecord stays as a deprecated alias until /admins is
retired in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:00:06 +02:00
MechaCat02
3688c26cb4 feat(manager-core,picloud): expose instance_role + email on /auth/me
Login and /auth/me now return the same shape — id, username,
instance_role, email — so the dashboard can gate UI on role from
either the login response or the layout's me() refetch without an
extra round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:06 +02:00
MechaCat02
2aab92af31 style: cargo fmt across Phase 3.5 changes
Pure formatting pass — no behavior changes. Catches the line-wrapping
drift across the new authz / api_keys / middleware / handler edits
that piled up during the implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:21:37 +02:00
MechaCat02
063595be31 test(picloud): integration tests for Phase 3.5 authz (11 cases)
Covers the matrix laid out in the plan:
* bootstrap admin lands as Owner
* owner / admin / member access matrices on the default app
* bearer pic_ key and cookie session resolve to the same Principal
* read-only key cannot write (scope intersection)
* bound key cannot escape its app
* member listing isolation at SQL for /admin/apps + /admin/scripts
* deactivating a user expires every API key for them
* mint rejects bound key carrying instance:* scopes (422)
* list_active_owners returns the right set for the startup warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:19:24 +02:00
MechaCat02
30a1584667 chore: bump product to 0.6.0; multi-owner startup warning
Phase 3.5 ships → product minor bump under pre-1.0 rules (any surface
bump triggers minor). Schema is now 6 (0006_users_authz.sql); API
remains 1 (additive endpoints + new credential type, no breaking
shape changes). docs/versioning.md updated.

main.rs gets warn_on_multi_owner_install() which fires once after
bootstrap when more than one active owner exists — points the
operator at PATCH /admin/admins/{id} for cleanup. Soft-fail on DB
error (does not block startup).

The api-test schema assertion was updated to expect 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:15:45 +02:00
MechaCat02
d229120df6 feat(manager-core,picloud): per-handler require(capability) checks
Every admin endpoint now resolves Capability for the loaded resource
and calls authz::require(...) before mutating. Forbidden → 403; every
handler State carries an Arc<dyn AuthzRepo>, plumbed from the new
PostgresAppMembersRepository in the picloud binary.

* api.rs (scripts): AppRead/AppWriteScript/AppLogRead bound to
  script.app_id after load. List branches on instance_role:
  Member → list_for_user, others → list (or ?app= filtered).
* apps_api.rs: InstanceCreateApp on POST; AppRead on get/list_domains;
  AppAdmin on patch/delete/slug:check; AppManageDomains on
  create_domain/delete_domain. list_apps membership-filters for Member.
* admin_users_api.rs: InstanceManageUsers on every endpoint. Mint +
  PATCH refuse to grant Owner unless the caller is already Owner
  (CannotEscalate / 422), on top of the existing last-owner guard.
* route_admin.rs: AppRead on list/check/match; AppWriteRoute on
  create/delete bound to the route's actual app_id (added a
  RouteRepository::get(uuid) lookup so delete binds correctly).
* AppRepository + ScriptRepository gain list_for_user(user_id) for
  membership-filtered listings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:13:45 +02:00
MechaCat02
8659a58eb2 feat(manager-core,picloud): api_keys_api + deactivation cascade
* auth: generate_api_key() mints pic_<base32(32 bytes)>, splits the
  indexed 8-char prefix, and Argon2-hashes the body. Adds the
  data-encoding workspace dep for unpadded base32.
* api_keys_api: POST /api/v1/admin/api-keys (mint, returns raw_token
  exactly once), GET (caller's own, no raw), DELETE {id} (caller's
  own; 404 deliberately covers both 'missing' and 'not yours').
  Mint validation rejects bound keys carrying instance:* scopes (422).
* AdminsState gains the api keys repo; PATCH set_active(false) now
  expires every active key for that user alongside session wipe —
  Phase 3.5 deactivation symmetry.
* picloud lib wires PostgresApiKeyRepository through AuthDeps into
  AdminsState + ApiKeysState; api_keys_router merges into the
  guarded_admin layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:00:33 +02:00
MechaCat02
5f7ddd23ab feat(manager-core,picloud): bearer pic_ keys land in Principal
* auth_middleware: split into resolve_principal → verify_session OR
  verify_api_key (selected by the pic_ prefix). Both paths converge on
  Principal as the request extension; require_admin keeps working as
  a #[deprecated] alias for require_authenticated. AuthState gains an
  api_keys repo; the cookie path is unchanged.
* api-key path takes the first 8 chars after pic_ as the indexed
  lookup key, Argon2-verifies each candidate, soft-rejects deactivated
  users, and updates last_used_at inline.
* auth_api: /auth/me now consumes Extension<Principal> and re-fetches
  the user row so username updates surface immediately.
* picloud: AuthDeps + AuthState wired with PostgresApiKeyRepository;
  the layer call switches to require_authenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:55:38 +02:00
MechaCat02
44db8d107a feat(manager-core): repos + admin patch for Phase 3.5 schema
* admin_user_repo: surface instance_role + email on AdminUserRow /
  Credentials; create() now takes instance_role; add
  update_instance_role, list_active_owners, count_other_active_owners.
* admin_users_api: DTO + create/patch accept instance_role (defaults
  to Admin on create — only env-var bootstrap defaults to Owner).
  PATCH and DELETE enforce the last-owner guard alongside the
  existing last-active-admin guard.
* app_members_repo: new — implements AuthzRepo::membership via the
  app_members table plus upsert/remove/list_for_user/list_for_app.
* api_key_repo: new — create / find_active_by_prefix / touch_last_used
  / list_for_user / get / delete_by_id_and_user / expire_all_for_user.
  Separates ApiKeyRow (no hash) from ApiKeyVerification (hash, for
  the middleware verifier) so handlers can't leak the hash.
* auth_bootstrap + picloud tests: pass Owner on the bootstrap seed
  and on the test admin seed respectively; in-memory test repo
  implements the new trait methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:49:54 +02:00
MechaCat02
abaabb68d8 feat(manager-core): add authz module with can() / require()
Implements the three-layer capability check from blueprint §11.6:
role grant (instance role + app_members) ∩ scope intersection (for
API keys) ∩ app binding (for bound keys). Capabilities are finer than
scopes (AppWriteScript vs AppWriteRoute, AppManageDomains vs
AppAdmin) so a script:write-only key cannot mutate routes; scopes
stay at the seven values the blueprint locks down.

In-memory AuthzRepo fixture in the test module covers the full
matrix: owner / admin / member behavior, scope intersection, bound
key isolation, and instance:* denial on bound keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:40:04 +02:00
MechaCat02
fd6f2b1f13 feat(shared): add Principal, InstanceRole, AppRole, Scope, ApiKeyId
Cross-crate authn/authz data types for Phase 3.5. The Principal struct
is the resolved caller identity that auth_middleware will produce for
both cookie sessions and bearer API keys; the role/scope enums mirror
the DB CHECK constraints from migration 0006 and round-trip through
their stable string forms.

UserId is a type alias for AdminUserId — the auth layer treats an
admin row as the principal identity, so the alias avoids a rename of
the existing id type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:35:25 +02:00
MechaCat02
d435322f9c feat(manager-core): add 0006 users_authz migration
Adds instance_role + reserved email/mfa_secret columns to admin_users,
creates app_members for per-app role grants, and creates api_keys for
bearer-token credentials. Schema snapshot re-blessed.

Reserves invites and service_accounts shapes in a trailing comment
block — both land in their own migrations when those flows ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:33:40 +02:00
MechaCat02
5546323cdc docs(blueprint): add §11.6 Phase 3.5 users, roles, and bearer-token auth
Specifies the unified can(principal, capability) gate, instance roles
(owner/admin/member), per-app memberships (app_admin/editor/viewer),
pic_-prefixed API keys, and the schema rooms for invites / MFA /
service accounts. Updates §12 Phase 3 to add 3c as a third foundation
piece alongside 3a (admin auth) and 3b (multi-app scoping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:31:25 +02:00
86 changed files with 12071 additions and 981 deletions

11
.gitignore vendored
View File

@@ -30,6 +30,17 @@ config.local.toml
/dashboard/build /dashboard/build
/dashboard/.env /dashboard/.env
# Dashboard — Playwright E2E
/dashboard/tests/e2e/.auth
/dashboard/tests/e2e/.results
/dashboard/playwright-report
/dashboard/test-results
/dashboard/.playwright
# When playwright is invoked from the repo root by accident, these
# also land here.
/playwright-report
/test-results
# Caddy # Caddy
/caddy/data /caddy/data
/caddy/config /caddy/config

376
Cargo.lock generated
View File

@@ -40,6 +40,56 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -68,6 +118,21 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "assert_cmd"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
dependencies = [
"anstyle",
"bstr",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -236,6 +301,17 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -302,6 +378,52 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -408,6 +530,12 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -434,6 +562,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -446,6 +580,27 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -510,6 +665,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "figment" name = "figment"
version = "0.10.19" version = "0.10.19"
@@ -530,6 +691,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "float-cmp"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1004,6 +1174,12 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1071,6 +1247,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1155,6 +1337,12 @@ dependencies = [
"spin 0.5.2", "spin 0.5.2",
] ]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1225,6 +1413,18 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -1305,12 +1505,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "picloud" name = "picloud"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
"axum-test", "axum-test",
"chrono",
"figment", "figment",
"picloud-executor-core", "picloud-executor-core",
"picloud-manager-core", "picloud-manager-core",
@@ -1325,11 +1526,31 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
]
[[package]]
name = "picloud-cli"
version = "0.6.0"
dependencies = [
"anyhow",
"assert_cmd",
"clap",
"directories",
"picloud-shared",
"predicates",
"reqwest",
"rpassword",
"serde",
"serde_json",
"tempfile",
"tokio",
"toml",
] ]
[[package]] [[package]]
name = "picloud-executor" name = "picloud-executor"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-executor-core", "picloud-executor-core",
@@ -1341,7 +1562,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-executor-core" name = "picloud-executor-core"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"picloud-shared", "picloud-shared",
@@ -1355,7 +1576,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager" name = "picloud-manager"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-manager-core", "picloud-manager-core",
@@ -1367,13 +1588,14 @@ dependencies = [
[[package]] [[package]]
name = "picloud-manager-core" name = "picloud-manager-core"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"axum", "axum",
"base64", "base64",
"chrono", "chrono",
"data-encoding",
"picloud-orchestrator-core", "picloud-orchestrator-core",
"picloud-shared", "picloud-shared",
"rand 0.8.6", "rand 0.8.6",
@@ -1390,7 +1612,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator" name = "picloud-orchestrator"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"picloud-orchestrator-core", "picloud-orchestrator-core",
@@ -1402,7 +1624,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-orchestrator-core" name = "picloud-orchestrator-core"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1421,7 +1643,7 @@ dependencies = [
[[package]] [[package]]
name = "picloud-shared" name = "picloud-shared"
version = "0.5.1" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -1500,6 +1722,36 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "predicates"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
dependencies = [
"anstyle",
"difflib",
"float-cmp",
"normalize-line-endings",
"predicates-core",
"regex",
]
[[package]]
name = "predicates-core"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
[[package]]
name = "predicates-tree"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
dependencies = [
"predicates-core",
"termtree",
]
[[package]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@@ -1695,6 +1947,29 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@@ -1720,7 +1995,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1803,6 +2080,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rpassword"
version = "7.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1823,6 +2111,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rust-multipart-rfc7578_2" name = "rust-multipart-rfc7578_2"
version = "0.8.0" version = "0.8.0"
@@ -1844,6 +2142,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@@ -2318,6 +2629,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2355,6 +2672,25 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]] [[package]]
name = "thin-vec" name = "thin-vec"
version = "0.2.18" version = "0.2.18"
@@ -2774,6 +3110,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.1"
@@ -2804,6 +3146,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -3057,6 +3408,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

View File

@@ -9,10 +9,11 @@ members = [
"crates/picloud-manager", "crates/picloud-manager",
"crates/picloud-orchestrator", "crates/picloud-orchestrator",
"crates/picloud-executor", "crates/picloud-executor",
"crates/picloud-cli",
] ]
[workspace.package] [workspace.package]
version = "0.5.1" version = "0.6.0"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -66,11 +67,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
url = "2" url = "2"
urlencoding = "2" urlencoding = "2"
# Auth (admin users + sessions) # Auth (admin users + sessions + API keys)
argon2 = "0.5" argon2 = "0.5"
rand = { version = "0.8", features = ["getrandom"] } rand = { version = "0.8", features = ["getrandom"] }
sha2 = "0.10" sha2 = "0.10"
base64 = "0.22" base64 = "0.22"
data-encoding = "2.6"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View File

@@ -27,6 +27,7 @@ argon2.workspace = true
rand.workspace = true rand.workspace = true
sha2.workspace = true sha2.workspace = true
base64.workspace = true base64.workspace = true
data-encoding.workspace = true
[dev-dependencies] [dev-dependencies]
tokio.workspace = true tokio.workspace = true

View File

@@ -0,0 +1,112 @@
-- Phase 3.5 users, roles, and bearer-token auth — see blueprint §11.6.
--
-- Lays down the schema that the unified can(principal, capability) gate
-- runs against, plus the api_keys table that backs `Authorization: Bearer
-- pic_…` credentials. No data-plane impact; Phase 4 SDKs (KV, docs, HTTP,
-- cron) will plug into this same authz pipeline.
--
-- Three changes:
-- 1. admin_users gains instance_role ('owner'/'admin'/'member') plus a
-- reserved email column and mfa_secret slot (neither is read yet).
-- Every pre-existing row becomes 'owner' via the DEFAULT — Phase 3a
-- had no role concept, so promoting all current admins to owner is
-- the only safe interpretation (and matches the spec). The Rust
-- startup path logs a warning when more than one active owner
-- exists, so operators can demote extras via the admin PATCH.
-- 2. app_members records explicit per-app grants for 'member' users.
-- Owners and admins get implicit grants in code (owner→app_admin
-- everywhere, admin→editor everywhere); no rows here.
-- 3. api_keys holds Argon2id-hashed bearer credentials. Lookup is
-- prefix-indexed (first 8 chars after `pic_`) then hash-verified;
-- raw token only ever exists in the POST response. Optional
-- expires_at / app_id implement TTL and app-binding respectively.
ALTER TABLE admin_users
-- DEFAULT 'owner' so the Phase 3a bootstrap admin (and any other
-- pre-existing rows) become full owners without a backfill step.
-- Multi-owner installs are flagged at startup; demotion is a
-- deliberate PATCH, not an automatic migration choice.
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
CHECK (instance_role IN ('owner', 'admin', 'member')),
-- Reserved for the eventual invite flow + Phase 4 user-management
-- SDK. UNIQUE so we never end up with two rows claiming the same
-- contact. Nullable because pre-existing admins have no email on
-- file and we don't want to force a backfill.
ADD COLUMN email TEXT UNIQUE,
-- Reserved slot for TOTP secrets. Not read in Phase 3.5 — present
-- now only to avoid a schema bump when MFA lands.
ADD COLUMN mfa_secret TEXT;
CREATE INDEX admin_users_instance_role_idx ON admin_users (instance_role);
-- Per-(user, app) explicit grant. Owners and admins do NOT appear here;
-- their app authority is implicit in their instance_role and resolved in
-- code. Only 'member' users need rows in this table — without one, a
-- member has no access to the app at all.
CREATE TABLE app_members (
app_id UUID NOT NULL REFERENCES apps(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 (app_id, user_id)
);
-- Lookup pattern is "what apps can this user see?" — needed for the
-- membership-filtered GET /admin/apps and GET /admin/scripts.
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
-- Bearer API keys. Format on the wire: `pic_<base32(32 random bytes)>`.
-- prefix = first 8 chars after `pic_` (indexed for O(1) candidate lookup)
-- hash = Argon2id PHC of the full body after `pic_`
-- Raw value is returned exactly once at mint time and never persisted.
--
-- Optional fields:
-- expires_at: TTL. Lookup always filters `expires_at IS NULL OR > NOW()`.
-- app_id : "bound key" — capability checks deny any App*(other_app),
-- regardless of the owning user's role. Cannot combine with
-- instance:* scopes (validated in the mint handler, not SQL).
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
hash TEXT NOT NULL,
prefix TEXT NOT NULL,
name TEXT NOT NULL,
-- TEXT[] keeps the scope set open to additions without a migration;
-- the seven legal values are validated at mint time in Rust, not by
-- a CHECK constraint here (so new scopes can land without a schema
-- bump).
scopes TEXT[] NOT NULL,
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NULL,
last_used_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
-- ---------------------------------------------------------------------
-- Reserved schema room (not built in Phase 3.5)
-- ---------------------------------------------------------------------
-- These tables are deliberately commented out, not created. They are
-- listed here so the design intent is visible at the migration boundary
-- and future authors don't reinvent the shape. Each lands in its own
-- numbered migration when the corresponding flow ships.
--
-- CREATE TABLE invites (
-- token TEXT PRIMARY KEY, -- raw at email-link time, hashed at rest
-- email TEXT NOT NULL,
-- instance_role TEXT NULL CHECK (instance_role IN ('owner','admin','member')),
-- app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
-- app_role TEXT NULL CHECK (app_role IN ('app_admin','editor','viewer')),
-- invited_by UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
-- expires_at TIMESTAMPTZ NOT NULL,
-- consumed_at TIMESTAMPTZ NULL
-- );
--
-- CREATE TABLE service_accounts (
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- name TEXT NOT NULL,
-- owning_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- );

View File

@@ -7,7 +7,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::AdminUserId; use picloud_shared::{AdminUserId, InstanceRole};
use sqlx::PgPool; use sqlx::PgPool;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -20,6 +20,12 @@ pub enum AdminUserRepositoryError {
#[error("username already taken: {0}")] #[error("username already taken: {0}")]
DuplicateUsername(String), DuplicateUsername(String),
#[error("email already taken: {0}")]
DuplicateEmail(String),
#[error("invalid instance_role stored in DB: {0}")]
InvalidInstanceRole(String),
} }
/// Row returned to handlers and bootstrap. Never includes the password /// Row returned to handlers and bootstrap. Never includes the password
@@ -30,6 +36,8 @@ pub struct AdminUserRow {
pub id: AdminUserId, pub id: AdminUserId,
pub username: String, pub username: String,
pub is_active: bool, pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>, pub last_login_at: Option<DateTime<Utc>>,
@@ -44,6 +52,7 @@ pub struct AdminUserCredentials {
pub username: String, pub username: String,
pub password_hash: String, pub password_hash: String,
pub is_active: bool, pub is_active: bool,
pub instance_role: InstanceRole,
} }
#[async_trait] #[async_trait]
@@ -58,10 +67,16 @@ pub trait AdminUserRepository: Send + Sync {
username: &str, username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>; ) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>; async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
/// Create a new admin. `instance_role` defaults to `Owner` for the
/// env-var bootstrap path; admin-creates-admin flows pass an
/// explicit role. `email` is optional — pass `None` to leave the
/// column NULL.
async fn create( async fn create(
&self, &self,
username: &str, username: &str,
password_hash: &str, password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError>; ) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn update_username( async fn update_username(
&self, &self,
@@ -73,6 +88,20 @@ pub trait AdminUserRepository: Send + Sync {
id: AdminUserId, id: AdminUserId,
password_hash: &str, password_hash: &str,
) -> Result<AdminUserRow, AdminUserRepositoryError>; ) -> Result<AdminUserRow, AdminUserRepositoryError>;
/// Set or clear the email address. `None` writes NULL to the column.
async fn update_email(
&self,
id: AdminUserId,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
/// callers enforce the last-owner guard (`count_other_active_owners`)
/// before invoking when role transitions away from `Owner`.
async fn update_instance_role(
&self,
id: AdminUserId,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn set_active( async fn set_active(
&self, &self,
id: AdminUserId, id: AdminUserId,
@@ -90,6 +119,15 @@ pub trait AdminUserRepository: Send + Sync {
&self, &self,
id: AdminUserId, id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError>; ) -> Result<i64, AdminUserRepositoryError>;
/// All active owners — used for the multi-owner startup warning.
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
/// Count of active owners excluding the given id. Used by the
/// last-owner guard when demoting / deactivating / deleting an
/// owner: "would this leave zero owners?"
async fn count_other_active_owners(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError>;
} }
pub struct PostgresAdminUserRepository { pub struct PostgresAdminUserRepository {
@@ -107,13 +145,14 @@ impl PostgresAdminUserRepository {
impl AdminUserRepository for PostgresAdminUserRepository { impl AdminUserRepository for PostgresAdminUserRepository {
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> { async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>( let row = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, created_at, updated_at, last_login_at \ "SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users WHERE id = $1", FROM admin_users WHERE id = $1",
) )
.bind(id.into_inner()) .bind(id.into_inner())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(Into::into)) row.map(TryInto::try_into).transpose()
} }
async fn get_by_username( async fn get_by_username(
@@ -121,13 +160,14 @@ impl AdminUserRepository for PostgresAdminUserRepository {
username: &str, username: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> { ) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>( let row = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, created_at, updated_at, last_login_at \ "SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users WHERE username = $1", FROM admin_users WHERE username = $1",
) )
.bind(username) .bind(username)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(Into::into)) row.map(TryInto::try_into).transpose()
} }
async fn get_credentials_by_username( async fn get_credentials_by_username(
@@ -135,45 +175,62 @@ impl AdminUserRepository for PostgresAdminUserRepository {
username: &str, username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> { ) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminCredsRecord>( let row = sqlx::query_as::<_, AdminCredsRecord>(
"SELECT id, username, password_hash, is_active \ "SELECT id, username, password_hash, is_active, instance_role \
FROM admin_users WHERE username = $1", FROM admin_users WHERE username = $1",
) )
.bind(username) .bind(username)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(row.map(Into::into)) row.map(TryInto::try_into).transpose()
} }
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> { async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
let rows = sqlx::query_as::<_, AdminUserRecord>( let rows = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, created_at, updated_at, last_login_at \ "SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users ORDER BY username", FROM admin_users ORDER BY username",
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.into_iter().map(Into::into).collect()) rows.into_iter().map(TryInto::try_into).collect()
} }
async fn create( async fn create(
&self, &self,
username: &str, username: &str,
password_hash: &str, password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> { ) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>( let res = sqlx::query_as::<_, AdminUserRecord>(
"INSERT INTO admin_users (username, password_hash) \ "INSERT INTO admin_users (username, password_hash, instance_role, email) \
VALUES ($1, $2) \ VALUES ($1, $2, $3, $4) \
RETURNING id, username, is_active, created_at, updated_at, last_login_at", RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
) )
.bind(username) .bind(username)
.bind(password_hash) .bind(password_hash)
.bind(instance_role.as_str())
.bind(email)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await; .await;
match res { match res {
Ok(row) => Ok(row.into()), Ok(row) => row.try_into(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err( Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
AdminUserRepositoryError::DuplicateUsername(username.to_string()), // username and email both have unique constraints; the
), // create path can collide on either, so peek at the
// constraint name to surface the right error.
if e.constraint() == Some("admin_users_email_key") {
Err(AdminUserRepositoryError::DuplicateEmail(
email.unwrap_or("").to_string(),
))
} else {
Err(AdminUserRepositoryError::DuplicateUsername(
username.to_string(),
))
}
}
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
@@ -186,7 +243,8 @@ impl AdminUserRepository for PostgresAdminUserRepository {
let res = sqlx::query_as::<_, AdminUserRecord>( let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET username = $2, updated_at = NOW() \ "UPDATE admin_users SET username = $2, updated_at = NOW() \
WHERE id = $1 \ WHERE id = $1 \
RETURNING id, username, is_active, created_at, updated_at, last_login_at", RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
) )
.bind(id.into_inner()) .bind(id.into_inner())
.bind(username) .bind(username)
@@ -194,7 +252,7 @@ impl AdminUserRepository for PostgresAdminUserRepository {
.await; .await;
match res { match res {
Ok(Some(row)) => Ok(row.into()), Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)), Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err( Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateUsername(username.to_string()), AdminUserRepositoryError::DuplicateUsername(username.to_string()),
@@ -211,14 +269,60 @@ impl AdminUserRepository for PostgresAdminUserRepository {
let row = sqlx::query_as::<_, AdminUserRecord>( let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \ "UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
WHERE id = $1 \ WHERE id = $1 \
RETURNING id, username, is_active, created_at, updated_at, last_login_at", RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
) )
.bind(id.into_inner()) .bind(id.into_inner())
.bind(password_hash) .bind(password_hash)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
row.map(Into::into) row.ok_or(AdminUserRepositoryError::NotFound(id))
.ok_or(AdminUserRepositoryError::NotFound(id)) .and_then(TryInto::try_into)
}
async fn update_email(
&self,
id: AdminUserId,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET email = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(email)
.fetch_optional(&self.pool)
.await;
match res {
Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
),
Err(e) => Err(e.into()),
}
}
async fn update_instance_role(
&self,
id: AdminUserId,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(id.into_inner())
.bind(instance_role.as_str())
.fetch_optional(&self.pool)
.await?;
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
} }
async fn set_active( async fn set_active(
@@ -229,14 +333,15 @@ impl AdminUserRepository for PostgresAdminUserRepository {
let row = sqlx::query_as::<_, AdminUserRecord>( let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \ "UPDATE admin_users SET is_active = $2, updated_at = NOW() \
WHERE id = $1 \ WHERE id = $1 \
RETURNING id, username, is_active, created_at, updated_at, last_login_at", RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
) )
.bind(id.into_inner()) .bind(id.into_inner())
.bind(is_active) .bind(is_active)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
row.map(Into::into) row.ok_or(AdminUserRepositoryError::NotFound(id))
.ok_or(AdminUserRepositoryError::NotFound(id)) .and_then(TryInto::try_into)
} }
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> { async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
@@ -277,6 +382,33 @@ impl AdminUserRepository for PostgresAdminUserRepository {
.await?; .await?;
Ok(count) Ok(count)
} }
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
let rows = sqlx::query_as::<_, AdminUserRecord>(
"SELECT id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at \
FROM admin_users \
WHERE is_active AND instance_role = 'owner' \
ORDER BY username",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn count_other_active_owners(
&self,
id: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*)::BIGINT FROM admin_users \
WHERE is_active AND instance_role = 'owner' AND id <> $1",
)
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count)
}
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -284,21 +416,28 @@ struct AdminUserRecord {
id: uuid::Uuid, id: uuid::Uuid,
username: String, username: String,
is_active: bool, is_active: bool,
instance_role: String,
email: Option<String>,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>, last_login_at: Option<DateTime<Utc>>,
} }
impl From<AdminUserRecord> for AdminUserRow { impl TryFrom<AdminUserRecord> for AdminUserRow {
fn from(r: AdminUserRecord) -> Self { type Error = AdminUserRepositoryError;
Self { fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(), id: r.id.into(),
username: r.username, username: r.username,
is_active: r.is_active, is_active: r.is_active,
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
)?,
email: r.email,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
last_login_at: r.last_login_at, last_login_at: r.last_login_at,
} })
} }
} }
@@ -308,15 +447,20 @@ struct AdminCredsRecord {
username: String, username: String,
password_hash: String, password_hash: String,
is_active: bool, is_active: bool,
instance_role: String,
} }
impl From<AdminCredsRecord> for AdminUserCredentials { impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
fn from(r: AdminCredsRecord) -> Self { type Error = AdminUserRepositoryError;
Self { fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(), id: r.id.into(),
username: r.username, username: r.username,
password_hash: r.password_hash, password_hash: r.password_hash,
is_active: r.is_active, is_active: r.is_active,
} instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
)?,
})
} }
} }

View File

@@ -14,15 +14,17 @@ use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::{Extension, Router};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::AdminUserId; use picloud_shared::{AdminUserId, InstanceRole, Principal};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository; use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow}; use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::api_key_repo::ApiKeyRepository;
use crate::auth::hash_password; use crate::auth::hash_password;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is /// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
/// a strict ASCII subset so the lookup column stays predictable, and /// a strict ASCII subset so the lookup column stays predictable, and
@@ -36,6 +38,13 @@ const PASSWORD_MIN: usize = 8;
pub struct AdminsState { pub struct AdminsState {
pub users: Arc<dyn AdminUserRepository>, pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>, pub sessions: Arc<dyn AdminSessionRepository>,
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
/// also expires every active API key for that user so cookie and
/// bearer credentials become inert at the same moment.
pub keys: Arc<dyn ApiKeyRepository>,
/// Capability gate: every endpoint here requires
/// `InstanceManageUsers` (owner / admin).
pub authz: Arc<dyn AuthzRepo>,
} }
pub fn admins_router(state: AdminsState) -> Router { pub fn admins_router(state: AdminsState) -> Router {
@@ -57,6 +66,8 @@ pub struct AdminDto {
pub id: AdminUserId, pub id: AdminUserId,
pub username: String, pub username: String,
pub is_active: bool, pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>, pub last_login_at: Option<DateTime<Utc>>,
} }
@@ -67,6 +78,8 @@ impl From<AdminUserRow> for AdminDto {
id: r.id, id: r.id,
username: r.username, username: r.username,
is_active: r.is_active, is_active: r.is_active,
instance_role: r.instance_role,
email: r.email,
created_at: r.created_at, created_at: r.created_at,
last_login_at: r.last_login_at, last_login_at: r.last_login_at,
} }
@@ -77,6 +90,18 @@ impl From<AdminUserRow> for AdminDto {
pub struct CreateAdminRequest { pub struct CreateAdminRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
/// Defaults to `Admin` when absent — minting an owner via the API
/// is a deliberate step. The env-var bootstrap path is the only
/// channel that defaults to `Owner`.
#[serde(default = "default_create_role")]
pub instance_role: InstanceRole,
/// Optional contact email. Blank/whitespace is normalized to None.
#[serde(default)]
pub email: Option<String>,
}
const fn default_create_role() -> InstanceRole {
InstanceRole::Admin
} }
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
@@ -84,6 +109,27 @@ pub struct PatchAdminRequest {
pub username: Option<String>, pub username: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
pub instance_role: Option<InstanceRole>,
/// JSON Merge Patch (RFC 7396) semantics for email:
/// absent → don't change
/// null → clear (set DB column to NULL)
/// "<string>" → set to that string
/// `Option<Option<T>>` is the idiomatic Rust shape for that
/// tri-state; the custom deserializer below distinguishes the
/// "missing" case from the "present-and-null" case that serde
/// would otherwise collapse together.
#[allow(clippy::option_option)]
#[serde(default, deserialize_with = "deserialize_present_optional")]
pub email: Option<Option<String>>,
}
#[allow(clippy::option_option)]
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<T>::deserialize(deserializer)?))
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -92,15 +138,29 @@ pub struct PatchAdminRequest {
async fn list_admins( async fn list_admins(
State(state): State<AdminsState>, State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<AdminDto>>, AdminApiError> { ) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
let rows = state.users.list().await?; let rows = state.users.list().await?;
Ok(Json(rows.into_iter().map(Into::into).collect())) Ok(Json(rows.into_iter().map(Into::into).collect()))
} }
async fn get_admin( async fn get_admin(
State(state): State<AdminsState>, State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>, Path(id): Path<AdminUserId>,
) -> Result<Json<AdminDto>, AdminApiError> { ) -> Result<Json<AdminDto>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
state state
.users .users
.get(id) .get(id)
@@ -112,24 +172,50 @@ async fn get_admin(
async fn create_admin( async fn create_admin(
State(state): State<AdminsState>, State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAdminRequest>, Json(input): Json<CreateAdminRequest>,
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> { ) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
// Minting an owner via the API requires the caller to ALSO be an
// owner — admin cannot self-elevate (or elevate someone else)
// beyond their own ceiling. Owner-creation by env-var bootstrap
// bypasses this path.
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
{
return Err(AdminApiError::CannotEscalate);
}
let username = input.username.trim(); let username = input.username.trim();
validate_username(username)?; validate_username(username)?;
validate_password(&input.password)?; validate_password(&input.password)?;
let email = normalize_email(input.email.as_deref())?;
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?; let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
let row = state.users.create(username, &hash).await?; let row = state
.users
.create(username, &hash, input.instance_role, email.as_deref())
.await?;
Ok((StatusCode::CREATED, Json(row.into()))) Ok((StatusCode::CREATED, Json(row.into())))
} }
async fn patch_admin( async fn patch_admin(
State(state): State<AdminsState>, State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>, Path(id): Path<AdminUserId>,
Json(input): Json<PatchAdminRequest>, Json(input): Json<PatchAdminRequest>,
) -> Result<Json<AdminDto>, AdminApiError> { ) -> Result<Json<AdminDto>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
// Verify the target exists upfront — keeps the error path uniform // Verify the target exists upfront — keeps the error path uniform
// for "rename a missing user" etc. // for "rename a missing user" etc.
let _ = state let current = state
.users .users
.get(id) .get(id)
.await? .await?
@@ -154,6 +240,32 @@ async fn patch_admin(
// for the initial cut.) // for the initial cut.)
} }
if let Some(email_patch) = input.email.as_ref() {
// email_patch is Some(None) → clear, Some(Some(s)) → set.
let normalized = normalize_email(email_patch.as_deref())?;
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
}
if let Some(new_role) = input.instance_role {
// Self-elevation guard: only an owner can promote anyone TO
// owner. An admin cannot turn themselves (or anyone else)
// into one.
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
return Err(AdminApiError::CannotEscalate);
}
// Last-active-owner guard: a transition off of `Owner` cannot
// leave the install with zero owners. The check is on the
// source role (current.instance_role) so demoting an
// already-non-owner is always fine.
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
let remaining = state.users.count_other_active_owners(id).await?;
if remaining == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
latest = Some(state.users.update_instance_role(id, new_role).await?);
}
if let Some(new_active) = input.is_active { if let Some(new_active) = input.is_active {
// Last-active-admin guard: only when transitioning to inactive. // Last-active-admin guard: only when transitioning to inactive.
if !new_active { if !new_active {
@@ -161,14 +273,40 @@ async fn patch_admin(
if remaining == 0 { if remaining == 0 {
return Err(AdminApiError::LastActiveAdmin); return Err(AdminApiError::LastActiveAdmin);
} }
// ALSO: if the target is currently the last active owner,
// deactivating them leaves no owner. Belt-and-suspenders to
// the role guard above (which only triggers on an explicit
// role transition).
let target_role = latest
.as_ref()
.map_or(current.instance_role, |r| r.instance_role);
if target_role == InstanceRole::Owner {
let remaining_owners = state.users.count_other_active_owners(id).await?;
if remaining_owners == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
} }
latest = Some(state.users.set_active(id, new_active).await?); latest = Some(state.users.set_active(id, new_active).await?);
// Deactivation invalidates all of the user's sessions. Cheap // Deactivation invalidates BOTH credential surfaces — sessions
// and safer than waiting for sliding-window expiry. // (cookie / session bearer) and API keys. Both writes are
// logged on failure but do not undo the deactivation; the
// alternative (leaving the user active when one cascade fails)
// is worse than slightly stale credential rows on a DB blip.
if !new_active { if !new_active {
if let Err(err) = state.sessions.delete_for_user(id).await { if let Err(err) = state.sessions.delete_for_user(id).await {
tracing::error!(?err, "failed to delete sessions for deactivated admin"); tracing::error!(?err, "failed to delete sessions for deactivated admin");
} }
match state.keys.expire_all_for_user(id).await {
Ok(n) => {
if n > 0 {
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
}
}
Err(err) => {
tracing::error!(?err, "failed to expire api keys for deactivated admin");
}
}
} }
} }
@@ -185,8 +323,15 @@ async fn patch_admin(
async fn delete_admin( async fn delete_admin(
State(state): State<AdminsState>, State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>, Path(id): Path<AdminUserId>,
) -> Result<StatusCode, AdminApiError> { ) -> Result<StatusCode, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
let target = state let target = state
.users .users
.get(id) .get(id)
@@ -197,9 +342,18 @@ async fn delete_admin(
if remaining == 0 { if remaining == 0 {
return Err(AdminApiError::LastActiveAdmin); return Err(AdminApiError::LastActiveAdmin);
} }
// Last-owner guard mirrors the role-transition guard in
// patch_admin — deleting the only owner is just as bad as
// demoting them.
if target.instance_role == InstanceRole::Owner {
let remaining_owners = state.users.count_other_active_owners(id).await?;
if remaining_owners == 0 {
return Err(AdminApiError::LastActiveOwner);
}
}
} }
state.users.delete(id).await?; state.users.delete(id).await?;
// Sessions cascade via FK; no explicit delete needed. // Sessions + api_keys cascade via FK; no explicit delete needed.
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -234,6 +388,26 @@ fn validate_password(s: &str) -> Result<(), AdminApiError> {
Ok(()) Ok(())
} }
/// Trim and reject empty / pathological emails, returning the
/// canonical form (or None when the input was blank). The shape
/// check is intentionally loose — we mainly want to reject blanks
/// and obvious junk; real verification is a future concern.
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
let Some(raw) = raw else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.len() > 254 || !trimmed.contains('@') {
return Err(AdminApiError::InvalidEmail(
"email must contain '@' and be at most 254 characters".to_string(),
));
}
Ok(Some(trimmed.to_string()))
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Errors // Errors
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -249,9 +423,24 @@ pub enum AdminApiError {
#[error("{0}")] #[error("{0}")]
InvalidPassword(String), InvalidPassword(String),
#[error("{0}")]
InvalidEmail(String),
#[error("cannot leave the system with zero active admins")] #[error("cannot leave the system with zero active admins")]
LastActiveAdmin, LastActiveAdmin,
#[error("cannot leave the system with zero active owners")]
LastActiveOwner,
#[error("only an owner can grant the owner role")]
CannotEscalate,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("failed to hash password: {0}")] #[error("failed to hash password: {0}")]
Hash(String), Hash(String),
@@ -259,16 +448,40 @@ pub enum AdminApiError {
Repo(#[from] AdminUserRepositoryError), Repo(#[from] AdminUserRepositoryError),
} }
impl From<AuthzDenied> for AdminApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for AdminApiError { impl IntoResponse for AdminApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, message) = match &self { let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => { Self::Repo(
(StatusCode::CONFLICT, self.to_string()) AdminUserRepositoryError::DuplicateUsername(_)
} | AdminUserRepositoryError::DuplicateEmail(_),
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => { ) => (StatusCode::CONFLICT, self.to_string()),
Self::InvalidUsername(_)
| Self::InvalidPassword(_)
| Self::InvalidEmail(_)
| Self::LastActiveAdmin
| Self::LastActiveOwner
| Self::CannotEscalate
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
} }
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "admin_users authz error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(AdminUserRepositoryError::NotFound(_)) => { Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string()) (StatusCode::NOT_FOUND, self.to_string())
} }

View File

@@ -9,14 +9,16 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
Json, Router, Extension, Json, Router,
}; };
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError, AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
ValidationError,
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::app_repo::AppRepository; use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
}; };
@@ -31,6 +33,10 @@ pub struct AdminState<R, L> {
/// App lookups: validates `app_id` on create, resolves `?app=<slug>` /// App lookups: validates `app_id` on create, resolves `?app=<slug>`
/// filter on list. Trait-object so apps_repo can stay separate. /// filter on list. Trait-object so apps_repo can stay separate.
pub apps: Arc<dyn AppRepository>, pub apps: Arc<dyn AppRepository>,
/// Phase 3.5 capability checks — every script handler resolves
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
/// loading the resource.
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>, pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling, pub sandbox_ceiling: SandboxCeiling,
} }
@@ -41,6 +47,7 @@ impl<R, L> Clone for AdminState<R, L> {
repo: self.repo.clone(), repo: self.repo.clone(),
logs: self.logs.clone(), logs: self.logs.clone(),
apps: self.apps.clone(), apps: self.apps.clone(),
authz: self.authz.clone(),
validator: self.validator.clone(), validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling, sandbox_ceiling: self.sandbox_ceiling,
} }
@@ -129,14 +136,22 @@ where
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>( async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Query(q): Query<ListScriptsQuery>, Query(q): Query<ListScriptsQuery>,
) -> Result<Json<Vec<Script>>, ApiError> { ) -> Result<Json<Vec<Script>>, ApiError> {
// Membership filter: `member` users see only scripts in apps they
// belong to. `?app=` filters further by app and additionally
// requires the member to belong to that app (the read check uses
// the resource's app_id).
if let Some(ident) = q.app { if let Some(ident) = q.app {
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?; let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
Ok(Json(state.repo.list_for_app(app).await?)) require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
} else { return Ok(Json(state.repo.list_for_app(app).await?));
Ok(Json(state.repo.list().await?))
} }
if principal.instance_role == InstanceRole::Member {
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
}
Ok(Json(state.repo.list().await?))
} }
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history /// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
@@ -159,20 +174,34 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<Json<Script>, ApiError> { ) -> Result<Json<Script>, ApiError> {
state let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
.repo require(
.get(id) state.authz.as_ref(),
.await? &principal,
.map(Json) Capability::AppRead(script.app_id),
.ok_or(ApiError::NotFound(id)) )
.await?;
Ok(Json(script))
} }
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateScriptRequest>, Json(input): Json<CreateScriptRequest>,
) -> Result<(StatusCode, Json<Script>), ApiError> { ) -> Result<(StatusCode, Json<Script>), ApiError> {
// Capability is bound to the *requested* app_id since there's no
// resource to load yet. If the app doesn't exist we 422 below;
// checking authz first means a Member trying to create against an
// unknown app gets 403 (no enumeration of app existence).
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(input.app_id),
)
.await?;
state.validator.validate(&input.source)?; state.validator.validate(&input.source)?;
state.sandbox_ceiling.check(&input.sandbox)?; state.sandbox_ceiling.check(&input.sandbox)?;
// Refuse early if the app_id doesn't exist — a clean 422 beats a // Refuse early if the app_id doesn't exist — a clean 422 beats a
@@ -201,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
Json(input): Json<UpdateScriptRequest>, Json(input): Json<UpdateScriptRequest>,
) -> Result<Json<Script>, ApiError> { ) -> Result<Json<Script>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
)
.await?;
if let Some(src) = input.source.as_deref() { if let Some(src) = input.source.as_deref() {
state.validator.validate(src)?; state.validator.validate(src)?;
} }
@@ -229,8 +266,19 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>( async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script.app_id),
)
.await?;
state.repo.delete(id).await?; state.repo.delete(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -249,9 +297,17 @@ const fn default_limit() -> i64 {
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>( async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>, State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
axum::extract::Query(q): axum::extract::Query<LogsQuery>, axum::extract::Query(q): axum::extract::Query<LogsQuery>,
) -> Result<Json<Vec<ExecutionLog>>, ApiError> { ) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script.app_id),
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are // Cap to keep the dashboard responsive; the data plane writes are
// unbounded over time so a paged read is the only sane default. // unbounded over time so a paged read is the only sane default.
let limit = q.limit.clamp(1, 200); let limit = q.limit.clamp(1, 200);
@@ -281,10 +337,25 @@ pub enum ApiError {
#[error("{0}")] #[error("{0}")]
Ceiling(#[from] CeilingError), Ceiling(#[from] CeilingError),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
impl From<AuthzDenied> for ApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, message) = match &self { let (status, message) = match &self {
@@ -294,6 +365,14 @@ impl IntoResponse for ApiError {
Self::Invalid(_) | Self::Ceiling(_) => { Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) (StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
} }
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ScriptRepositoryError::NotFound(_)) => { Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string()) (StatusCode::NOT_FOUND, self.to_string())
} }

View File

@@ -0,0 +1,292 @@
//! CRUD over the `api_keys` table — backs the `Authorization: Bearer
//! pic_…` credential flow from blueprint §11.6.
//!
//! The repo never sees the raw token; only the 8-char `prefix` and the
//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split,
//! hash compute) lives in `api_keys_api.rs`. Verification logic
//! (prefix lookup + Argon2 verify per candidate) lives in
//! `auth_middleware.rs`. Both call this repo for the storage layer.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum ApiKeyRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("api key not found: {0}")]
NotFound(ApiKeyId),
#[error("invalid scope stored in DB: {0}")]
InvalidScope(String),
}
/// Insert payload — built by `api_keys_api` after generating the raw
/// token and hashing it. `hash` is an Argon2id PHC string covering the
/// body of the token (everything after `pic_`); `prefix` is the first
/// 8 chars of that body, indexed for fast candidate lookup.
#[derive(Debug, Clone)]
pub struct NewApiKey {
pub user_id: AdminUserId,
pub hash: String,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
}
/// Public-facing row — never exposes the hash. Used for `GET
/// /admin/api-keys` and the `POST` response (alongside the
/// one-shot raw token).
#[derive(Debug, Clone)]
pub struct ApiKeyRow {
pub id: ApiKeyId,
pub user_id: AdminUserId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
/// Verification candidate — includes the Argon2id `hash` and `user_id`
/// so middleware can verify the supplied token and assemble the
/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak
/// the hash through a careless `Json(row)`.
#[derive(Debug, Clone)]
pub struct ApiKeyVerification {
pub id: ApiKeyId,
pub user_id: AdminUserId,
pub hash: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
}
#[async_trait]
pub trait ApiKeyRepository: Send + Sync {
/// Mint. Caller has already hashed the raw token + computed prefix.
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError>;
/// Return every non-expired key with the given 8-char prefix. The
/// caller (middleware) Argon2-verifies the supplied token against
/// each candidate's `hash`. Returning a Vec rather than one row
/// keeps the contract correct even if two keys happen to share a
/// prefix (statistically near-zero but possible).
async fn find_active_by_prefix(
&self,
prefix: &str,
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError>;
/// Update `last_used_at` for an authenticated request. Inline (not
/// fire-and-forget) so a DB blip surfaces as a 500 rather than
/// silent stale timestamps.
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>;
/// Caller's own keys, for `GET /admin/api-keys`.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError>;
/// Look up a key by id — used by `DELETE` to verify ownership
/// before issuing the delete.
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError>;
/// Delete the row only if it belongs to `user_id`. Returns whether
/// a row was actually deleted (false = key didn't exist OR wasn't
/// theirs — handlers map both to 404 to avoid leaking the
/// distinction).
async fn delete_by_id_and_user(
&self,
id: ApiKeyId,
user_id: AdminUserId,
) -> Result<bool, ApiKeyRepositoryError>;
/// Set `expires_at = NOW()` on every active key for a user. Wired
/// into `set_active(false)` so deactivation invalidates both
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
/// and bearer keys at the same moment.
async fn expire_all_for_user(&self, user_id: AdminUserId)
-> Result<u64, ApiKeyRepositoryError>;
}
pub struct PostgresApiKeyRepository {
pool: PgPool,
}
impl PostgresApiKeyRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl ApiKeyRepository for PostgresApiKeyRepository {
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
let scope_strings: Vec<String> =
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
let row = sqlx::query_as::<_, ApiKeyRecord>(
"INSERT INTO api_keys \
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at",
)
.bind(key.user_id.into_inner())
.bind(&key.hash)
.bind(&key.prefix)
.bind(&key.name)
.bind(&scope_strings)
.bind(key.app_id.map(picloud_shared::AppId::into_inner))
.bind(key.expires_at)
.fetch_one(&self.pool)
.await?;
row.try_into()
}
async fn find_active_by_prefix(
&self,
prefix: &str,
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError> {
let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>(
"SELECT id, user_id, hash, scopes, app_id \
FROM api_keys \
WHERE prefix = $1 \
AND (expires_at IS NULL OR expires_at > NOW())",
)
.bind(prefix)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> {
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError> {
let rows = sqlx::query_as::<_, ApiKeyRecord>(
"SELECT id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at \
FROM api_keys WHERE user_id = $1 \
ORDER BY created_at DESC",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError> {
let row = sqlx::query_as::<_, ApiKeyRecord>(
"SELECT id, user_id, prefix, name, scopes, app_id, \
expires_at, last_used_at, created_at \
FROM api_keys WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn delete_by_id_and_user(
&self,
id: ApiKeyId,
user_id: AdminUserId,
) -> Result<bool, ApiKeyRepositoryError> {
let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2")
.bind(id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
async fn expire_all_for_user(
&self,
user_id: AdminUserId,
) -> Result<u64, ApiKeyRepositoryError> {
let res = sqlx::query(
"UPDATE api_keys \
SET expires_at = NOW() \
WHERE user_id = $1 \
AND (expires_at IS NULL OR expires_at > NOW())",
)
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
}
#[derive(sqlx::FromRow)]
struct ApiKeyRecord {
id: uuid::Uuid,
user_id: uuid::Uuid,
prefix: String,
name: String,
scopes: Vec<String>,
app_id: Option<uuid::Uuid>,
expires_at: Option<DateTime<Utc>>,
last_used_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
impl TryFrom<ApiKeyRecord> for ApiKeyRow {
type Error = ApiKeyRepositoryError;
fn try_from(r: ApiKeyRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
user_id: r.user_id.into(),
prefix: r.prefix,
name: r.name,
scopes: parse_scopes(r.scopes)?,
app_id: r.app_id.map(Into::into),
expires_at: r.expires_at,
last_used_at: r.last_used_at,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct ApiKeyVerifyRecord {
id: uuid::Uuid,
user_id: uuid::Uuid,
hash: String,
scopes: Vec<String>,
app_id: Option<uuid::Uuid>,
}
impl TryFrom<ApiKeyVerifyRecord> for ApiKeyVerification {
type Error = ApiKeyRepositoryError;
fn try_from(r: ApiKeyVerifyRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
user_id: r.user_id.into(),
hash: r.hash,
scopes: parse_scopes(r.scopes)?,
app_id: r.app_id.map(Into::into),
})
}
}
fn parse_scopes(raw: Vec<String>) -> Result<Vec<Scope>, ApiKeyRepositoryError> {
raw.into_iter()
.map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s)))
.collect()
}

View File

@@ -0,0 +1,251 @@
//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6).
//!
//! All endpoints are guarded by `require_authenticated`. Capability
//! checks: none — every authenticated user manages **their own** keys.
//! The repo enforces caller ownership on `delete`, and `list` is
//! scoped to the caller's user_id. No instance-level authority is
//! exposed (no listing other users' keys, no admin-issued keys for
//! another user — those flows belong with the invite system).
//!
//! Mint semantics:
//! * raw token is returned **exactly once** in the POST response and
//! never logged. Lose it = mint a new key.
//! * `app_id` (optional) binds the key to one app; capability checks
//! deny every `App*(other_app)`.
//! * scopes containing `instance:*` are rejected when `app_id` is
//! set — the combination is irreconcilable.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{ApiKeyId, AppId, Principal, Scope};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey};
use crate::auth::generate_api_key;
/// Validation bounds for the user-supplied `name` field — keeps the
/// dashboard's list view tidy and rejects accidental whole-token
/// pastes.
const NAME_MIN: usize = 1;
const NAME_MAX: usize = 64;
#[derive(Clone)]
pub struct ApiKeysState {
pub keys: Arc<dyn ApiKeyRepository>,
}
pub fn api_keys_router(state: ApiKeysState) -> Router {
Router::new()
.route("/api-keys", get(list_keys).post(mint_key))
.route("/api-keys/{id}", delete(delete_key))
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct MintApiKeyRequest {
pub name: String,
pub scopes: Vec<Scope>,
/// When set, the key is bound to this app — every `App*(other)`
/// capability is denied regardless of role.
#[serde(default)]
pub app_id: Option<AppId>,
/// When set, lookup rejects the key after this instant. Absent =
/// never expires (until explicit DELETE).
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
/// Response body for a freshly-minted key. `raw_token` only appears
/// here — `GET /api-keys` returns `ApiKeyDto` without it.
#[derive(Debug, Serialize)]
pub struct MintApiKeyResponse {
#[serde(flatten)]
pub key: ApiKeyDto,
/// The full wire-format token (`pic_<base32>`). Shown exactly once;
/// store it client-side immediately.
pub raw_token: String,
}
#[derive(Debug, Serialize)]
pub struct ApiKeyDto {
pub id: ApiKeyId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
impl From<ApiKeyRow> for ApiKeyDto {
fn from(r: ApiKeyRow) -> Self {
Self {
id: r.id,
prefix: r.prefix,
name: r.name,
scopes: r.scopes,
app_id: r.app_id,
expires_at: r.expires_at,
last_used_at: r.last_used_at,
created_at: r.created_at,
}
}
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn mint_key(
State(state): State<ApiKeysState>,
Extension(principal): Extension<Principal>,
Json(input): Json<MintApiKeyRequest>,
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
validate_name(&input.name)?;
validate_scopes(&input.scopes, input.app_id)?;
let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?;
let row = state
.keys
.create(NewApiKey {
user_id: principal.user_id,
hash: minted.hash,
prefix: minted.prefix,
name: input.name,
scopes: input.scopes,
app_id: input.app_id,
expires_at: input.expires_at,
})
.await?;
Ok((
StatusCode::CREATED,
Json(MintApiKeyResponse {
key: row.into(),
raw_token: minted.raw,
}),
))
}
async fn list_keys(
State(state): State<ApiKeysState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<ApiKeyDto>>, ApiKeysError> {
let rows = state.keys.list_for_user(principal.user_id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn delete_key(
State(state): State<ApiKeysState>,
Extension(principal): Extension<Principal>,
Path(id): Path<ApiKeyId>,
) -> Result<StatusCode, ApiKeysError> {
let deleted = state
.keys
.delete_by_id_and_user(id, principal.user_id)
.await?;
if !deleted {
// 404 covers both "doesn't exist" and "exists but not yours" —
// we deliberately don't leak the distinction.
return Err(ApiKeysError::NotFound(id));
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation
// ----------------------------------------------------------------------------
fn validate_name(s: &str) -> Result<(), ApiKeysError> {
let trimmed = s.trim();
if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX {
return Err(ApiKeysError::InvalidName(format!(
"name must be {NAME_MIN}-{NAME_MAX} characters after trimming"
)));
}
Ok(())
}
fn validate_scopes(scopes: &[Scope], app_id: Option<AppId>) -> Result<(), ApiKeysError> {
if scopes.is_empty() {
return Err(ApiKeysError::InvalidScopes(
"scopes must be non-empty".into(),
));
}
// Bound key + any instance:* scope → irreconcilable.
if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) {
return Err(ApiKeysError::InvalidScopes(
"bound keys (app_id set) cannot carry instance:* scopes".into(),
));
}
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum ApiKeysError {
#[error("api key not found: {0}")]
NotFound(ApiKeyId),
#[error("{0}")]
InvalidName(String),
#[error("{0}")]
InvalidScopes(String),
#[error("failed to hash key: {0}")]
Hash(String),
#[error("repository error: {0}")]
Repo(#[from] ApiKeyRepositoryError),
}
impl IntoResponse for ApiKeysError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::InvalidName(_) | Self::InvalidScopes(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Hash(_) => {
tracing::error!(error = %self, "api key hash failure");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ApiKeyRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string())
}
Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => {
tracing::error!(error = %self, "api key row carries an unknown scope");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ApiKeyRepositoryError::Db(e)) => {
tracing::error!(error = %e, "api_keys db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}

View File

@@ -0,0 +1,331 @@
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
//! `app_members` table (blueprint §11.6).
//!
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
//! resolving the app from `id_or_slug`. Editors and viewers receive
//! 403 from list and never see the dashboard's Members tab.
//!
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
//! user_id)` returns 409 rather than upsert-200, so the UI can show
//! "already a member — promote / demote them instead" cleanly. Role
//! changes go through PATCH.
//!
//! No last-app-admin guard: owners always implicitly satisfy
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
//! final explicit `app_admin` membership cannot orphan an app.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, patch};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
#[derive(Clone)]
pub struct AppMembersState {
pub apps: Arc<dyn AppRepository>,
pub users: Arc<dyn AdminUserRepository>,
pub members: Arc<dyn AppMembersRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn app_members_router(state: AppMembersState) -> Router {
Router::new()
.route(
"/apps/{id_or_slug}/members",
get(list_members).post(grant_member),
)
.route(
"/apps/{id_or_slug}/members/{user_id}",
patch(patch_member).delete(remove_member),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AppMemberDto {
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<AppMembershipDetail> for AppMemberDto {
fn from(d: AppMembershipDetail) -> 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,
}
}
}
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
/// POST/PATCH at the cost of trusting the two inputs reference the
/// same user_id — caller's responsibility.
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
AppMemberDto {
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,
}
}
#[derive(Debug, Deserialize)]
pub struct GrantMemberRequest {
pub user_id: AdminUserId,
pub role: AppRole,
}
#[derive(Debug, Deserialize)]
pub struct PatchMemberRequest {
pub role: AppRole,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_members(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let rows = s.members.list_for_app_enriched(app.id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn grant_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let user = s
.users
.get(input.user_id)
.await?
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
validate_grant_target(&user)?;
// Atomic insert — if a row already exists, returns None and we 409.
// Avoids the find-then-upsert race where two concurrent POSTs would
// both pass the existence check and the second `upsert` would
// silently rewrite the role.
let row = s
.members
.try_insert(app.id, user.id, input.role)
.await?
.ok_or_else(|| AppMembersApiError::AlreadyMember {
username: user.username.clone(),
})?;
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}
async fn patch_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
Json(input): Json<PatchMemberRequest>,
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let user_id = AdminUserId::from(user_id);
let user = s
.users
.get(user_id)
.await?
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
// Atomic update — returns None if no row exists, so 404 is decided
// by the same statement that does the write. Eliminates the
// find-then-upsert race where a concurrent DELETE between the two
// calls would let PATCH silently re-create the row.
let row = s
.members
.update_role(app.id, user_id, input.role)
.await?
.ok_or(AppMembersApiError::MembershipNotFound)?;
Ok(Json(compose_dto(user, row)))
}
async fn remove_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation + helpers
// ----------------------------------------------------------------------------
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
if !user.is_active {
return Err(AppMembersApiError::TargetInactive {
username: user.username.clone(),
});
}
if user.instance_role != InstanceRole::Member {
return Err(AppMembersApiError::TargetNotMember {
username: user.username.clone(),
instance_role: user.instance_role,
});
}
Ok(())
}
async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<picloud_shared::App, AppMembersApiError> {
crate::app_repo::resolve_app(apps, ident)
.await?
.map(|l| l.app)
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AppMembersApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("user not found: {0}")]
UserNotFound(AdminUserId),
#[error("no membership exists for this user on this app")]
MembershipNotFound,
#[error("{username} is already a member of this app — use PATCH to change their role")]
AlreadyMember { username: String },
#[error("{username} is deactivated and cannot be added as a member")]
TargetInactive { username: String },
#[error(
"{username} has instance_role {instance_role:?} and already has implicit access \
on every app — no explicit membership needed"
)]
TargetNotMember {
username: String,
instance_role: InstanceRole,
},
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
Members(#[from] AppMembersRepositoryError),
#[error("user repository error: {0}")]
Users(#[from] AdminUserRepositoryError),
#[error("repository error: {0}")]
Apps(#[from] ScriptRepositoryError),
}
impl From<AuthzDenied> for AppMembersApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for AppMembersApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::UserNotFound(_)
| Self::MembershipNotFound
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "app members authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Members(e) => {
tracing::error!(error = %e, "app members repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Users(e) => {
tracing::error!(error = %e, "admin users repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Apps(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "apps repo error in app_members");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,340 @@
//! CRUD over the `app_members` table — explicit per-(user, app) role
//! grants for `member` instance-role users. Owners and admins do NOT
//! appear here; their app authority is implicit (see authz.rs).
//!
//! Doubles as the production `AuthzRepo` implementation: the
//! membership lookup `can()` needs is the same single-row SELECT as
//! `find` here.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo};
#[derive(Debug, thiserror::Error)]
pub enum AppMembersRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("membership row not found: app={app_id}, user={user_id}")]
NotFound { app_id: AppId, user_id: AdminUserId },
#[error("invalid app_role stored in DB: {0}")]
InvalidRole(String),
}
/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
/// so handlers can render the cross-reference without joining to apps
/// or admin_users themselves.
#[derive(Debug, Clone)]
pub struct AppMembershipRow {
pub app_id: AppId,
pub user_id: AdminUserId,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
/// `app_members` row joined with `admin_users` so the dashboard's
/// Members tab can render usernames / emails / status without an N+1
/// fetch per row. Drives `GET /apps/{id}/members`.
#[derive(Debug, Clone)]
pub struct AppMembershipDetail {
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 AppMembersRepository: Send + Sync {
/// Single (user, app) lookup. Returns `None` for non-members and
/// for unrelated apps. This is the hot path for `authz::can`.
async fn find(
&self,
user_id: AdminUserId,
app_id: AppId,
) -> Result<Option<AppRole>, AppMembersRepositoryError>;
/// Upsert a membership. Used both for first-time grants and role
/// promotions/demotions on an existing row.
async fn upsert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
/// Atomic insert. Returns `Some(row)` on success, `None` if a
/// membership already exists. Lets the HTTP handler return 409
/// without a separate `find` round-trip (no TOCTOU between check
/// and insert).
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Atomic role update. Returns `Some(row)` on success, `None` if no
/// membership row exists. Lets PATCH return 404 without a separate
/// `find` round-trip (no TOCTOU between check and update).
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Remove a membership. No-op (Ok) when the row doesn't exist —
/// the user wasn't a member, which is the desired post-condition.
async fn remove(
&self,
app_id: AppId,
user_id: AdminUserId,
) -> Result<(), AppMembersRepositoryError>;
/// Every membership the user holds. Drives the membership-filtered
/// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
/// `member` callers).
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
/// Every membership on a given app. Used by `GET
/// /admin/apps/{id}/members` once that surface lands; included now
/// so the trait is complete enough for tests.
async fn list_for_app(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
/// Like `list_for_app` but joined with `admin_users` so the
/// dashboard can render member rows in one round-trip. Ordered by
/// username for a stable list.
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
}
pub struct PostgresAppMembersRepository {
pool: PgPool,
}
impl PostgresAppMembersRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppMembersRepository for PostgresAppMembersRepository {
async fn find(
&self,
user_id: AdminUserId,
app_id: AppId,
) -> Result<Option<AppRole>, AppMembersRepositoryError> {
let row: Option<(String,)> =
sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
.bind(user_id.into_inner())
.bind(app_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(|(role,)| {
AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
})
.transpose()
}
async fn upsert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<AppMembershipRow, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"INSERT INTO app_members (app_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
RETURNING app_id, user_id, role, created_at",
)
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_one(&self.pool)
.await?;
row.try_into()
}
async fn remove(
&self,
app_id: AppId,
user_id: AdminUserId,
) -> Result<(), AppMembersRepositoryError> {
sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"INSERT INTO app_members (app_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, user_id) DO NOTHING \
RETURNING app_id, user_id, role, created_at",
)
.bind(app_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,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"UPDATE app_members SET role = $1 \
WHERE app_id = $2 AND user_id = $3 \
RETURNING app_id, user_id, role, created_at",
)
.bind(role.as_str())
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipRecord>(
"SELECT app_id, user_id, role, created_at \
FROM app_members WHERE user_id = $1 \
ORDER BY created_at",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn list_for_app(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipRecord>(
"SELECT app_id, user_id, role, created_at \
FROM app_members WHERE app_id = $1 \
ORDER BY created_at",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
am.role, am.created_at \
FROM app_members am \
JOIN admin_users au ON au.id = am.user_id \
WHERE am.app_id = $1 \
ORDER BY au.username",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
/// it to `authz::can` without casting.
#[async_trait]
impl AuthzRepo for PostgresAppMembersRepository {
async fn membership(
&self,
user_id: AdminUserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
self.find(user_id, app_id)
.await
.map_err(|e| AuthzError::Repo(e.to_string()))
}
}
#[derive(sqlx::FromRow)]
struct AppMembershipRecord {
app_id: uuid::Uuid,
user_id: uuid::Uuid,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
type Error = AppMembersRepositoryError;
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
Ok(Self {
app_id: r.app_id.into(),
user_id: r.user_id.into(),
role: AppRole::from_db_str(&r.role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}
#[derive(sqlx::FromRow)]
struct AppMembershipDetailRecord {
id: uuid::Uuid,
username: String,
email: Option<String>,
instance_role: String,
is_active: bool,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
type Error = AppMembersRepositoryError;
fn try_from(r: AppMembershipDetailRecord) -> 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(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
is_active: r.is_active,
role: AppRole::from_db_str(&r.role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}

View File

@@ -6,8 +6,9 @@
//! that writes the history row in the same transaction. //! that writes the history row in the same transaction.
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{App, AppId}; use picloud_shared::{AdminUserId, App, AppId};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid;
use crate::repo::ScriptRepositoryError; use crate::repo::ScriptRepositoryError;
@@ -20,9 +21,40 @@ pub struct AppLookup {
pub redirected: bool, pub redirected: bool,
} }
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
/// fall through to `app_slug_history` and set `redirected: true` when
/// they hit it.
///
/// Returns `Ok(None)` when nothing matches — callers map that to their
/// own not-found error variant.
///
/// # Errors
/// Propagates any underlying repository error.
pub async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
if let Ok(uuid) = ident.parse::<Uuid>() {
return Ok(apps
.get_by_id(AppId::from(uuid))
.await?
.map(|app| AppLookup {
app,
redirected: false,
}));
}
apps.get_by_slug_or_history(ident).await
}
#[async_trait] #[async_trait]
pub trait AppRepository: Send + Sync { pub trait AppRepository: Send + Sync {
/// Every app on the instance. For owner/admin callers — `member`
/// users go through `list_for_user`.
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>; async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
/// 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>;
async fn get_by_id(&self, id: AppId) -> Result<Option<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(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history( async fn get_by_slug_or_history(
@@ -92,6 +124,20 @@ impl AppRepository for PostgresAppRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
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 \
FROM apps a \
JOIN app_members m ON m.app_id = a.id \
WHERE m.user_id = $1 \
ORDER BY a.name",
)
.bind(user_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> { async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \ "SELECT id, slug, name, description, created_at, updated_at \

View File

@@ -15,15 +15,16 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
use axum::Router; use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain}; use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId}; use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain}; use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository; use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError; use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository; use crate::route_repo::RouteRepository;
@@ -41,6 +42,8 @@ pub struct AppsState {
/// Cached host → app_id lookup; replaced after every domain CRUD /// Cached host → app_id lookup; replaced after every domain CRUD
/// operation so the orchestrator sees changes immediately. /// operation so the orchestrator sees changes immediately.
pub domain_table: Arc<AppDomainTable>, pub domain_table: Arc<AppDomainTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
} }
pub fn apps_router(state: AppsState) -> Router { pub fn apps_router(state: AppsState) -> Router {
@@ -138,20 +141,39 @@ pub struct AppLookupResponse {
/// at the live slug so dashboards can redirect. /// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>, pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` and `Admin` both map to `app_admin` (implicit per
/// blueprint §11.6); `Member` carries its explicit
/// `app_members.role`.
pub my_role: Option<AppRole>,
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Handlers // Handlers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> { async fn list_apps(
Ok(Json(s.apps.list().await?)) State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<App>>, AppsApiError> {
// Member callers see only apps they're a member of; owner/admin
// see everything. Filter at the SQL layer (not just in the
// dashboard) — that's the strict-isolation guarantee from §11.6.
let apps = if principal.instance_role == InstanceRole::Member {
s.apps.list_for_user(principal.user_id).await?
} else {
s.apps.list().await?
};
Ok(Json(apps))
} }
async fn create_app( async fn create_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAppRequest>, Json(input): Json<CreateAppRequest>,
) -> Result<(StatusCode, Json<App>), AppsApiError> { ) -> Result<(StatusCode, Json<App>), AppsApiError> {
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
validate_slug(&input.slug)?; validate_slug(&input.slug)?;
// Historical-slug check before insert: if the slug is in history // Historical-slug check before insert: if the slug is in history
@@ -178,26 +200,58 @@ async fn create_app(
async fn get_app( async fn get_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
) -> Result<Json<AppLookupResponse>, AppsApiError> { ) -> Result<Json<AppLookupResponse>, AppsApiError> {
let lookup = resolve_app(&*s.apps, &id_or_slug).await?; let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppRead(lookup.app.id),
)
.await?;
let redirect_to = if lookup.redirected { let redirect_to = if lookup.redirected {
Some(lookup.app.slug.clone()) Some(lookup.app.slug.clone())
} else { } else {
None None
}; };
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
Ok(Json(AppLookupResponse { Ok(Json(AppLookupResponse {
app: lookup.app, app: lookup.app,
redirect_to, redirect_to,
my_role,
})) }))
} }
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
/// consults `app_members`.
async fn compute_my_role(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> 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?),
}
}
async fn patch_app( async fn patch_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Json(input): Json<PatchAppRequest>, Json(input): Json<PatchAppRequest>,
) -> Result<Json<App>, AppsApiError> { ) -> Result<Json<App>, AppsApiError> {
let current = resolve_app(&*s.apps, &id_or_slug).await?.app; let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(
s.authz.as_ref(),
&principal,
Capability::AppAdmin(current.id),
)
.await?;
// Edits to name/description go first (separate from rename so we // Edits to name/description go first (separate from rename so we
// don't conflate the two errors). // don't conflate the two errors).
@@ -240,10 +294,12 @@ async fn patch_app(
async fn delete_app( async fn delete_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Query(q): Query<DeleteAppQuery>, Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> { ) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
if q.force { if q.force {
s.apps.delete_cascade(app.id).await?; s.apps.delete_cascade(app.id).await?;
@@ -262,9 +318,12 @@ async fn delete_app(
async fn slug_check( async fn slug_check(
State(s): State<AppsState>, State(s): State<AppsState>,
Path(_id_or_slug): Path<String>, Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SlugCheckRequest>, Json(input): Json<SlugCheckRequest>,
) -> Result<Json<SlugCheckResponse>, AppsApiError> { ) -> Result<Json<SlugCheckResponse>, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
match validate_slug(&input.new_slug) { match validate_slug(&input.new_slug) {
Err(AppsApiError::InvalidSlug(reason)) => { Err(AppsApiError::InvalidSlug(reason)) => {
return Ok(Json(SlugCheckResponse { return Ok(Json(SlugCheckResponse {
@@ -303,18 +362,27 @@ async fn slug_check(
async fn list_domains( async fn list_domains(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppDomain>>, AppsApiError> { ) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
Ok(Json(s.domains.list_for_app(app.id).await?)) Ok(Json(s.domains.list_for_app(app.id).await?))
} }
async fn create_domain( async fn create_domain(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateDomainRequest>, Json(input): Json<CreateDomainRequest>,
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> { ) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageDomains(app.id),
)
.await?;
let parsed = pattern::parse_app_domain(&input.pattern)?; let parsed = pattern::parse_app_domain(&input.pattern)?;
let created = s let created = s
.domains .domains
@@ -331,9 +399,16 @@ async fn create_domain(
async fn delete_domain( async fn delete_domain(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, domain_id)): Path<(String, Uuid)>, Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppsApiError> { ) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageDomains(app.id),
)
.await?;
let Some(domain) = s.domains.get(domain_id).await? else { let Some(domain) = s.domains.get(domain_id).await? else {
return Err(AppsApiError::DomainNotFound(domain_id)); return Err(AppsApiError::DomainNotFound(domain_id));
}; };
@@ -378,16 +453,7 @@ async fn resolve_app(
apps: &dyn AppRepository, apps: &dyn AppRepository,
ident: &str, ident: &str,
) -> Result<crate::app_repo::AppLookup, AppsApiError> { ) -> Result<crate::app_repo::AppLookup, AppsApiError> {
if let Ok(uuid) = ident.parse::<Uuid>() { crate::app_repo::resolve_app(apps, ident)
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
return Ok(crate::app_repo::AppLookup {
app,
redirected: false,
});
}
return Err(AppsApiError::AppNotFound(ident.to_string()));
}
apps.get_by_slug_or_history(ident)
.await? .await?
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string())) .ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
} }
@@ -476,10 +542,31 @@ pub enum AppsApiError {
#[error("conflict: {0}")] #[error("conflict: {0}")]
Conflict(String), Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
impl From<AuthzDenied> for AppsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for AppsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for AppsApiError { impl IntoResponse for AppsApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {
@@ -511,6 +598,14 @@ impl IntoResponse for AppsApiError {
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => { Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() })) (StatusCode::CONFLICT, json!({ "error": self.to_string() }))
} }
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "apps authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Repo(ScriptRepositoryError::Db(e)) => { Self::Repo(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "apps api db error"); tracing::error!(error = %e, "apps api db error");
( (

View File

@@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt
use argon2::Argon2; use argon2::Argon2;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _; use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
out out
} }
// ----------------------------------------------------------------------------
// API key generation (Phase 3.5)
// ----------------------------------------------------------------------------
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
/// generator and the verifier agree.
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion (the first 8 chars of the
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
///
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
/// caller in the response body and never persisted.
/// * `prefix` is the indexed 8-char slice persisted to
/// `api_keys.prefix` for lookup.
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
/// covers the body after `pic_` (i.e., `raw[4..]`).
pub struct GeneratedApiKey {
pub raw: String,
pub prefix: String,
pub hash: String,
}
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
/// key; everything after `pic_` is what the verifier hashes.
///
/// # Errors
///
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
/// fails (which it shouldn't under normal conditions).
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let body = BASE32_NOPAD.encode(&bytes);
debug_assert!(
body.len() >= API_KEY_INDEX_PREFIX_LEN,
"32 bytes base32 must exceed the 8-char prefix length"
);
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
let salt = SaltString::generate(&mut ArgonRng);
let hash = Argon2::default()
.hash_password(body.as_bytes(), &salt)?
.to_string();
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
Ok(GeneratedApiKey { raw, prefix, hash })
}
/// Verify a wire-format token body (the portion *after* `pic_`)
/// against a stored Argon2id hash. Convenience wrapper around
/// `verify_password` named to reflect its caller.
#[must_use]
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
verify_password(stored_hash, presented_body)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -129,4 +190,42 @@ mod tests {
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible"); assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars"); assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
} }
#[test]
fn generate_api_key_round_trip() {
let key = generate_api_key().expect("mint");
assert!(
key.raw.starts_with(API_KEY_WIRE_PREFIX),
"raw must carry the pic_ prefix"
);
let body = key
.raw
.strip_prefix(API_KEY_WIRE_PREFIX)
.expect("starts with prefix");
assert_eq!(
&body[..API_KEY_INDEX_PREFIX_LEN],
key.prefix,
"stored prefix matches the first 8 chars of the body"
);
assert!(
verify_api_key(&key.hash, body),
"Argon2 verify must accept the original body"
);
assert!(
!verify_api_key(&key.hash, "wrong-body-entirely"),
"Argon2 verify must reject anything else"
);
}
#[test]
fn generate_api_key_unique() {
let a = generate_api_key().expect("mint a");
let b = generate_api_key().expect("mint b");
assert_ne!(a.raw, b.raw);
assert_ne!(a.hash, b.hash);
assert_ne!(
a.prefix, b.prefix,
"32 random bytes → prefix collision is negligible"
);
}
} }

View File

@@ -18,12 +18,14 @@ use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use chrono::{DateTime, Duration as ChronoDuration, Utc}; use chrono::{DateTime, Duration as ChronoDuration, Utc};
use picloud_shared::AdminUserId; use picloud_shared::{AdminUserId, InstanceRole};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use picloud_shared::Principal;
use crate::auth::{generate_session_token, hash_token, verify_password}; use crate::auth::{generate_session_token, hash_token, verify_password};
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
pub fn auth_router(state: AuthState) -> Router { pub fn auth_router(state: AuthState) -> Router {
// /login + /logout are unguarded (login is how you get in; logout // /login + /logout are unguarded (login is how you get in; logout
@@ -31,7 +33,7 @@ pub fn auth_router(state: AuthState) -> Router {
// who you are, so the middleware must run first. // who you are, so the middleware must run first.
let guarded = Router::new() let guarded = Router::new()
.route("/auth/me", get(me)) .route("/auth/me", get(me))
.route_layer(from_fn_with_state(state.clone(), require_admin)); .route_layer(from_fn_with_state(state.clone(), require_authenticated));
Router::new() Router::new()
.route("/auth/login", post(login)) .route("/auth/login", post(login))
@@ -61,6 +63,8 @@ pub struct LoginResponse {
pub struct AdminUserDto { pub struct AdminUserDto {
pub id: AdminUserId, pub id: AdminUserId,
pub username: String, pub username: String,
pub instance_role: InstanceRole,
pub email: Option<String>,
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -85,9 +89,11 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
} }
}; };
let (stored_hash, user_id, username, is_active) = match creds { // username from creds is discarded — the re-fetch below carries the
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active), // canonical row used in the response DTO.
None => (DUMMY_HASH.to_string(), None, String::new(), false), let (stored_hash, user_id, is_active) = match creds {
Some(c) => (c.password_hash, Some(c.id), c.is_active),
None => (DUMMY_HASH.to_string(), None, false),
}; };
let password_ok = verify_password(&stored_hash, &input.password); let password_ok = verify_password(&stored_hash, &input.password);
@@ -96,6 +102,18 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
} }
let user_id = user_id.unwrap(); let user_id = user_id.unwrap();
// Re-fetch the full row so the login response carries the same
// shape /me does (instance_role, email). The credentials struct
// intentionally omits email; one extra query per login is fine.
let user_row = match state.users.get(user_id).await {
Ok(Some(row)) => row,
Ok(None) => return invalid_credentials(),
Err(err) => {
tracing::error!(?err, "admin_users lookup after login failed");
return internal_error();
}
};
let token = generate_session_token(); let token = generate_session_token();
let expires_at = Utc::now() let expires_at = Utc::now()
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24)); + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
@@ -128,8 +146,10 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
headers, headers,
Json(LoginResponse { Json(LoginResponse {
user: AdminUserDto { user: AdminUserDto {
id: user_id, id: user_row.id,
username, username: user_row.username,
instance_role: user_row.instance_role,
email: user_row.email,
}, },
token: token.raw, token: token.raw,
expires_at, expires_at,
@@ -158,11 +178,27 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
(StatusCode::NO_CONTENT, headers).into_response() (StatusCode::NO_CONTENT, headers).into_response()
} }
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> { async fn me(
Json(AdminUserDto { State(state): State<AuthState>,
id: admin.id, Extension(principal): Extension<Principal>,
username: admin.username, ) -> Response {
}) // /me consumes the resolved Principal directly; we re-fetch the
// user row only to surface a fresh username (it can change via
// PATCH while a session/key is still valid).
match state.users.get(principal.user_id).await {
Ok(Some(row)) => Json(AdminUserDto {
id: row.id,
username: row.username,
instance_role: row.instance_role,
email: row.email,
})
.into_response(),
Ok(None) => invalid_credentials(),
Err(err) => {
tracing::error!(?err, "admin_users lookup for /me failed");
internal_error()
}
}
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -116,7 +116,16 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
(None, None) => return Err(BootstrapError::MissingPassword), (None, None) => return Err(BootstrapError::MissingPassword),
}; };
repo.create(&username, &password_hash).await?; // Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
// first row to full instance control. Subsequent admins minted via
// the API default to Admin and can be promoted explicitly.
repo.create(
&username,
&password_hash,
picloud_shared::InstanceRole::Owner,
None,
)
.await?;
info!(username = %username, "bootstrapped initial admin user"); info!(username = %username, "bootstrapped initial admin user");
Ok(()) Ok(())
} }
@@ -130,7 +139,7 @@ mod tests {
use super::*; use super::*;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use picloud_shared::AdminUserId; use picloud_shared::{AdminUserId, InstanceRole};
use std::sync::Mutex; use std::sync::Mutex;
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow}; use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
@@ -167,11 +176,15 @@ mod tests {
&self, &self,
username: &str, username: &str,
_password_hash: &str, _password_hash: &str,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> { ) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = AdminUserRow { let row = AdminUserRow {
id: AdminUserId::new(), id: AdminUserId::new(),
username: username.to_string(), username: username.to_string(),
is_active: true, is_active: true,
instance_role,
email: email.map(str::to_string),
created_at: Utc::now(), created_at: Utc::now(),
updated_at: Utc::now(), updated_at: Utc::now(),
last_login_at: None, last_login_at: None,
@@ -193,6 +206,20 @@ mod tests {
) -> Result<AdminUserRow, AdminUserRepositoryError> { ) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!() unimplemented!()
} }
async fn update_email(
&self,
_i: AdminUserId,
_e: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_instance_role(
&self,
_i: AdminUserId,
_r: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn set_active( async fn set_active(
&self, &self,
_i: AdminUserId, _i: AdminUserId,
@@ -215,6 +242,15 @@ mod tests {
) -> Result<i64, AdminUserRepositoryError> { ) -> Result<i64, AdminUserRepositoryError> {
unimplemented!() unimplemented!()
} }
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn count_other_active_owners(
&self,
_i: AdminUserId,
) -> Result<i64, AdminUserRepositoryError> {
unimplemented!()
}
} }
#[tokio::test] #[tokio::test]
@@ -245,7 +281,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn populated_db_is_noop() { async fn populated_db_is_noop() {
let repo = InMemoryRepo::default(); let repo = InMemoryRepo::default();
repo.create("seeded", "x").await.unwrap(); repo.create("seeded", "x", InstanceRole::Owner, None)
.await
.unwrap();
let env = BootstrapEnv { let env = BootstrapEnv {
username: Some("alice".into()), username: Some("alice".into()),
password: Some("supersecret".into()), password: Some("supersecret".into()),

View File

@@ -1,12 +1,17 @@
//! `require_admin` axum middleware: gates a router on a valid admin //! Authentication middleware — resolves the caller's `Principal` from
//! session. Accepts the token from either the `picloud_session` cookie //! either a session cookie / Bearer session-token OR an API key
//! or an `Authorization: Bearer …` header — same token system serves //! (`Authorization: Bearer pic_…`). Both paths converge on the same
//! the dashboard and CLI/CI clients. //! request extension so downstream handlers see one shape.
//! //!
//! On success, injects `AuthedAdmin` as a request extension so handlers //! Capability checks live in `crate::authz` and are called per-handler
//! can `Extension<AuthedAdmin>` to know who's calling. On failure, //! (after the relevant resource is loaded, so the capability binds to
//! returns 401 with a generic JSON body (no enumeration about whether //! the actual resource's `app_id`). This middleware is gate-only: it
//! the token was wrong vs. the user was deactivated). //! ensures *some* `Principal` is attached, or returns 401.
//!
//! Token discriminator: the `pic_` prefix on a Bearer value selects
//! the API-key path; anything else (raw 32-byte base64-url-encoded
//! string) takes the session path. The session cookie can only ever
//! carry a session token (cookies are never API keys).
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use chrono::Utc; use chrono::Utc;
use picloud_shared::AdminUserId; use picloud_shared::{AdminUserId, Principal};
use serde_json::json; use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository; use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::AdminUserRepository; use crate::admin_user_repo::AdminUserRepository;
use crate::auth::hash_token; use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
use crate::auth::{hash_token, verify_password};
pub const SESSION_COOKIE: &str = "picloud_session"; pub const SESSION_COOKIE: &str = "picloud_session";
/// Shared state for auth: the two repos plus the configured sliding /// Prefix on the wire that selects the API-key path. The body that
/// session TTL. Cheap to clone (`Arc` everywhere). /// follows is `base32(32 random bytes)`; the first 8 chars of the body
/// index into `api_keys.prefix` for verification.
pub const API_KEY_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion of an API key (the 8 chars
/// immediately after `pic_`). Schema-side index is on this slice.
pub const API_KEY_PREFIX_LEN: usize = 8;
/// Shared state for auth: the user / session / API-key repos plus the
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
#[derive(Clone)] #[derive(Clone)]
pub struct AuthState { pub struct AuthState {
pub users: Arc<dyn AdminUserRepository>, pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>, pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration, pub ttl: Duration,
} }
/// Request-extension type that authenticated handlers extract via /// Legacy request-extension alias retained so the (only remaining)
/// `Extension<AuthedAdmin>`. Available only inside guarded routers. /// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
/// keeps compiling during the migration. New handlers should pull
/// `Extension<Principal>` directly.
#[deprecated(note = "use Extension<Principal> directly")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AuthedAdmin { pub struct AuthedAdmin {
pub id: AdminUserId, pub id: AdminUserId,
pub username: String, pub username: String,
} }
/// Middleware function. Wire with /// Middleware entry point. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`. /// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
pub async fn require_admin( /// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
/// extensions on success; returns 401 on any failure mode.
pub async fn require_authenticated(
State(state): State<AuthState>, State(state): State<AuthState>,
mut req: Request<Body>, mut req: Request<Body>,
next: Next, next: Next,
@@ -53,48 +74,162 @@ pub async fn require_admin(
let Some(token) = extract_token(&req) else { let Some(token) = extract_token(&req) else {
return unauthorized(); return unauthorized();
}; };
let token_hash = hash_token(&token); let principal = match resolve_principal(&state, &token).await {
Ok(Some(p)) => p,
Ok(None) => return unauthorized(),
Err(InternalError) => return internal_error(),
};
let username_for_legacy = username_for(&state, principal.user_id).await;
req.extensions_mut().insert(principal.clone());
#[allow(deprecated)]
if let Some(username) = username_for_legacy {
req.extensions_mut().insert(AuthedAdmin {
id: principal.user_id,
username,
});
}
next.run(req).await
}
/// Backwards-compatible alias — the single callsite that still names
/// `require_admin` keeps working without an immediate rename. New
/// wiring should call `require_authenticated`.
#[deprecated(note = "renamed to require_authenticated")]
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
require_authenticated(state, req, next).await
}
/// Decide whether the token is an API key (pic_ prefix) or a session
/// token, then resolve the corresponding `Principal`. `Ok(None)`
/// means the token was structurally valid but didn't match any active
/// credential; `Err(InternalError)` means a DB blip.
async fn resolve_principal(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
return verify_api_key(state, rest).await;
}
verify_session(state, token).await
}
async fn verify_session(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
let token_hash = hash_token(token);
let lookup = match state.sessions.lookup(&token_hash).await { let lookup = match state.sessions.lookup(&token_hash).await {
Ok(Some(lookup)) => lookup, Ok(Some(l)) => l,
Ok(None) => return unauthorized(), Ok(None) => return Ok(None),
Err(err) => { Err(err) => {
tracing::error!(?err, "admin_sessions lookup failed"); tracing::error!(?err, "admin_sessions lookup failed");
return internal_error(); return Err(InternalError);
} }
}; };
// Resolve the user. A deleted user is impossible here (FK cascade
// wipes their sessions), but a deactivated user still needs to be
// rejected — and so does the edge case of a session predating the
// deactivate (we wipe their sessions on deactivate, but a race
// could land a request in flight).
let user = match state.users.get(lookup.user_id).await { let user = match state.users.get(lookup.user_id).await {
Ok(Some(u)) if u.is_active => u, Ok(Some(u)) if u.is_active => u,
Ok(_) => return unauthorized(), Ok(_) => return Ok(None),
Err(err) => { Err(err) => {
tracing::error!(?err, "admin_users lookup failed"); tracing::error!(?err, "admin_users lookup failed");
return internal_error(); return Err(InternalError);
} }
}; };
// Sliding window bump. Inline (not fire-and-forget) so a DB blip // Sliding-window bump — inline so a DB blip surfaces as 500 rather
// surfaces as a request error rather than silent stale sessions. // than silent stale sessions. Same shape as Phase 3a.
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default(); let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await { if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
tracing::error!(?err, "admin_sessions touch failed"); tracing::error!(?err, "admin_sessions touch failed");
return internal_error(); return Err(InternalError);
} }
req.extensions_mut().insert(AuthedAdmin { Ok(Some(Principal {
id: user.id, user_id: user.id,
username: user.username, instance_role: user.instance_role,
}); scopes: None,
next.run(req).await app_binding: None,
}))
}
/// API-key verification path. `rest` is the portion of the bearer
/// value *after* `pic_`. We slice off the first 8 chars as the
/// indexed lookup key, then Argon2id-verify each candidate's hash
/// against the full `rest`. At most one match is expected; multiple
/// candidates with the same prefix is statistically negligible but
/// handled correctly (verify each, take the first match).
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
if rest.len() <= API_KEY_PREFIX_LEN {
return Ok(None);
}
let prefix = &rest[..API_KEY_PREFIX_LEN];
let candidates = match state.keys.find_active_by_prefix(prefix).await {
Ok(v) => v,
Err(err) => {
tracing::error!(?err, "api_keys lookup failed");
return Err(InternalError);
}
};
let matched: Option<ApiKeyVerification> = candidates
.into_iter()
.find(|c| verify_password(&c.hash, rest));
let Some(matched) = matched else {
return Ok(None);
};
// Resolve the owning user. is_active = false → reject even if the
// key itself hasn't been expired yet (the expire_all_for_user
// cascade on deactivation is the primary defense; this is the
// belt-and-suspenders check at request time).
let user = match state.users.get(matched.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup for api key failed");
return Err(InternalError);
}
};
if let Err(err) = state.keys.touch_last_used(matched.id).await {
tracing::error!(?err, "api_keys touch_last_used failed");
// Soft-fail: a timestamp blip should not invalidate the
// request. Continue with the resolved Principal.
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: Some(matched.scopes),
app_binding: matched.app_id,
}))
}
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
/// Returns `None` on DB error (the caller treats `None` as "skip the
/// legacy extension"). New handlers use `Principal` and don't depend
/// on this.
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
match state.users.get(id).await {
Ok(Some(u)) => Some(u.username),
Ok(None) => None,
Err(err) => {
tracing::warn!(
?err,
"username lookup for AuthedAdmin failed; skipping legacy ext"
);
None
}
}
} }
/// Pull the bearer token out of an `Authorization` header (preferred) /// Pull the bearer token out of an `Authorization` header (preferred)
/// or the `picloud_session` cookie (fallback for browser clients). /// or the `picloud_session` cookie (fallback for browser clients).
/// Same shape as Phase 3a; the cookie only ever carries session
/// tokens — no `pic_` prefix expected there.
fn extract_token(req: &Request<Body>) -> Option<String> { fn extract_token(req: &Request<Body>) -> Option<String> {
if let Some(value) = req.headers().get(header::AUTHORIZATION) { if let Some(value) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = value.to_str() { if let Ok(s) = value.to_str() {
@@ -121,6 +256,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
None None
} }
/// Sentinel returned from the resolve functions when a DB error should
/// produce a 500 rather than a 401. Empty struct because the actual
/// error is already logged at the failure site.
struct InternalError;
fn unauthorized() -> Response { fn unauthorized() -> Response {
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
@@ -141,6 +281,7 @@ fn internal_error() -> Response {
mod tests { mod tests {
use super::*; use super::*;
use axum::http::Request; use axum::http::Request;
use picloud_shared::InstanceRole;
fn req_with_header(name: &str, value: &str) -> Request<Body> { fn req_with_header(name: &str, value: &str) -> Request<Body> {
Request::builder() Request::builder()
@@ -155,6 +296,12 @@ mod tests {
assert_eq!(extract_token(&r).as_deref(), Some("abc123")); assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
} }
#[test]
fn extracts_bearer_pic_prefixed_token() {
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
}
#[test] #[test]
fn ignores_bearer_with_no_token() { fn ignores_bearer_with_no_token() {
let r = req_with_header("authorization", "Bearer "); let r = req_with_header("authorization", "Bearer ");
@@ -182,4 +329,20 @@ mod tests {
let r = Request::builder().body(Body::empty()).unwrap(); let r = Request::builder().body(Body::empty()).unwrap();
assert_eq!(extract_token(&r), None); assert_eq!(extract_token(&r), None);
} }
// Round-trip test for the unused-variable to keep `Principal`
// visibly tied to InstanceRole — caught a real bug during dev when
// the field order in the struct literal had drifted.
#[test]
fn principal_construction_is_explicit() {
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
};
assert_eq!(p.instance_role, InstanceRole::Owner);
assert!(p.scopes.is_none());
assert!(p.app_binding.is_none());
}
} }

View File

@@ -0,0 +1,614 @@
//! Capability-based authorization — see blueprint §11.6.
//!
//! Single entry point for every admin endpoint: `can(repo, principal,
//! capability)` returns whether the caller can perform the action.
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
//! after loading the resource so the capability binds to the resource's
//! actual `app_id`, not a path param the caller controls.
//!
//! Three layers of intersection, evaluated in order:
//!
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
//! `app_members` row authorize this capability?
//! 2. **Scope intersection** — if the principal came from an API key
//! (`principal.scopes.is_some()`), does the key's scope set cover
//! the capability's required scope?
//! 3. **App binding** — if the key was minted bound to a specific
//! app (`principal.app_binding`), does the capability target the
//! same app? (Instance-level capabilities are denied for bound
//! keys; the mint handler also rejects the combination upfront.)
//!
//! The capability set is intentionally finer-grained than the seven
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
//! the `script:write` / `route:write` scopes respectively). Keeping
//! capabilities precise lets a `script:write`-only key write scripts
//! without also being able to mutate routes. The scope set stays at
//! seven values — capabilities are the internal check, scopes are the
//! external user-facing label.
use async_trait::async_trait;
use picloud_shared::{AppId, AppRole, 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
/// it from the loaded resource (e.g., `script.app_id`), not from a
/// path param.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
/// Create a new app. Owner / admin only.
InstanceCreateApp,
/// Create / update / delete admin_users rows (other than self
/// password change, which is a separate flow). Owner / admin.
InstanceManageUsers,
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
/// Owner only.
InstanceManageSettings,
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
/// (member); implicit for admin / owner.
AppRead(AppId),
/// Create / update / delete a script in this app.
AppWriteScript(AppId),
/// Create / update / delete a route in this app.
AppWriteRoute(AppId),
/// Manage domain claims on this app (add / remove).
AppManageDomains(AppId),
/// App settings + delete app. app_admin only (or owner via
/// implicit grant).
AppAdmin(AppId),
/// Read execution logs for scripts in this app.
AppLogRead(AppId),
}
impl Capability {
/// Extract the `AppId` for app-scoped capabilities; `None` for
/// instance-scoped ones. Used by the app-binding check on API keys.
#[must_use]
pub const fn app_id(self) -> Option<AppId> {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
None
}
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
| Self::AppManageDomains(id)
| Self::AppAdmin(id)
| Self::AppLogRead(id) => Some(id),
}
}
/// The single scope that authorizes this capability on an API key.
/// Strict mapping — a `script:write` key cannot read scripts unless
/// it also carries `script:read`. The intent is predictability: a
/// key has exactly the scopes it was minted with, no implicit
/// upgrades.
#[must_use]
pub const fn required_scope(self) -> Scope {
match self {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin
}
Self::AppRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_) => Scope::AppAdmin,
Self::AppLogRead(_) => Scope::LogRead,
}
}
}
/// Repo seam for membership lookups. Implemented in the DB-backed
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
/// means unit tests can stub it.
#[async_trait]
pub trait AuthzRepo: Send + Sync {
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError>;
}
/// Repo errors surface here so handlers can map them to 500 without
/// dragging sqlx types across the boundary.
#[derive(Debug, thiserror::Error)]
pub enum AuthzError {
#[error("authorization repo error: {0}")]
Repo(String),
}
/// Decision flavor returned by `can` — distinguishes outright denial
/// from a partial answer that requires further checks (none today,
/// but the shape lets us add audit/explain mode later without rewriting
/// every caller).
#[must_use = "an authorization decision must be acted on"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
}
impl Decision {
#[must_use]
pub const fn is_allow(self) -> bool {
matches!(self, Self::Allow)
}
}
/// Core authorization check. Walks the three intersection layers in
/// order and returns the resulting `Decision`.
pub async fn can(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<Decision, AuthzError> {
if !role_grants(repo, principal, cap).await? {
return Ok(Decision::Deny);
}
if !scope_allows(principal, cap) {
return Ok(Decision::Deny);
}
if !binding_allows(principal, cap) {
return Ok(Decision::Deny);
}
Ok(Decision::Allow)
}
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
/// Handlers call this so the `?` operator threads the 403 through
/// naturally.
///
/// # Errors
///
/// Returns `AuthzDenied::Denied` when the capability is not granted,
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
pub async fn require(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<(), AuthzDenied> {
match can(repo, principal, cap).await {
Ok(Decision::Allow) => Ok(()),
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
Err(e) => Err(AuthzDenied::Repo(e)),
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthzDenied {
#[error("forbidden")]
Denied,
#[error(transparent)]
Repo(#[from] AuthzError),
}
// ----------------------------------------------------------------------------
// Layer 1: role-derived grant
// ----------------------------------------------------------------------------
async fn role_grants(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
) -> Result<bool, AuthzError> {
match principal.instance_role {
InstanceRole::Owner => Ok(true),
InstanceRole::Admin => Ok(admin_grants(cap)),
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
}
}
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
!matches!(cap, Capability::InstanceManageSettings)
}
/// Member has zero instance authority. App authority requires an
/// explicit `app_members` row with sufficient `AppRole`.
async fn member_grants(
repo: &dyn AuthzRepo,
user_id: UserId,
cap: Capability,
) -> Result<bool, AuthzError> {
let Some(app_id) = cap.app_id() else {
return Ok(false);
};
let Some(role) = repo.membership(user_id, app_id).await? else {
return Ok(false);
};
Ok(role_satisfies(role, cap))
}
/// 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
/// the check is "is this capability in the role's set?".
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_));
let in_editor = in_viewer
|| matches!(
cap,
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
);
let in_app_admin = in_editor
|| matches!(
cap,
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
);
match role {
AppRole::Viewer => in_viewer,
AppRole::Editor => in_editor,
AppRole::AppAdmin => in_app_admin,
}
}
// ----------------------------------------------------------------------------
// Layer 2: API-key scope intersection
// ----------------------------------------------------------------------------
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
match &principal.scopes {
None => true, // cookie session — full role authority
Some(scopes) => scopes.contains(&cap.required_scope()),
}
}
// ----------------------------------------------------------------------------
// Layer 3: API-key app binding
// ----------------------------------------------------------------------------
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
let Some(bound_app) = principal.app_binding else {
return true;
};
match cap.app_id() {
// Instance-scoped capability + bound key → always denied. The
// mint handler also rejects this combination upfront, but
// defending in depth here means a stale/malformed row can't
// escalate.
None => false,
Some(target_app) => target_app == bound_app,
}
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use picloud_shared::{AdminUserId, AppId};
use std::collections::HashMap;
use tokio::sync::Mutex;
/// In-memory `AuthzRepo` so the unit tests don't need a database.
#[derive(Default)]
struct InMemoryAuthzRepo {
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
}
impl InMemoryAuthzRepo {
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
self.memberships.lock().await.insert((user, app), role);
}
}
#[async_trait]
impl AuthzRepo for InMemoryAuthzRepo {
async fn membership(
&self,
user_id: UserId,
app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(self
.memberships
.lock()
.await
.get(&(user_id, app_id))
.copied())
}
}
fn principal(role: InstanceRole) -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: role,
scopes: None,
app_binding: None,
}
}
#[tokio::test]
async fn owner_can_do_everything() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Owner);
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"owner denied {cap:?}"
);
}
}
#[tokio::test]
async fn admin_cannot_manage_instance_settings() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn admin_is_implicit_app_admin_on_every_app() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
let app = AppId::new();
// Instance-scoped allowances.
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::InstanceManageUsers)
.await
.unwrap(),
Decision::Allow,
);
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"admin denied app-scoped capability {cap:?}"
);
}
}
#[tokio::test]
async fn member_without_row_is_denied_everywhere() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
assert_eq!(
can(&repo, &p, cap).await.unwrap(),
Decision::Deny,
"member granted {cap:?} without a membership row"
);
}
}
#[tokio::test]
async fn member_with_viewer_role_can_read_but_not_write() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Viewer).await;
assert!(can(&repo, &p, Capability::AppRead(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppLogRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Deny
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn member_with_editor_role_can_write_scripts_and_routes() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
/// (Delete). The script-delete handler gates on the latter so the
/// API can't be tricked into letting an editor remove the script
/// they were only allowed to edit.
#[tokio::test]
async fn editor_can_write_scripts_but_not_delete_them() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
// Delete is gated on AppAdmin in the handler — editors must be
// denied here for that gate to bite.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
assert!(can(&repo, &p, Capability::AppAdmin(app))
.await
.unwrap()
.is_allow());
assert!(can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap()
.is_allow());
// Membership in App A does NOT grant access to App B
let other_app = AppId::new();
assert_eq!(
can(&repo, &p, Capability::AppAdmin(other_app))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn scoped_key_intersects_with_role() {
let repo = InMemoryAuthzRepo::default();
let app = AppId::new();
// Owner key with only script:read — cannot write
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptRead]),
app_binding: None,
};
assert!(can(&repo, &p, Capability::AppRead(app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Deny
);
// Even though the user is owner — the key's scope set is the
// hard ceiling.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn bound_key_cannot_escape_its_app() {
let repo = InMemoryAuthzRepo::default();
let bound_app = AppId::new();
let other_app = AppId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::ScriptWrite]),
app_binding: Some(bound_app),
};
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
.await
.unwrap()
.is_allow());
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(other_app))
.await
.unwrap(),
Decision::Deny
);
}
#[tokio::test]
async fn bound_key_cannot_do_instance_actions() {
let repo = InMemoryAuthzRepo::default();
let bound_app = AppId::new();
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
app_binding: Some(bound_app),
};
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Deny,
"bound key with instance scope must still be denied at the binding layer"
);
}
#[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));
}
#[test]
fn capability_required_scope_mapping_is_complete() {
// Sanity: every variant returns a scope. Compiler-enforced
// exhaustiveness lives in the match itself; this test guards
// against accidental drift to a default branch.
let app = AppId::new();
for cap in [
Capability::InstanceCreateApp,
Capability::InstanceManageUsers,
Capability::InstanceManageSettings,
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
Capability::AppLogRead(app),
] {
let _ = cap.required_scope(); // does not panic
}
}
}

View File

@@ -8,14 +8,19 @@ pub mod admin_session_repo;
pub mod admin_user_repo; pub mod admin_user_repo;
pub mod admin_users_api; pub mod admin_users_api;
pub mod api; pub mod api;
pub mod api_key_repo;
pub mod api_keys_api;
pub mod app_bootstrap; pub mod app_bootstrap;
pub mod app_domain_repo; pub mod app_domain_repo;
pub mod app_members_api;
pub mod app_members_repo;
pub mod app_repo; pub mod app_repo;
pub mod apps_api; pub mod apps_api;
pub mod auth; pub mod auth;
pub mod auth_api; pub mod auth_api;
pub mod auth_bootstrap; pub mod auth_bootstrap;
pub mod auth_middleware; pub mod auth_middleware;
pub mod authz;
pub mod log_sink; pub mod log_sink;
pub mod migrations; pub mod migrations;
pub mod repo; pub mod repo;
@@ -34,15 +39,30 @@ pub use admin_user_repo::{
}; };
pub use admin_users_api::{admins_router, AdminsState}; pub use admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState}; pub use api::{admin_router, AdminState};
pub use api_key_repo::{
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
PostgresApiKeyRepository,
};
pub use api_keys_api::{api_keys_router, ApiKeysState};
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome}; pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository}; pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository}; pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
pub use app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
PostgresAppMembersRepository,
};
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
pub use apps_api::{apps_router, AppsState}; pub use apps_api::{apps_router, AppsState};
pub use auth_api::auth_router; pub use auth_api::auth_router;
pub use auth_bootstrap::{ pub use auth_bootstrap::{
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError, bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
}; };
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; #[allow(deprecated)]
pub use auth_middleware::{
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
API_KEY_PREFIX_LEN, SESSION_COOKIE,
};
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;
pub use repo::{ pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use async_trait::async_trait; use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver}; use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox, AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -27,6 +27,14 @@ pub trait ScriptRepository: Send + Sync {
/// "global" views; the dashboard reaches scripts via `list_for_app`. /// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>; async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>; async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// Every script in any app the user is a member of. Drives
/// `GET /admin/scripts` for `member` instance-role callers so the
/// API never returns scripts they shouldn't see — even before the
/// per-handler capability check fires.
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>; async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
async fn update( async fn update(
&self, &self,
@@ -117,6 +125,24 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
FROM scripts s \
JOIN app_members m ON m.app_id = s.app_id \
WHERE m.user_id = $1 \
ORDER BY s.name",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> { async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default()) let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({})); .unwrap_or_else(|_| serde_json::json!({}));

View File

@@ -10,14 +10,15 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{delete, get, post}, routing::{delete, get, post},
Json, Router, Extension, Json, Router,
}; };
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable}; use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId}; use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::app_domain_repo::AppDomainRepository; use crate::app_domain_repo::AppDomainRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError}; use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository}; use crate::route_repo::{NewRoute, RouteRepository};
@@ -30,6 +31,8 @@ pub struct RouteAdminState<RR, SR> {
/// declared domain claims. /// declared domain claims.
pub domains: Arc<dyn AppDomainRepository>, pub domains: Arc<dyn AppDomainRepository>,
pub table: Arc<RouteTable>, pub table: Arc<RouteTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
} }
impl<RR, SR> Clone for RouteAdminState<RR, SR> { impl<RR, SR> Clone for RouteAdminState<RR, SR> {
@@ -39,6 +42,7 @@ impl<RR, SR> Clone for RouteAdminState<RR, SR> {
scripts: self.scripts.clone(), scripts: self.scripts.clone(),
domains: self.domains.clone(), domains: self.domains.clone(),
table: self.table.clone(), table: self.table.clone(),
authz: self.authz.clone(),
} }
} }
} }
@@ -130,13 +134,26 @@ pub struct MatchedRoute {
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>( async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>, Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> { ) -> Result<Json<Vec<Route>>, RouteApiError> {
let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
)
.await?;
Ok(Json(state.routes.list_for_script(script_id).await?)) Ok(Json(state.routes.list_for_script(script_id).await?))
} }
async fn create_route<RR: RouteRepository, SR: ScriptRepository>( async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>, Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>, Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> { ) -> Result<(StatusCode, Json<Route>), RouteApiError> {
@@ -154,6 +171,12 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
.await? .await?
.ok_or(RouteApiError::ScriptNotFound(script_id))?; .ok_or(RouteApiError::ScriptNotFound(script_id))?;
let app_id = script.app_id; let app_id = script.app_id;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(app_id),
)
.await?;
// Validate the route's host is consistent with one of the app's // Validate the route's host is consistent with one of the app's
// domain claims. `HostKind::Any` is always permitted (catches every // domain claims. `HostKind::Any` is always permitted (catches every
@@ -196,8 +219,22 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>( async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(route_id): Path<Uuid>, Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> { ) -> Result<StatusCode, RouteApiError> {
// Resolve the route's app before we delete, so the capability
// binds to the actual route's app_id (not a path param).
let route = state
.routes
.get(route_id)
.await?
.ok_or(RouteApiError::RouteNotFound(route_id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteRoute(route.app_id),
)
.await?;
state.routes.delete(route_id).await?; state.routes.delete(route_id).await?;
refresh_table(&state).await?; refresh_table(&state).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
@@ -205,8 +242,18 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
async fn check_route<RR: RouteRepository, SR: ScriptRepository>( async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CheckRouteRequest>, Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> { ) -> Result<Json<CheckRouteResponse>, RouteApiError> {
// routes:check is read-only — peeking at a hypothetical conflict
// is bounded by AppRead on the target app (otherwise members
// could probe other apps).
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(input.host_kind, &input.host, None)?; pattern::parse_host(input.host_kind, &input.host, None)?;
@@ -235,8 +282,15 @@ async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
async fn match_route<RR: RouteRepository, SR: ScriptRepository>( async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>, State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<MatchRouteRequest>, Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> { ) -> Result<Json<MatchRouteResponse>, RouteApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let parsed = url::Url::parse(&input.url) let parsed = url::Url::parse(&input.url)
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?; .map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
let host = parsed.host_str().unwrap_or("").to_string(); let host = parsed.host_str().unwrap_or("").to_string();
@@ -415,16 +469,34 @@ pub enum RouteApiError {
#[error("script not found: {0}")] #[error("script not found: {0}")]
ScriptNotFound(ScriptId), ScriptNotFound(ScriptId),
#[error("route not found: {0}")]
RouteNotFound(Uuid),
#[error("host {host:?} is not claimed by this app")] #[error("host {host:?} is not claimed by this app")]
HostNotClaimed { HostNotClaimed {
host: String, host: String,
available_claims: Vec<String>, available_claims: Vec<String>,
}, },
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
impl From<AuthzDenied> for RouteApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for RouteApiError { impl IntoResponse for RouteApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {
@@ -443,10 +515,23 @@ impl IntoResponse for RouteApiError {
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),
), ),
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => ( Self::ScriptNotFound(_)
| Self::RouteNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
serde_json::json!({ "error": self.to_string() }), serde_json::json!({ "error": self.to_string() }),
), ),
Self::Forbidden => (
StatusCode::FORBIDDEN,
serde_json::json!({ "error": self.to_string() }),
),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "route authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({ "error": "internal error" }),
)
}
Self::HostNotClaimed { Self::HostNotClaimed {
host, host,
available_claims, available_claims,

View File

@@ -25,6 +25,10 @@ pub struct NewRoute {
#[async_trait] #[async_trait]
pub trait RouteRepository: Send + Sync { pub trait RouteRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>; async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
/// the capability check binds to the route's actual `app_id`
/// (not a path param).
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>; async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn list_for_script( async fn list_for_script(
&self, &self,
@@ -66,6 +70,18 @@ impl RouteRepository for PostgresRouteRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
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, created_at \
FROM routes WHERE id = $1",
)
.bind(route_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> { async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>( let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \ "SELECT id, app_id, script_id, host_kind, host, host_param_name, \

View File

@@ -18,6 +18,21 @@ table: admin_users
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now()
last_login_at: timestamp with time zone NULL last_login_at: timestamp with time zone NULL
instance_role: text NOT NULL default='owner'::text
email: text NULL
mfa_secret: text NULL
table: api_keys
id: uuid NOT NULL default=gen_random_uuid()
user_id: uuid NOT NULL
hash: text NOT NULL
prefix: text NOT NULL
name: text NOT NULL
scopes: ARRAY NOT NULL
app_id: uuid NULL
expires_at: timestamp with time zone NULL
last_used_at: timestamp with time zone NULL
created_at: timestamp with time zone NOT NULL default=now()
table: app_domains table: app_domains
id: uuid NOT NULL default=gen_random_uuid() id: uuid NOT NULL default=gen_random_uuid()
@@ -27,6 +42,12 @@ table: app_domains
shape_key: text NOT NULL shape_key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
table: app_members
app_id: uuid NOT NULL
user_id: uuid NOT NULL
role: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: app_slug_history table: app_slug_history
slug: text NOT NULL slug: text NOT NULL
current_app_id: uuid NOT NULL current_app_id: uuid NOT NULL
@@ -88,14 +109,25 @@ indexes on admin_sessions:
admin_sessions_user_idx: public.admin_sessions USING btree (user_id) admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
indexes on admin_users: indexes on admin_users:
admin_users_email_key: public.admin_users USING btree (email)
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
admin_users_pkey: public.admin_users USING btree (id) admin_users_pkey: public.admin_users USING btree (id)
admin_users_username_key: public.admin_users USING btree (username) admin_users_username_key: public.admin_users USING btree (username)
indexes on api_keys:
api_keys_pkey: public.api_keys USING btree (id)
api_keys_prefix_idx: public.api_keys USING btree (prefix)
api_keys_user_id_idx: public.api_keys USING btree (user_id)
indexes on app_domains: indexes on app_domains:
app_domains_app_id_idx: public.app_domains USING btree (app_id) app_domains_app_id_idx: public.app_domains USING btree (app_id)
app_domains_pkey: public.app_domains USING btree (id) app_domains_pkey: public.app_domains USING btree (id)
app_domains_shape_key_key: public.app_domains USING btree (shape_key) app_domains_shape_key_key: public.app_domains USING btree (shape_key)
indexes on app_members:
app_members_pkey: public.app_members USING btree (app_id, user_id)
app_members_user_id_idx: public.app_members USING btree (user_id)
indexes on app_slug_history: indexes on app_slug_history:
app_slug_history_pkey: public.app_slug_history USING btree (slug) app_slug_history_pkey: public.app_slug_history USING btree (slug)
@@ -127,15 +159,28 @@ constraints on admin_sessions:
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash) [PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
constraints on admin_users: constraints on admin_users:
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id) [PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
[UNIQUE] admin_users_email_key: UNIQUE (email)
[UNIQUE] admin_users_username_key: UNIQUE (username) [UNIQUE] admin_users_username_key: UNIQUE (username)
constraints on api_keys:
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
constraints on app_domains: constraints on app_domains:
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text]))) [CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id) [PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key) [UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
constraints on app_members:
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
constraints on app_slug_history: constraints on app_slug_history:
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug) [PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
@@ -169,3 +214,4 @@ constraints on scripts:
0003: routes 0003: routes
0004: admin auth 0004: admin auth
0005: apps 0005: apps
0006: users authz

View File

@@ -0,0 +1,31 @@
[package]
name = "picloud-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "PiCloud command-line client"
[[bin]]
name = "pic"
path = "src/main.rs"
[dependencies]
picloud-shared.workspace = true
reqwest = { workspace = true, features = ["json"] }
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
clap = { version = "4", features = ["derive"] }
toml = "0.8"
directories = "5"
rpassword = "7"
anyhow = "1"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
reqwest = { workspace = true, features = ["json", "blocking"] }

View File

@@ -0,0 +1,333 @@
//! Reqwest-backed HTTP client + minimal wire DTOs.
//!
//! The CLI deliberately re-declares small request/response structs here
//! rather than depending on `manager-core` (and pulling its Postgres
//! transitive surface). Fields kept to what the CLI actually sends or
//! reads.
use std::collections::BTreeMap;
use anyhow::{anyhow, Context, Result};
use picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, Script};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config::Credentials;
pub struct Client {
http: reqwest::Client,
url: String,
token: String,
}
impl Client {
pub fn from_creds(creds: &Credentials) -> Result<Self> {
Self::new(&creds.url, &creds.token)
}
pub fn new(url: &str, token: &str) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
.build()
.context("building HTTP client")?;
Ok(Self {
http,
url: url.trim_end_matches('/').to_string(),
token: token.to_string(),
})
}
pub fn url(&self) -> &str {
&self.url
}
fn request(&self, method: Method, path: &str) -> RequestBuilder {
self.http
.request(method, format!("{}{path}", self.url))
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
}
/// `GET /api/v1/admin/auth/me`
pub async fn auth_me(&self) -> Result<AuthMeDto> {
let resp = self
.request(Method::GET, "/api/v1/admin/auth/me")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps`
pub async fn apps_list(&self) -> Result<Vec<App>> {
let resp = self
.request(Method::GET, "/api/v1/admin/apps")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps`
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
let resp = self
.request(Method::POST, "/api/v1/admin/apps")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts?app={ident}`
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/scripts`
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
let resp = self
.request(Method::POST, "/api/v1/admin/scripts")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics.
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
let body = UpdateScriptBody { source };
let resp = self
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
/// and JSON body (the orchestrator marshals the script's output as
/// the HTTP response itself, not a wrapper object).
pub async fn execute(
&self,
id: &str,
body: Value,
headers: &[(String, String)],
) -> Result<ExecuteResponse> {
let mut req = self
.request(Method::POST, &format!("/api/v1/execute/{id}"))
.json(&body);
for (k, v) in headers {
req = req.header(k, v);
}
let resp = req.send().await?;
let status = resp.status().as_u16();
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in resp.headers() {
if let Ok(val) = v.to_str() {
headers_out.insert(k.as_str().to_string(), val.to_string());
}
}
let bytes = resp.bytes().await.context("reading execute response")?;
let body_json: Value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes)
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
};
Ok(ExecuteResponse {
status_code: status,
headers: headers_out,
body: body_json,
})
}
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
}
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct AuthMeDto {
// Part of the wire shape (and kept for symmetry with the dashboard's
// MeDto), even though the CLI never displays it.
pub id: String,
pub username: String,
pub instance_role: InstanceRole,
#[serde(default)]
pub email: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct AppLookupDto {
#[serde(flatten)]
pub app: App,
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
#[serde(default)]
pub my_role: Option<AppRole>,
}
#[derive(Debug, Serialize)]
pub struct CreateAppBody<'a> {
pub slug: &'a str,
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
}
#[derive(Debug, Serialize)]
pub struct CreateScriptBody<'a> {
pub app_id: AppId,
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub source: &'a str,
}
#[derive(Debug, Serialize)]
struct UpdateScriptBody<'a> {
source: &'a str,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct ExecuteResponse {
pub status_code: u16,
// Captured for completeness; not displayed today, but `pic invoke -v`
// could surface them later without changing this struct.
pub headers: BTreeMap<String, String>,
pub body: Value,
}
// ---------- helpers ----------
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
/// pair. Trims surrounding whitespace on both sides.
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
let (k, v) = raw
.split_once(':')
.or_else(|| raw.split_once('='))
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
let k = k.trim();
let v = v.trim();
if k.is_empty() {
return Err(format!("empty header name in {raw:?}"));
}
Ok((k.to_string(), v.to_string()))
}
fn urlencoded(s: &str) -> String {
// Minimal pass: percent-encode the few chars that break the query.
// Slugs and UUIDs don't contain them in practice, but be safe.
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' | '=' | '?' | '#' | ' ' => {
out.push_str(&format!("%{:02X}", u32::from(ch)));
}
_ => out.push(ch),
}
}
out
}
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
if resp.status().is_success() {
return resp.json::<T>().await.context("parsing response body");
}
Err(server_error(resp).await)
}
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
let hint = role_hint(status);
if hint.is_empty() {
anyhow!("HTTP {}: {}", status.as_u16(), msg)
} else {
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
}
}
fn parse_error_body(s: &str) -> Option<String> {
let v: Value = serde_json::from_str(s).ok()?;
let obj = v.as_object()?;
if let Some(m) = obj.get("message").and_then(Value::as_str) {
return Some(m.to_string());
}
if let Some(e) = obj.get("error").and_then(Value::as_str) {
return Some(e.to_string());
}
None
}
fn role_hint(status: StatusCode) -> &'static str {
match status {
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_kv_colon() {
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
assert_eq!(k, "X-Foo");
assert_eq!(v, "bar");
}
#[test]
fn parse_kv_equals() {
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
assert_eq!(k, "X-Foo");
assert_eq!(v, "bar");
}
#[test]
fn parse_kv_rejects_no_separator() {
assert!(parse_kv_header("X-Foo").is_err());
}
#[test]
fn parse_kv_rejects_empty_name() {
assert!(parse_kv_header(": bar").is_err());
}
#[test]
fn url_strip_trailing_slash() {
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
assert_eq!(c.url(), "http://localhost:8000");
}
}

View File

@@ -0,0 +1,40 @@
//! `pic apps ls` and `pic apps create`.
use anyhow::Result;
use crate::client::{Client, CreateAppBody};
use crate::config::load;
use crate::output::Table;
pub async fn ls() -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let apps = client.apps_list().await?;
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
for app in apps {
// The list endpoint returns App without my_role. We do a per-app
// lookup only on demand; for `ls` we leave the column dashed so
// the call stays cheap (one HTTP request).
table.row([
app.slug.clone(),
app.name.clone(),
"-".to_string(),
app.created_at.to_rfc3339(),
]);
}
table.print();
Ok(())
}
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let body = CreateAppBody {
slug,
name: name.unwrap_or(slug),
description,
};
let app = client.apps_create(&body).await?;
println!("Created app {}", app.slug);
Ok(())
}

View File

@@ -0,0 +1,66 @@
//! `pic login` — interactively (or via PICLOUD_URL/PICLOUD_TOKEN env
//! shortcut for non-interactive contexts like CI and integration tests)
//! capture the URL + bearer token, validate against `/auth/me`, save.
use std::io::{self, BufRead, Write};
use anyhow::Result;
use crate::client::Client;
use crate::config::{save, Credentials};
const DEFAULT_URL: &str = "http://localhost:8000";
pub async fn run() -> Result<()> {
let (url, token) = collect_credentials()?;
let client = Client::new(&url, &token)?;
let me = client.auth_me().await?;
let creds = Credentials {
url: client.url().to_string(),
token,
username: me.username.clone(),
};
save(&creds)?;
println!(
"Logged in as {} ({}) at {}",
me.username,
instance_role_label(&me.instance_role),
creds.url
);
Ok(())
}
fn collect_credentials() -> Result<(String, String)> {
// Non-interactive shortcut: both vars set → use as-is. Used by the
// integration test and any CI flow that wants to skip the prompts.
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
if !url.is_empty() && !tok.is_empty() {
return Ok((url, tok));
}
}
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
let token = rpassword::prompt_password("API token: ")?;
Ok((url, token))
}
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
print!("{label} [{default}]: ");
io::stdout().flush()?;
let mut buf = String::new();
io::stdin().lock().read_line(&mut buf)?;
let trimmed = buf.trim();
Ok(if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
})
}
fn instance_role_label(role: &picloud_shared::InstanceRole) -> &'static str {
use picloud_shared::InstanceRole as R;
match role {
R::Owner => "owner",
R::Admin => "admin",
R::Member => "member",
}
}

View File

@@ -0,0 +1,58 @@
//! `pic logs <script-id>` — print recent execution log rows.
use anyhow::Result;
use picloud_shared::ExecutionStatus;
use crate::client::Client;
use crate::config::load;
pub async fn run(script_id: &str, limit: u32) -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?;
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
println!(
"{}\t{}\t{}",
e.created_at.to_rfc3339(),
status_label(&e.status),
truncate(&summary, 120),
);
}
Ok(())
}
fn status_label(s: &ExecutionStatus) -> &'static str {
match s {
ExecutionStatus::Success => "success",
ExecutionStatus::Error => "error",
ExecutionStatus::Timeout => "timeout",
ExecutionStatus::BudgetExceeded => "budget_exceeded",
}
}
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
// Prefer the last script-side log line (often the most useful for
// grepping). Fall back to the response body.
if let Some(arr) = script_logs.as_array() {
if let Some(last) = arr.last() {
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
return msg.to_string();
}
}
}
response_body
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "-".to_string())
}
fn truncate(s: &str, n: usize) -> String {
let normalized = s.replace('\n', " ");
if normalized.chars().count() <= n {
normalized
} else {
let head: String = normalized.chars().take(n).collect();
format!("{head}")
}
}

View File

@@ -0,0 +1,5 @@
pub mod apps;
pub mod login;
pub mod logs;
pub mod scripts;
pub mod whoami;

View File

@@ -0,0 +1,178 @@
//! `pic scripts ls | deploy | invoke`.
use std::io::{self, Read, Write};
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use crate::client::{Client, CreateScriptBody};
use crate::config::load;
use crate::output::Table;
pub async fn ls(app: Option<&str>) -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
if let Some(ident) = app {
let app = client.apps_get(ident).await?;
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
for s in scripts {
table.row([
s.id.to_string(),
app.app.slug.clone(),
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
} else {
// No filter → walk every accessible app. One request per app is
// fine at MVP scale (handful of apps); a bulk endpoint can come
// later if the count grows.
let apps = client.apps_list().await?;
for a in apps {
let scripts = client.scripts_list_by_app(&a.slug).await?;
for s in scripts {
table.row([
s.id.to_string(),
a.slug.clone(),
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
}
}
}
table.print();
Ok(())
}
pub async fn deploy(
file: &Path,
app_ident: &str,
name_override: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let source =
std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
let name = match name_override {
Some(n) => n.to_string(),
None => file
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
.ok_or_else(|| {
anyhow!(
"could not derive script name from path {} (use --name)",
file.display()
)
})?,
};
// Slug-or-id resolution: a single GET satisfies both lookups and
// gives us the canonical app_id needed for create.
let app = client.apps_get(app_ident).await?;
let existing = client.scripts_list_by_app(app_ident).await?;
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source)
.await?;
println!("Updated {} v{}", updated.name, updated.version);
} else {
let body = CreateScriptBody {
app_id: app.app.id,
name: &name,
description,
source: &source,
};
let created = client.scripts_create(&body).await?;
println!("Created {} v{}", created.name, created.version);
}
Ok(())
}
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let body = parse_body_arg(body_arg)?;
let resp = client.execute(id, body, headers).await?;
// Status to stderr so stdout stays JSON for piping into jq.
let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
println!("{pretty}");
if (200..400).contains(&resp.status_code) {
Ok(())
} else {
Err(anyhow!("execute returned HTTP {}", resp.status_code))
}
}
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
match arg {
None => Ok(Value::Object(serde_json::Map::new())),
Some("@-") => {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("reading stdin")?;
parse_or_string(&buf)
}
Some(raw) if raw.starts_with('@') => {
let path = &raw[1..];
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading body file {path}"))?;
parse_or_string(&text)
}
Some(raw) => parse_or_string(raw),
}
}
fn parse_or_string(s: &str) -> Result<Value> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_str(trimmed)
.with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}", &s[..n])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_body_inline_json() {
let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
assert_eq!(v["x"], 1);
}
#[test]
fn parse_body_none_is_empty_object() {
let v = parse_body_arg(None).unwrap();
assert!(v.is_object());
assert_eq!(v.as_object().unwrap().len(), 0);
}
#[test]
fn parse_body_invalid_json_reports() {
let err = parse_body_arg(Some("not-json{")).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("not valid JSON"), "got: {msg}");
}
}

View File

@@ -0,0 +1,22 @@
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
//! every time. Cached username in the credentials file is for
//! display-only contexts; this command is the source of truth.
use anyhow::Result;
use crate::client::Client;
use crate::config::load;
pub async fn run() -> Result<()> {
let creds = load()?;
let client = Client::from_creds(&creds)?;
let me = client.auth_me().await?;
let role = match me.instance_role {
picloud_shared::InstanceRole::Owner => "owner",
picloud_shared::InstanceRole::Admin => "admin",
picloud_shared::InstanceRole::Member => "member",
};
let email = me.email.as_deref().unwrap_or("-");
println!("{}\t{role}\t{email}\t{}", me.username, creds.url);
Ok(())
}

View File

@@ -0,0 +1,118 @@
//! On-disk credentials store.
//!
//! Path is resolved via `directories::ProjectDirs` so the file lives in
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
//! pasted bearer token isn't world-readable.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Credentials {
pub url: String,
pub token: String,
pub username: String,
}
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
/// override (used by tests to redirect to a tempdir) before falling
/// back to the platform default.
pub fn credentials_path() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
return Ok(PathBuf::from(dir).join("credentials"));
}
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
.ok_or_else(|| anyhow!("could not determine config directory"))?;
Ok(dirs.config_dir().join("credentials"))
}
pub fn load() -> Result<Credentials> {
let path = credentials_path()?;
let body = fs::read_to_string(&path).with_context(|| {
format!(
"no credentials at {}. run `pic login` first",
path.display()
)
})?;
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
}
pub fn save(creds: &Credentials) -> Result<()> {
let path = credentials_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let body = toml::to_string(creds).context("serializing credentials")?;
write_private(&path, body.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("opening {}", path.display()))?;
f.write_all(bytes)
.with_context(|| format!("writing {}", path.display()))?;
// Belt-and-suspenders: re-set perms in case the file already existed
// with a wider mode (mode() on create doesn't downgrade existing).
let mut perms = fs::metadata(path)?.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn roundtrip_toml() {
let creds = Credentials {
url: "http://localhost:8000".to_string(),
token: "pic_abc".to_string(),
username: "admin".to_string(),
};
let serialized = toml::to_string(&creds).unwrap();
let parsed: Credentials = toml::from_str(&serialized).unwrap();
assert_eq!(creds, parsed);
}
#[cfg(unix)]
#[test]
fn posix_mode_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
let creds = Credentials {
url: "http://localhost:8000".to_string(),
token: "pic_secret".to_string(),
username: "admin".to_string(),
};
save(&creds).unwrap();
let path = credentials_path().unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
std::env::remove_var("PICLOUD_CONFIG_DIR");
}
}

View File

@@ -0,0 +1,142 @@
//! PiCloud command-line client.
//!
//! Thin client over the existing admin + execute HTTP surface — the
//! server gains nothing for the CLI; the CLI is just a developer
//! ergonomics layer over endpoints the dashboard already uses.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
mod client;
mod cmds;
mod config;
mod output;
#[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Save URL + bearer token to `~/.picloud/credentials`.
Login,
/// Print the principal the saved token resolves to.
Whoami,
/// App management.
Apps {
#[command(subcommand)]
cmd: AppsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
cmd: ScriptsCmd,
},
/// Tail recent execution logs for a script.
Logs(LogsArgs),
}
#[derive(Subcommand)]
enum AppsCmd {
/// List apps the caller can see.
Ls,
/// Create a new app.
Create {
slug: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
}
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without,
/// iterates over every app the caller can see.
Ls {
#[arg(long)]
app: Option<String>,
},
/// Upload a `.rhai` file. Patches the existing script with the
/// matching name in `--app` if one exists, otherwise creates it.
Deploy {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON.
Invoke {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, String)>,
},
}
#[derive(Args)]
struct LogsArgs {
script_id: String,
#[arg(long, default_value_t = 50)]
limit: u32,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
let result = match cli.cmd {
Cmd::Login => cmds::login::run().await,
Cmd::Whoami => cmds::whoami::run().await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await,
Cmd::Apps {
cmd:
AppsCmd::Create {
slug,
name,
description,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref()).await,
Cmd::Scripts {
cmd:
ScriptsCmd::Deploy {
file,
app,
name,
description,
},
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await,
Cmd::Scripts {
cmd: ScriptsCmd::Invoke { id, body, headers },
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).await,
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
output::print_error(&err);
ExitCode::FAILURE
}
}
}

View File

@@ -0,0 +1,103 @@
//! Tab-separated table writer + error formatting.
//!
//! Aligned columns are nice for humans but `\t`-separated stays
//! pipe-friendly: `pic apps ls | awk -F'\t' '{print $1}'` works without
//! parsing box-drawing.
use std::io::{self, Write};
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
impl Table {
pub fn new<I, S>(headers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
headers: headers.into_iter().map(Into::into).collect(),
rows: Vec::new(),
}
}
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.rows.push(cells.into_iter().map(Into::into).collect());
self
}
pub fn render(&self) -> String {
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
if i >= widths.len() {
widths.push(cell.len());
} else if cell.len() > widths[i] {
widths[i] = cell.len();
}
}
}
let mut out = String::new();
write_row(&mut out, &self.headers, &widths);
for row in &self.rows {
write_row(&mut out, row, &widths);
}
out
}
pub fn print(&self) {
let s = self.render();
// Best-effort write — broken pipe from `| head` etc. shouldn't
// surface as an error.
let _ = io::stdout().write_all(s.as_bytes());
}
}
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
for (i, cell) in row.iter().enumerate() {
if i > 0 {
out.push('\t');
}
out.push_str(cell);
// Right-pad with spaces so tabs land on the column grid for
// human readers. Skip on the final column.
if i + 1 < row.len() {
let w = widths.get(i).copied().unwrap_or(cell.len());
for _ in cell.len()..w {
out.push(' ');
}
}
}
out.push('\n');
}
pub fn print_error(err: &anyhow::Error) {
let mut stderr = io::stderr();
let _ = writeln!(stderr, "error: {err:#}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_aligns_columns() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let out = t.render();
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
}
#[test]
fn table_empty_rows() {
let t = Table::new(["a", "b"]);
assert_eq!(t.render(), "a\tb\n");
}
}

View File

@@ -0,0 +1,371 @@
//! Bare-metal end-to-end integration test.
//!
//! Spawns a `picloud` subprocess against `DATABASE_URL` on a private
//! port, logs in over HTTP to mint a bearer token, then drives the
//! `pic` binary through the full edit-deploy-invoke-tail loop and
//! cleans up the app it created.
//!
//! Gated on `DATABASE_URL`. To run:
//!
//! docker compose up -d postgres
//! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
//! cargo test -p picloud-cli --test cli -- --include-ignored
#![allow(clippy::too_many_lines)]
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command as StdCommand, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;
// The bootstrap env vars are inert once any admin row exists, so we
// can't carve out a dedicated test admin against the dev database. The
// dev stack seeds `admin`/`admin` (see CLAUDE.md); we use those.
// `PICLOUD_CLI_E2E_USERNAME` / `_PASSWORD` let CI override.
fn admin_username() -> String {
std::env::var("PICLOUD_CLI_E2E_USERNAME").unwrap_or_else(|_| "admin".to_string())
}
fn admin_password() -> String {
std::env::var("PICLOUD_CLI_E2E_PASSWORD").unwrap_or_else(|_| "admin".to_string())
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn end_to_end_login_deploy_invoke_logs() {
let Ok(database_url) = std::env::var("DATABASE_URL") else {
eprintln!("skipping: DATABASE_URL not set");
return;
};
let port = pick_free_port();
let url = format!("http://127.0.0.1:{port}");
let mut server = spawn_picloud(&database_url, port);
if let Err(e) = wait_for_health(&url, Duration::from_secs(60)) {
kill_subprocess(&mut server);
panic!("picloud failed to become healthy: {e}");
}
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_flow(&url);
}));
// Always tear down regardless of outcome so a failed test doesn't
// leak a child process.
kill_subprocess(&mut server);
if let Err(p) = outcome {
std::panic::resume_unwind(p);
}
}
fn run_flow(url: &str) {
let token = login_for_bearer_token(url);
let cfg_dir = TempDir::new().expect("tempdir");
let home = TempDir::new().expect("home tempdir");
let env = TestEnv {
url: url.to_string(),
token,
config_dir: cfg_dir.path().to_path_buf(),
home: home.path().to_path_buf(),
};
// Slug carries the wall-clock so reruns against a long-lived dev
// database don't collide on the unique-slug constraint.
let slug = format!(
"pic-cli-e2e-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
);
let username = admin_username();
// 1) login
pic(&env)
.args(["login"])
.assert()
.success()
.stdout(predicate::str::contains(format!("Logged in as {username}")));
let creds_path = env.config_dir.join("credentials");
assert!(
creds_path.exists(),
"credentials file should exist after login"
);
let body = std::fs::read_to_string(&creds_path).unwrap();
assert!(body.contains(&env.url), "creds should contain url: {body}");
assert!(
body.contains(&username),
"creds should contain username: {body}"
);
// 2) whoami
pic(&env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(username.clone()));
// 3) apps create
pic(&env)
.args(["apps", "create", &slug])
.assert()
.success()
.stdout(predicate::str::contains(format!("Created app {slug}")));
// Ensure the app is cleaned up no matter what subsequent assertions do.
let _guard = AppGuard {
url: env.url.clone(),
token: env.token.clone(),
slug: slug.clone(),
};
// 4) apps ls
pic(&env)
.args(["apps", "ls"])
.assert()
.success()
.stdout(predicate::str::contains(slug.as_str()));
// 5) scripts deploy (create then update)
let fixture = fixture_path("hello.rhai");
pic(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
pic(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Updated hello v2"));
// 6) scripts ls and capture the id
let ls_out = pic(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}");
let id = parse_first_id(std::str::from_utf8(&ls_out.stdout).unwrap())
.expect("scripts ls should print at least one row");
// 7) invoke
let invoke_out = pic(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("scripts invoke");
assert!(
invoke_out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&invoke_out.stderr)
);
let parsed: Value =
serde_json::from_slice(&invoke_out.stdout).expect("invoke stdout should be JSON");
assert_eq!(
parsed["ok"], true,
"expected hello.rhai response, got {parsed}"
);
// 8) logs (the invoke above should have produced exactly one row)
let logs_out = pic(&env).args(["logs", &id]).output().expect("pic logs");
assert!(logs_out.status.success(), "logs failed: {logs_out:?}");
let stdout = String::from_utf8_lossy(&logs_out.stdout);
assert!(
stdout.lines().any(|l| !l.trim().is_empty()),
"logs should have at least one row, got: {stdout}"
);
}
// --------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------
struct TestEnv {
url: String,
token: String,
config_dir: PathBuf,
home: PathBuf,
}
fn pic(env: &TestEnv) -> AssertCommand {
let mut cmd = AssertCommand::cargo_bin("pic").expect("pic binary");
cmd.env("PICLOUD_URL", &env.url)
.env("PICLOUD_TOKEN", &env.token)
.env("PICLOUD_CONFIG_DIR", &env.config_dir)
.env("HOME", &env.home);
cmd
}
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
fn picloud_binary_path() -> PathBuf {
// The integration test binary lives at
// `<target>/debug/deps/cli-<hash>`. CARGO_MANIFEST_DIR points at the
// crate; the workspace target dir is two levels up. `picloud` lands
// next to our own test executable.
let exe = std::env::current_exe().expect("current_exe");
// current_exe is `.../target/debug/deps/cli-<hash>`. Walk up twice
// to reach `.../target/debug`, then look for `picloud`.
let debug_dir = exe
.parent()
.and_then(|p| p.parent())
.expect("test binary should live under target/debug/deps");
debug_dir.join(if cfg!(windows) {
"picloud.exe"
} else {
"picloud"
})
}
fn pick_free_port() -> u16 {
// Bind to :0, read the assigned port, drop the listener.
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1:0 to pick port");
listener.local_addr().expect("local addr").port()
}
fn spawn_picloud(database_url: &str, port: u16) -> Child {
// Execute the pre-built `picloud` binary directly. Going through
// `cargo run -p picloud` while inside `cargo test` would contend on
// the same build lock and can deadlock. We assume the binary was
// built as part of the workspace compile that produced this test —
// and check explicitly so the panic is informative if not.
let binary = picloud_binary_path();
assert!(
binary.exists(),
"expected picloud binary at {}. Run `cargo build -p picloud` first \
(or use `cargo test --workspace -- --include-ignored` which builds it)",
binary.display()
);
let mut child = StdCommand::new(&binary)
.env("PICLOUD_BIND", format!("127.0.0.1:{port}"))
.env("DATABASE_URL", database_url)
.env("PICLOUD_ADMIN_USERNAME", admin_username())
.env("PICLOUD_ADMIN_PASSWORD", admin_password())
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn picloud");
// Drain stderr in a side thread so the pipe buffer doesn't fill and
// block the server. We only echo to test output on failure.
if let Some(err) = child.stderr.take().map(BufReader::new) {
let (tx, _rx) = mpsc::channel::<String>();
thread::spawn(move || {
for line in err.lines().map_while(Result::ok) {
let _ = tx.send(line);
}
});
}
child
}
fn wait_for_health(url: &str, timeout: Duration) -> Result<(), String> {
let deadline = Instant::now() + timeout;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.map_err(|e| e.to_string())?;
while Instant::now() < deadline {
if let Ok(resp) = client.get(format!("{url}/healthz")).send() {
if resp.status().is_success() {
return Ok(());
}
}
thread::sleep(Duration::from_millis(250));
}
Err(format!("/healthz never returned 200 within {timeout:?}"))
}
fn login_for_bearer_token(url: &str) -> String {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!("{url}/api/v1/admin/auth/login"))
.json(&serde_json::json!({
"username": admin_username(),
"password": admin_password(),
}))
.send()
.expect("login request");
assert!(
resp.status().is_success(),
"login should succeed, got {}: {}",
resp.status(),
resp.text().unwrap_or_default()
);
let v: Value = resp.json().expect("login json");
v["token"]
.as_str()
.expect("login returns token")
.to_string()
}
fn parse_first_id(table: &str) -> Option<String> {
// The header line starts with "id"; the first row's first
// tab-delimited cell is the script UUID.
let mut lines = table.lines().filter(|l| !l.trim().is_empty());
let header = lines.next()?;
if !header.starts_with("id") {
return None;
}
let row = lines.next()?;
let first = row.split('\t').next()?.trim();
if first.is_empty() {
None
} else {
Some(first.to_string())
}
}
fn kill_subprocess(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
struct AppGuard {
url: String,
token: String,
slug: String,
}
impl Drop for AppGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!(
"{}/api/v1/admin/apps/{}?force=true",
self.url, self.slug
))
.bearer_auth(&self.token)
.send();
}
}

View File

@@ -0,0 +1,4 @@
// Smallest possible Rhai script for the integration test: returns a JSON
// object so the orchestrator wraps it as the HTTP response body.
let body = #{ ok: true, greeting: "hello from pic" };
body

View File

@@ -39,3 +39,5 @@ figment.workspace = true
axum-test = "17" axum-test = "17"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -10,10 +10,12 @@ use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router}; use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits}; use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations, admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling, RouteRepository, SandboxCeiling,
@@ -37,6 +39,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
pub struct AuthDeps { pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>, pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>, pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration, pub ttl: Duration,
} }
@@ -46,7 +49,8 @@ impl AuthDeps {
pub fn from_pool(pool: PgPool) -> Self { pub fn from_pool(pool: PgPool) -> Self {
Self { Self {
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())), users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)), sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
ttl: read_session_ttl(), ttl: read_session_ttl(),
} }
} }
@@ -76,6 +80,7 @@ fn read_session_ttl() -> Duration {
/// the `require_admin` middleware. The data plane /// the `require_admin` middleware. The data plane
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`, /// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
/// `/version`) stays open — it's the public ingress for user scripts. /// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> { pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default())); let engine = Arc::new(Engine::new(Limits::default()));
@@ -85,7 +90,14 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone())); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone())); let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> = let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool)); Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// The Postgres app_members repo implements both `AppMembersRepository`
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
// for capability checks). Construct it once and clone the Arc into
// both trait views — same allocation, two vtables.
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
let authz: Arc<dyn AuthzRepo> = members_concrete;
// Compile the routes table once at startup; admin writes refresh it. // Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new()); let route_table = Arc::new(RouteTable::new());
@@ -120,6 +132,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())), repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo, logs: log_repo,
apps: apps_repo.clone(), apps: apps_repo.clone(),
authz: authz.clone(),
validator: engine as Arc<dyn ScriptValidator>, validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(), sandbox_ceiling: SandboxCeiling::from_env(),
}; };
@@ -128,6 +141,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)), scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
domains: domains_repo.clone(), domains: domains_repo.clone(),
table: route_table.clone(), table: route_table.clone(),
authz: authz.clone(),
}; };
let data_plane = DataPlaneState { let data_plane = DataPlaneState {
executor, executor,
@@ -141,28 +155,46 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
domains: domains_repo, domains: domains_repo,
routes: route_repo, routes: route_repo,
domain_table: app_domain_table, domain_table: app_domain_table,
authz: authz.clone(),
}; };
let auth_state = AuthState { let auth_state = AuthState {
users: auth.users.clone(), users: auth.users.clone(),
sessions: auth.sessions.clone(), sessions: auth.sessions.clone(),
keys: auth.keys.clone(),
ttl: auth.ttl, ttl: auth.ttl,
}; };
let admins_state = AdminsState { let admins_state = AdminsState {
users: auth.users, users: auth.users.clone(),
sessions: auth.sessions, sessions: auth.sessions,
keys: auth.keys.clone(),
authz: authz.clone(),
}; };
let app_members_state = AppMembersState {
apps: apps_state.apps.clone(),
users: auth.users,
members,
authz,
};
let api_keys_state = ApiKeysState { keys: auth.keys };
// /admin/auth/login + /logout are unguarded by design (login is how // /admin/auth/login + /logout are unguarded by design (login is how
// you get in). /admin/auth/me applies the middleware internally so // you get in). /admin/auth/me applies the middleware internally so
// the same Router::with_state machinery composes cleanly. Everything // the same Router::with_state machinery composes cleanly. Everything
// else under /admin gets the require_admin layer. // else under /admin gets the require_authenticated layer; capability
// checks live in each handler (after the resource is loaded so the
// capability binds to the resource's actual app_id).
let guarded_admin = Router::new() let guarded_admin = Router::new()
.merge(admin_router(admin)) .merge(admin_router(admin))
.merge(route_admin_router(route_admin)) .merge(route_admin_router(route_admin))
.merge(admins_router(admins_state)) .merge(admins_router(admins_state))
.merge(apps_router(apps_state)) .merge(apps_router(apps_state))
.layer(from_fn_with_state(auth_state.clone(), require_admin)); .merge(app_members_router(app_members_state))
.merge(api_keys_router(api_keys_state))
.layer(from_fn_with_state(
auth_state.clone(),
require_authenticated,
));
// Silence "unused import" lint on `apps_api` — we re-export via the // Silence "unused import" lint on `apps_api` — we re-export via the
// facade above; the bare module path is retained so it's discoverable. // facade above; the bare module path is retained so it's discoverable.
@@ -244,6 +276,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> { ) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_app(app_id).await self.0.list_for_app(app_id).await
} }
async fn list_for_user(
&self,
user_id: picloud_shared::AdminUserId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_user(user_id).await
}
async fn create( async fn create(
&self, &self,
input: picloud_manager_core::NewScript, input: picloud_manager_core::NewScript,

View File

@@ -45,6 +45,7 @@ async fn run_server() -> anyhow::Result<()> {
let auth = AuthDeps::from_pool(pool.clone()); let auth = AuthDeps::from_pool(pool.clone());
bootstrap_first_admin(&*auth.users).await?; bootstrap_first_admin(&*auth.users).await?;
warn_on_multi_owner_install(&*auth.users).await;
// Seed Hello World into the default app when this is a fresh // Seed Hello World into the default app when this is a fresh
// install (no scripts and no routes). Idempotent on upgrades. // install (no scripts and no routes). Idempotent on upgrades.
@@ -79,6 +80,34 @@ async fn run_server() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Multi-owner startup warning — Phase 3.5 migration upgraded every
/// pre-existing admin_users row to `Owner` via DEFAULT, which for
/// installs with several Phase 3a admins means several co-owners.
/// Surface this once at boot so the operator can demote extras via
/// `PATCH /api/v1/admin/admins/{id}` with `instance_role: "admin"`.
/// Soft-fail: a DB blip should not block startup.
async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) {
match users.list_active_owners().await {
Ok(owners) if owners.len() > 1 => {
let names: Vec<String> = owners.into_iter().map(|u| u.username).collect();
tracing::warn!(
count = names.len(),
owners = ?names,
"multiple active owners detected — Phase 3.5 promoted every \
pre-existing admin to owner. Demote extras via \
PATCH /api/v1/admin/admins/{{id}} with instance_role."
);
}
Ok(_) => {}
Err(err) => {
tracing::warn!(
?err,
"could not count active owners for multi-owner startup check"
);
}
}
}
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) { fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
tokio::spawn(async move { tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(600)); let mut ticker = tokio::time::interval(Duration::from_secs(600));

View File

@@ -31,11 +31,12 @@ async fn server(pool: PgPool) -> TestServer {
/// any test that creates scripts (every script now requires `app_id`). /// any test that creates scripts (every script now requires `app_id`).
async fn server_with_app(pool: PgPool) -> (TestServer, String) { async fn server_with_app(pool: PgPool) -> (TestServer, String) {
use picloud_manager_core::auth::hash_password; use picloud_manager_core::auth::hash_password;
use picloud_shared::InstanceRole;
let auth = picloud::AuthDeps::from_pool(pool.clone()); let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("test-pw").expect("hash"); let hash = hash_password("test-pw").expect("hash");
auth.users auth.users
.create("test-admin", &hash) .create("test-admin", &hash, InstanceRole::Owner, None)
.await .await
.expect("seed admin"); .expect("seed admin");
@@ -92,6 +93,68 @@ async fn healthz_responds_ok(pool: PgPool) {
assert_eq!(r.text(), "ok"); assert_eq!(r.text(), "ok");
} }
// ============================================================================
// Auth
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/api/v1/admin/auth/me").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["username"], "test-admin");
assert_eq!(body["instance_role"], "owner");
// Seeded admin has no email — must round-trip as null, not be missing.
assert!(
body.get("email").is_some_and(Value::is_null),
"email should be present and null, got: {body}"
);
assert!(body["id"].as_str().is_some());
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
let s = server(pool).await;
// Create with email set.
let created = s
.post("/api/v1/admin/admins")
.json(&json!({
"username": "alice",
"password": "correct-horse-battery",
"instance_role": "member",
"email": "alice@example.com",
}))
.await;
created.assert_status(axum::http::StatusCode::CREATED);
let body: Value = created.json();
let alice_id = body["id"].as_str().expect("id").to_string();
assert_eq!(body["email"], "alice@example.com");
// Patch with email present-and-null clears it.
let cleared = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "email": null }))
.await;
cleared.assert_status_ok();
assert!(cleared.json::<Value>()["email"].is_null());
// Patch with email omitted is a no-op (doesn't clobber a re-set).
let reset = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "email": "alice2@example.com" }))
.await;
reset.assert_status_ok();
let omit = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "username": "alice" })) // no email key
.await;
omit.assert_status_ok();
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
}
// ============================================================================ // ============================================================================
// Script CRUD // Script CRUD
// ============================================================================ // ============================================================================
@@ -821,7 +884,7 @@ async fn version_includes_public_base_url(pool: PgPool) {
let v: Value = r.json(); let v: Value = r.json();
assert!(v["public_base_url"].is_string()); assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1); assert_eq!(v["api"], 1);
assert_eq!(v["schema"], 5); assert_eq!(v["schema"], 6);
assert_eq!(v["sdk"], "1.1"); assert_eq!(v["sdk"], "1.1");
} }

File diff suppressed because it is too large Load Diff

242
crates/shared/src/auth.rs Normal file
View File

@@ -0,0 +1,242 @@
//! Cross-crate authn/authz types — Phase 3.5, see blueprint §11.6.
//!
//! The `Principal` extracted by `manager-core::auth_middleware` lives
//! here so handlers in every crate (and, later, the v1.1 SDKs in
//! `executor-core`) can refer to the same shape without pulling in the
//! manager crate. The authorization rules themselves live in
//! `manager-core::authz` — this module is data only.
//!
//! `UserId` is a transitional alias for `AdminUserId`. Phase 3a named
//! the table `admin_users` to leave room for the v1.1 script-level
//! `users` SDK feature (see blueprint §11.4 "Naming"); from the
//! authorization layer's perspective an admin row is the principal
//! identity, so we expose the alias rather than renaming the existing
//! id type.
use serde::{Deserialize, Serialize};
use crate::{AdminUserId, AppId};
/// Transitional alias — see module docs.
pub type UserId = AdminUserId;
/// Instance-wide role carried by every `admin_users` row. The DB
/// representation is `text` (`'owner'|'admin'|'member'`), checked via
/// a CHECK constraint in migration `0006_users_authz.sql`; this enum
/// is the Rust mirror.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceRole {
/// Full instance control, manage other owners, implicit `app_admin`
/// on every app. Multiple allowed.
Owner,
/// Create apps, invite users, implicit `editor` on every app. No
/// instance-settings authority and no owner-management.
Admin,
/// Invited into specific apps via `app_members` only. No app
/// creation, no invite authority. List endpoints filter strictly
/// by membership at SQL.
Member,
}
impl InstanceRole {
/// Stable string form — matches the DB CHECK constraint values
/// exactly. Used by repos and the seed/audit paths.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Admin => "admin",
Self::Member => "member",
}
}
/// Inverse of `as_str` — used when reading a row out of Postgres.
/// Returns `None` for unknown values so the caller can decide
/// between failing loudly or skipping a bad row.
#[must_use]
pub fn from_db_str(s: &str) -> Option<Self> {
match s {
"owner" => Some(Self::Owner),
"admin" => Some(Self::Admin),
"member" => Some(Self::Member),
_ => None,
}
}
}
/// Per-app role recorded in `app_members`. Members hold zero-or-one row
/// per (user, app); owners and admins are not represented in the table
/// (their app authority is implicit via `InstanceRole`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppRole {
/// App settings, domain claims, delete.
AppAdmin,
/// CRUD on scripts, routes, sandbox config.
Editor,
/// Read scripts + execution logs.
Viewer,
}
impl AppRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AppAdmin => "app_admin",
Self::Editor => "editor",
Self::Viewer => "viewer",
}
}
#[must_use]
pub fn from_db_str(s: &str) -> Option<Self> {
match s {
"app_admin" => Some(Self::AppAdmin),
"editor" => Some(Self::Editor),
"viewer" => Some(Self::Viewer),
_ => None,
}
}
}
/// API-key scope. Exactly seven values; new scopes need a blueprint
/// edit before they're added here. Wire form is the colon-separated
/// string (`"script:read"`, etc.) — matches the `text[]` stored in
/// `api_keys.scopes` and the strings shown to operators.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Scope {
ScriptRead,
ScriptWrite,
RouteWrite,
DomainManage,
LogRead,
AppAdmin,
InstanceAdmin,
}
impl Scope {
pub const ALL: &'static [Scope] = &[
Scope::ScriptRead,
Scope::ScriptWrite,
Scope::RouteWrite,
Scope::DomainManage,
Scope::LogRead,
Scope::AppAdmin,
Scope::InstanceAdmin,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ScriptRead => "script:read",
Self::ScriptWrite => "script:write",
Self::RouteWrite => "route:write",
Self::DomainManage => "domain:manage",
Self::LogRead => "log:read",
Self::AppAdmin => "app:admin",
Self::InstanceAdmin => "instance:admin",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
Self::ALL.iter().copied().find(|sc| sc.as_str() == s)
}
/// True for scopes that only make sense on an unbound key — bound
/// keys (api_keys.app_id IS NOT NULL) cannot claim instance-wide
/// authority and the mint handler rejects the combination at 422.
#[must_use]
pub const fn is_instance(self) -> bool {
matches!(self, Self::InstanceAdmin)
}
}
// Custom serde so the wire form is the colon-separated string. The
// stored DB value lives in a `text[]`, so the repo converts between
// `Vec<String>` and `Vec<Scope>` using `as_str`/`from_wire`.
impl Serialize for Scope {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Scope {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::from_wire(&s).ok_or_else(|| serde::de::Error::custom(format!("unknown scope: {s}")))
}
}
/// Resolved caller identity. Produced by `manager-core::auth_middleware`
/// for both the cookie-session path (then `scopes`/`app_binding` are
/// `None`) and the bearer-API-key path (then both fields carry the
/// key's constraints).
///
/// The capability check in `manager-core::authz::can` intersects
/// `instance_role` with `scopes` and `app_binding` to decide whether
/// a given `Capability` is granted.
#[derive(Debug, Clone)]
pub struct Principal {
pub user_id: UserId,
pub instance_role: InstanceRole,
/// `None` for cookie sessions (no scope restriction beyond the
/// role itself); `Some` for API keys, in which case the effective
/// authority is `role ∩ scopes`.
pub scopes: Option<Vec<Scope>>,
/// `Some(app)` for keys bound to a single app at mint time. Every
/// `App*(other)` capability is denied regardless of role.
pub app_binding: Option<AppId>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_role_round_trip() {
for role in [
InstanceRole::Owner,
InstanceRole::Admin,
InstanceRole::Member,
] {
assert_eq!(InstanceRole::from_db_str(role.as_str()), Some(role));
}
assert_eq!(InstanceRole::from_db_str("bogus"), None);
}
#[test]
fn app_role_round_trip() {
for role in [AppRole::AppAdmin, AppRole::Editor, AppRole::Viewer] {
assert_eq!(AppRole::from_db_str(role.as_str()), Some(role));
}
assert_eq!(AppRole::from_db_str("bogus"), None);
}
#[test]
fn scope_round_trip_covers_all() {
for &scope in Scope::ALL {
assert_eq!(Scope::from_wire(scope.as_str()), Some(scope));
}
assert_eq!(Scope::from_wire("script:nope"), None);
}
#[test]
fn scope_is_instance_flags_only_instance_admin() {
for &scope in Scope::ALL {
let expected = scope == Scope::InstanceAdmin;
assert_eq!(scope.is_instance(), expected, "scope {}", scope.as_str());
}
}
#[test]
fn scope_serde_uses_wire_form() {
let s = serde_json::to_string(&Scope::ScriptWrite).unwrap();
assert_eq!(s, "\"script:write\"");
let back: Scope = serde_json::from_str(&s).unwrap();
assert_eq!(back, Scope::ScriptWrite);
let err = serde_json::from_str::<Scope>("\"unknown\"").unwrap_err();
assert!(err.to_string().contains("unknown scope"));
}
}

View File

@@ -52,3 +52,4 @@ id_type!(ExecutionId);
id_type!(RequestId); id_type!(RequestId);
id_type!(AdminUserId); id_type!(AdminUserId);
id_type!(AppId); id_type!(AppId);
id_type!(ApiKeyId);

View File

@@ -5,6 +5,7 @@
//! entity, error roots, transport DTOs). //! entity, error roots, transport DTOs).
pub mod app; pub mod app;
pub mod auth;
pub mod error; pub mod error;
pub mod execution_log; pub mod execution_log;
pub mod ids; pub mod ids;
@@ -16,9 +17,10 @@ pub mod validator;
pub mod version; pub mod version;
pub use app::{App, AppDomain, DomainShape}; pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error; pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{AdminUserId, AppId, ExecutionId, RequestId, ScriptId}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route}; pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;

View File

@@ -2,3 +2,9 @@
build build
node_modules node_modules
package-lock.json package-lock.json
# Playwright generated artifacts
playwright-report
test-results
tests/e2e/.auth
tests/e2e/.results

View File

@@ -1,12 +1,12 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.1", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.2", "@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
@@ -20,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.0", "@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
@@ -885,6 +886,22 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -3010,6 +3027,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -1,49 +1,53 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.1", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"test": "vitest run" "test": "vitest run",
}, "test:e2e": "playwright test",
"devDependencies": { "test:e2e:ui": "playwright test --ui",
"@eslint/js": "^9.18.0", "test:e2e:install": "playwright install --with-deps chromium"
"@sveltejs/adapter-static": "^3.0.8", },
"@sveltejs/kit": "^2.17.0", "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@eslint/js": "^9.18.0",
"@types/node": "^22.10.5", "@playwright/test": "^1.60.0",
"eslint": "^9.18.0", "@sveltejs/adapter-static": "^3.0.8",
"eslint-config-prettier": "^10.0.1", "@sveltejs/kit": "^2.17.0",
"eslint-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"globals": "^15.14.0", "@types/node": "^22.10.5",
"prettier": "^3.4.2", "eslint": "^9.18.0",
"prettier-plugin-svelte": "^3.3.3", "eslint-config-prettier": "^10.0.1",
"svelte": "^5.19.0", "eslint-plugin-svelte": "^3.0.0",
"svelte-check": "^4.1.4", "globals": "^15.14.0",
"typescript": "^5.7.3", "prettier": "^3.4.2",
"typescript-eslint": "^8.20.0", "prettier-plugin-svelte": "^3.3.3",
"vite": "^6.0.7", "svelte": "^5.19.0",
"vitest": "^3.0.5" "svelte-check": "^4.1.4",
}, "typescript": "^5.7.3",
"overrides": { "typescript-eslint": "^8.20.0",
"cookie": "^0.7.2" "vite": "^6.0.7",
}, "vitest": "^3.0.5"
"dependencies": { },
"@codemirror/autocomplete": "^6.20.2", "overrides": {
"@codemirror/commands": "^6.10.3", "cookie": "^0.7.2"
"@codemirror/lang-json": "^6.0.2", },
"@codemirror/language": "^6.12.3", "dependencies": {
"@codemirror/search": "^6.7.0", "@codemirror/autocomplete": "^6.20.2",
"@codemirror/state": "^6.6.0", "@codemirror/commands": "^6.10.3",
"@codemirror/view": "^6.43.0", "@codemirror/lang-json": "^6.0.2",
"@lezer/highlight": "^1.2.3", "@codemirror/language": "^6.12.3",
"codemirror": "^6.0.2" "@codemirror/search": "^6.7.0",
} "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2"
}
} }

View File

@@ -0,0 +1,51 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
// baseURL is the origin only — the SvelteKit dashboard is mounted at
// `/admin` (svelte.config.js paths.base), so tests use full paths like
// `/admin/login` rather than relying on baseURL path resolution.
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
export default defineConfig({
testDir: './tests/e2e',
outputDir: './tests/e2e/.results',
fullyParallel: true,
forbidOnly: !!process.env.CI,
// Local: 1 retry to absorb dev-server warmup flakiness. CI: 2.
retries: process.env.CI ? 2 : 1,
// Cap at 4 workers locally to keep the shared Vite dev server
// from getting stampeded during cold-start compiles.
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI ? [['html'], ['github']] : 'html',
globalSetup: './tests/e2e/global-setup.ts',
expect: { timeout: 5_000 },
use: {
baseURL: DASHBOARD_BASE,
actionTimeout: 10_000,
navigationTimeout: 30_000,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
}
}
],
webServer: {
command: 'npm run dev',
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
timeout: 60_000
}
});

View File

@@ -0,0 +1,256 @@
<!--
Per-row "⋮" kebab menu. Hides secondary actions (edit, deactivate,
delete, etc.) behind a single trigger so list rows stay tidy.
Usage:
<ActionMenu
items={[
{ label: 'Edit', onClick: () => openEdit(row) },
{ label: row.is_active ? 'Deactivate' : 'Reactivate',
onClick: () => toggleActive(row) },
{ label: 'Delete', danger: true, onClick: () => openDelete(row),
disabled: !canDelete(row) },
]}
/>
Closes on: item click, click outside, ESC, scroll/resize. Keyboard:
Enter/Space opens; Up/Down navigate; Enter activates; ESC closes and
re-focuses the trigger. The popover is absolutely positioned relative
to the trigger and right-anchored — the parent must allow overflow
(`overflow: visible`) for it to extend past the row.
-->
<script lang="ts">
export interface MenuItem {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
interface Props {
items: MenuItem[];
/** Accessible label for the trigger button. */
label?: string;
}
let { items, label = 'More actions' }: Props = $props();
let open = $state(false);
let triggerEl = $state<HTMLButtonElement | null>(null);
let menuEl = $state<HTMLDivElement | null>(null);
let activeIndex = $state(-1);
let enabledIndices = $derived(
items
.map((it, i) => (it.disabled ? -1 : i))
.filter((i) => i >= 0)
);
function toggle() {
open ? close() : openMenu();
}
function openMenu() {
open = true;
activeIndex = enabledIndices[0] ?? -1;
}
function close(refocus = false) {
open = false;
activeIndex = -1;
if (refocus) triggerEl?.focus();
}
function activate(index: number) {
const item = items[index];
if (!item || item.disabled) return;
close();
item.onClick();
}
function moveActive(step: 1 | -1) {
if (enabledIndices.length === 0) return;
const cur = enabledIndices.indexOf(activeIndex);
const next =
cur === -1
? enabledIndices[0]
: enabledIndices[(cur + step + enabledIndices.length) % enabledIndices.length];
activeIndex = next;
}
function onTriggerKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!open) openMenu();
}
}
function onMenuKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
moveActive(1);
break;
case 'ArrowUp':
e.preventDefault();
moveActive(-1);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (activeIndex >= 0) activate(activeIndex);
break;
case 'Escape':
e.preventDefault();
close(true);
break;
case 'Tab':
close();
break;
}
}
function onWindowMouseDown(e: MouseEvent) {
if (!open) return;
const target = e.target as Node;
if (menuEl?.contains(target) || triggerEl?.contains(target)) return;
close();
}
// Close on viewport changes — naive but enough; without a portal a
// scrolling list would otherwise leave the popover drifting away from
// its row.
function onViewportChange() {
if (open) close();
}
</script>
<svelte:window
onmousedown={onWindowMouseDown}
onscroll={onViewportChange}
onresize={onViewportChange}
/>
<div class="wrap">
<button
bind:this={triggerEl}
type="button"
class="trigger"
class:open
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onclick={toggle}
onkeydown={onTriggerKeydown}
>
<!-- vertical ellipsis ⋮ — kept inline as text so it inherits color -->
<span aria-hidden="true"></span>
</button>
{#if open}
<div
bind:this={menuEl}
class="menu"
role="menu"
tabindex="-1"
onkeydown={onMenuKeydown}
>
{#each items as item, i (i)}
<button
type="button"
role="menuitem"
class="item"
class:danger={item.danger}
class:active={i === activeIndex}
disabled={item.disabled}
onclick={() => activate(i)}
onmouseenter={() => {
if (!item.disabled) activeIndex = i;
}}
>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
display: inline-flex;
justify-content: flex-end;
}
.trigger {
background: transparent;
color: #94a3b8;
border: 1px solid transparent;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.trigger:hover,
.trigger:focus-visible,
.trigger.open {
background: #1e293b;
color: #e2e8f0;
border-color: #334155;
outline: none;
}
.menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.6);
padding: 0.25rem;
display: flex;
flex-direction: column;
z-index: 50;
}
.item {
background: transparent;
color: #cbd5e1;
border: none;
text-align: left;
padding: 0.4rem 0.6rem;
font: inherit;
font-size: 0.8rem;
border-radius: 0.25rem;
cursor: pointer;
}
.item.active:not(:disabled) {
background: #1e293b;
color: #e2e8f0;
}
.item.danger {
color: #fca5a5;
}
.item.danger.active:not(:disabled) {
background: #450a0a;
color: #fecaca;
}
.item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>

View File

@@ -25,12 +25,18 @@
value = $bindable(''), value = $bindable(''),
language = 'rhai' as Language, language = 'rhai' as Language,
placeholder = '', placeholder = '',
minHeight = '12rem' minHeight = '12rem',
readOnly = false
}: { }: {
value?: string; value?: string;
language?: Language; language?: Language;
placeholder?: string; placeholder?: string;
minHeight?: string; minHeight?: string;
/** When true the editor renders without a cursor and rejects
* keystrokes. Parent-driven `value` changes still apply via
* the dispatch path below — this only blocks user edits.
* Not reactive after mount; re-mount via `{#key}` if needed. */
readOnly?: boolean;
} = $props(); } = $props();
let host: HTMLDivElement | null = null; let host: HTMLDivElement | null = null;
@@ -48,6 +54,12 @@
keymap.of([indentWithTab]), keymap.of([indentWithTab]),
dashboardSyntaxHighlighting, dashboardSyntaxHighlighting,
dashboardTheme, dashboardTheme,
// readOnly + editable together: readOnly blocks the
// underlying transactions, editable suppresses the caret
// + selection visuals so the user can see it's not
// editable.
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged && !pushingFromOutside) { if (update.docChanged && !pushingFromOutside) {
value = update.state.doc.toString(); value = update.state.doc.toString();

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import type { InstanceRole } from '$lib/auth';
import type { AppRole } from '$lib/api';
interface Props {
role?: InstanceRole;
appRole?: AppRole;
size?: 'sm' | 'md';
}
let { role, appRole, size = 'md' }: Props = $props();
// Display label: app roles read better with a space ("app admin")
// than their wire form ("app_admin").
const label = $derived(
appRole ? appRole.replace('_', ' ') : (role ?? '')
);
const cls = $derived(appRole ? `chip-${appRole}` : `chip-${role}`);
</script>
<span class="chip {cls}" class:sm={size === 'sm'}>{label}</span>
<style>
.chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid transparent;
}
.chip.sm {
font-size: 0.625rem;
padding: 0.1rem 0.45rem;
}
.chip-owner {
background: #78350f;
color: #fbbf24;
border-color: #b45309;
}
.chip-admin {
background: #164e63;
color: #67e8f9;
border-color: #0e7490;
}
.chip-member {
background: #1e293b;
color: #cbd5e1;
border-color: #334155;
}
.chip-app_admin {
background: #4c1d95;
color: #c4b5fd;
border-color: #6d28d9;
}
.chip-editor {
background: #1e3a8a;
color: #93c5fd;
border-color: #1d4ed8;
}
.chip-viewer {
background: #1f2937;
color: #9ca3af;
border-color: #374151;
}
</style>

View File

@@ -8,7 +8,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { base } from '$app/paths'; import { base } from '$app/paths';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { clearSession, getToken, setSession, type AdminUser } from './auth'; import { clearSession, getToken, setSession, type InstanceRole } from './auth';
export type { InstanceRole };
export interface ScriptSandbox { export interface ScriptSandbox {
max_operations?: number; max_operations?: number;
@@ -42,6 +44,8 @@ export interface App {
updated_at: string; updated_at: string;
} }
export type AppRole = 'app_admin' | 'editor' | 'viewer';
export type DomainShape = 'exact' | 'wildcard' | 'parameterized'; export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain { export interface AppDomain {
@@ -62,6 +66,11 @@ export interface AppLookupResponse {
updated_at: string; updated_at: string;
/// Present only when the requested slug was a retired redirect. /// Present only when the requested slug was a retired redirect.
redirect_to?: string; redirect_to?: string;
/// The caller's role on this app — owners are implicit `app_admin`,
/// admins implicit `editor`, members carry their `app_members.role`.
/// `null` only when a member somehow reaches the endpoint without
/// a membership (the server normally 403s first).
my_role: AppRole | null;
} }
export interface SlugCheckResponse { export interface SlugCheckResponse {
@@ -232,10 +241,42 @@ function safeJson(text: string): unknown {
} }
} }
export interface AdminUserRecord { export type Scope =
| 'script:read'
| 'script:write'
| 'route:write'
| 'domain:manage'
| 'log:read'
| 'app:admin'
| 'instance:admin';
export const ALL_SCOPES: readonly Scope[] = [
'script:read',
'script:write',
'route:write',
'domain:manage',
'log:read',
'app:admin',
'instance:admin'
] as const;
export function isInstanceScope(s: Scope): boolean {
return s.startsWith('instance:');
}
export interface MeDto {
id: string;
username: string;
instance_role: InstanceRole;
email: string | null;
}
export interface AdminDto {
id: string; id: string;
username: string; username: string;
is_active: boolean; is_active: boolean;
instance_role: InstanceRole;
email: string | null;
created_at: string; created_at: string;
last_login_at: string | null; last_login_at: string | null;
} }
@@ -243,16 +284,57 @@ export interface AdminUserRecord {
export interface CreateAdminInput { export interface CreateAdminInput {
username: string; username: string;
password: string; password: string;
instance_role?: InstanceRole;
email?: string | null;
} }
export interface PatchAdminInput { export interface PatchAdminInput {
username?: string; username?: string;
password?: string; password?: string;
is_active?: boolean; is_active?: boolean;
instance_role?: InstanceRole;
email?: string | null;
}
export interface AppMemberDto {
user_id: string;
username: string;
email: string | null;
instance_role: InstanceRole;
is_active: boolean;
role: AppRole;
created_at: string;
}
export interface GrantAppMemberInput {
user_id: string;
role: AppRole;
}
export interface ApiKeyDto {
id: string;
prefix: string;
name: string;
scopes: Scope[];
app_id: string | null;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
}
export interface MintApiKeyInput {
name: string;
scopes: Scope[];
app_id?: string | null;
expires_at?: string | null;
}
export interface MintApiKeyResponse extends ApiKeyDto {
raw_token: string;
} }
interface LoginResponse { interface LoginResponse {
user: AdminUser; user: MeDto;
token: string; token: string;
expires_at: string; expires_at: string;
} }
@@ -263,7 +345,7 @@ export const api = {
version: () => adminRequest<VersionInfo>('/version'), version: () => adminRequest<VersionInfo>('/version'),
auth: { auth: {
login: async (username: string, password: string): Promise<AdminUser> => { login: async (username: string, password: string): Promise<MeDto> => {
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', { const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
@@ -282,19 +364,19 @@ export const api = {
clearSession(); clearSession();
} }
}, },
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me') me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
}, },
admins: { admins: {
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'), list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`), get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
create: (input: CreateAdminInput) => create: (input: CreateAdminInput) =>
adminRequest<AdminUserRecord>('/api/v1/admin/admins', { adminRequest<AdminDto>('/api/v1/admin/admins', {
method: 'POST', method: 'POST',
body: JSON.stringify(input) body: JSON.stringify(input)
}), }),
update: (id: string, input: PatchAdminInput) => update: (id: string, input: PatchAdminInput) =>
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, { adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(input) body: JSON.stringify(input)
}), }),
@@ -302,6 +384,17 @@ export const api = {
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' }) adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
}, },
apiKeys: {
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
mint: (input: MintApiKeyInput) =>
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
method: 'POST',
body: JSON.stringify(input)
}),
revoke: (id: string) =>
adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
},
routes: { routes: {
listForScript: (scriptId: string) => listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`), adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
@@ -401,6 +494,28 @@ export const api = {
) )
}, },
appMembers: {
list: (idOrSlug: string) =>
adminRequest<AppMemberDto[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
),
add: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<AppMemberDto>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
setRole: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<AppMemberDto>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
},
execute: async ( execute: async (
id: string, id: string,
body: unknown, body: unknown,

View File

@@ -10,9 +10,13 @@
import { writable, get } from 'svelte/store'; import { writable, get } from 'svelte/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
export type InstanceRole = 'owner' | 'admin' | 'member';
export interface AdminUser { export interface AdminUser {
id: string; id: string;
username: string; username: string;
instance_role: InstanceRole;
email: string | null;
} }
const TOKEN_KEY = 'picloud.admin.token'; const TOKEN_KEY = 'picloud.admin.token';

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import type { AppRole, MeDto } from './api';
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
function me(role: MeDto['instance_role']): MeDto {
return { id: 'u', username: 'u', instance_role: role, email: null };
}
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
describe('capabilities', () => {
it('null caller is denied everything', () => {
expect(canCreateApp(null)).toBe(false);
expect(canManageUsers(null)).toBe(false);
expect(canWriteApp(null, 'app_admin')).toBe(false);
expect(canAdminApp(null, 'app_admin')).toBe(false);
});
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
expect(canCreateApp(me('owner'))).toBe(true);
expect(canCreateApp(me('admin'))).toBe(true);
expect(canCreateApp(me('member'))).toBe(false);
expect(canManageUsers(me('owner'))).toBe(true);
expect(canManageUsers(me('admin'))).toBe(true);
expect(canManageUsers(me('member'))).toBe(false);
});
it('owner + admin can write and admin every app regardless of my_role', () => {
for (const role of ['owner', 'admin'] as const) {
for (const appRole of APP_ROLES) {
expect(canWriteApp(me(role), appRole)).toBe(true);
expect(canAdminApp(me(role), appRole)).toBe(true);
}
}
});
it('member: write requires app_admin or editor; admin requires app_admin', () => {
const m = me('member');
expect(canWriteApp(m, 'app_admin')).toBe(true);
expect(canWriteApp(m, 'editor')).toBe(true);
expect(canWriteApp(m, 'viewer')).toBe(false);
expect(canWriteApp(m, null)).toBe(false);
expect(canAdminApp(m, 'app_admin')).toBe(true);
expect(canAdminApp(m, 'editor')).toBe(false);
expect(canAdminApp(m, 'viewer')).toBe(false);
expect(canAdminApp(m, null)).toBe(false);
});
it('canAdminApp implies canWriteApp for every combination', () => {
for (const role of ROLES) {
for (const appRole of APP_ROLES) {
if (canAdminApp(me(role), appRole)) {
expect(canWriteApp(me(role), appRole)).toBe(true);
}
}
}
});
});

View File

@@ -0,0 +1,43 @@
// Permission predicates the dashboard uses to shadow create / edit /
// delete affordances. Mirrors the canonical role → capability rules in
// crates/manager-core/src/authz.rs:
//
// owner / admin instance role → implicit app_admin on every app
// app_admin → settings, domain claims, delete app, delete scripts
// editor → CRUD on scripts, routes, sandbox config (no script delete)
// viewer → read scripts + execution logs
// member with no membership → no access
//
// These helpers are read-only and have no Svelte runes — callers pass
// the current `MeDto` and (when relevant) the per-app `my_role` they
// already hold. Hiding here never authorizes anything; the backend's
// `require(Capability::…)` is always the ground truth.
import type { AppRole, MeDto } from './api';
/** Owner + admin only. Members never see "New app". */
export function canCreateApp(me: MeDto | null): boolean {
if (!me) return false;
return me.instance_role === 'owner' || me.instance_role === 'admin';
}
/** Owner + admin only — the "Users" admin page is also gated this way. */
export function canManageUsers(me: MeDto | null): boolean {
if (!me) return false;
return me.instance_role === 'owner' || me.instance_role === 'admin';
}
/** Can mutate scripts and routes (Save, +Add route, remove route). */
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
if (!me) return false;
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
return appMyRole === 'app_admin' || appMyRole === 'editor';
}
/** Can take app-admin actions: app settings, domain claims, delete
* app, delete scripts, manage members. */
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
if (!me) return false;
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
return appMyRole === 'app_admin';
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { generatePassword } from './password-gen';
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
describe('generatePassword', () => {
it('rejects lengths under 8', () => {
expect(() => generatePassword(7)).toThrowError(/at least 8/);
});
it('respects the requested length', () => {
for (const len of [8, 16, 32, 64]) {
expect(generatePassword(len)).toHaveLength(len);
}
});
it('uses only characters from the documented charset', () => {
const set = new Set(CHARSET);
for (let i = 0; i < 1000; i++) {
for (const c of generatePassword(32)) {
expect(set.has(c)).toBe(true);
}
}
});
// Rejection-sampling sanity. With N = 71 the expected count per
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
// any byte-level bias (biased modulo would push the first 38
// chars by ~16 ppm — too small for this band to flag on its
// own, but a regression to `% N` over Uint16/Uint32 with a
// non-power-of-two charset would still produce visible drift in
// pathological codepaths). Mostly this guards against
// fundamental mistakes (off-by-one in the loop, returning the
// same byte stream every time, etc.).
it('distribution stays within a wide tolerance band', () => {
const samples = 100_000;
const counts = new Map<string, number>();
for (let i = 0; i < samples; i++) {
const c = generatePassword(8)[0];
counts.set(c, (counts.get(c) ?? 0) + 1);
}
const expected = samples / CHARSET.length;
const sigma = Math.sqrt(expected);
const band = 6 * sigma;
for (const c of CHARSET) {
const observed = counts.get(c) ?? 0;
const drift = Math.abs(observed - expected);
expect(
drift,
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
).toBeLessThan(band);
}
});
});

View File

@@ -0,0 +1,37 @@
// Cryptographically random password generator for the user-create
// and reset-password flows. PiCloud has no email yet, so the admin
// invites a user by generating a password locally, posting it to the
// backend, and copying the cleartext out of the one-time reveal panel
// to share through whatever channel they trust.
//
// Charset is alphanumeric plus a small printable symbol set — enough
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
// avoidant of characters that ship awkwardly through chat clients
// (no quotes, slashes, or backticks).
//
// Sampling: rejection sampling against a Uint8 stream. The naive
// `byte % CHARSET.length` would slightly overweight the first
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
// safe at 16 chars but easy to remove.
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
export function generatePassword(length = 16): string {
if (length < 8) {
throw new Error('password length must be at least 8');
}
const n = CHARSET.length;
// Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
// rejected to remove modulo bias.
const max = 256 - (256 % n);
const buf = new Uint8Array(length);
let out = '';
while (out.length < length) {
crypto.getRandomValues(buf);
for (let i = 0; i < buf.length && out.length < length; i++) {
const byte = buf[i];
if (byte < max) out += CHARSET[byte % n];
}
}
return out;
}

View File

@@ -5,6 +5,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { currentUser, getToken } from '$lib/auth'; import { currentUser, getToken } from '$lib/auth';
import RoleChip from '$lib/RoleChip.svelte';
let { children } = $props(); let { children } = $props();
@@ -46,12 +47,17 @@
<a href={base + '/'} class="brand">PiCloud</a> <a href={base + '/'} class="brand">PiCloud</a>
<nav> <nav>
<a href={base + '/apps'}>Apps</a> <a href={base + '/apps'}>Apps</a>
<a href={base + '/admins'}>Admins</a> {#if user && user.instance_role !== 'member'}
<a href={base + '/users'}>Users</a>
{/if}
</nav> </nav>
<div class="spacer"></div> <div class="spacer"></div>
{#if user} {#if user}
<div class="usermenu"> <div class="usermenu">
<span class="username">{user.username}</span> <a href={base + '/profile'} class="profile-chip" title="View profile">
<RoleChip role={user.instance_role} size="sm" />
<span class="username">{user.username}</span>
</a>
<button type="button" class="logout" onclick={handleLogout}>Logout</button> <button type="button" class="logout" onclick={handleLogout}>Logout</button>
</div> </div>
{/if} {/if}
@@ -121,6 +127,20 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.profile-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.55rem;
border-radius: 0.375rem;
text-decoration: none;
border: 1px solid transparent;
}
.profile-chip:hover {
background: #1e293b;
border-color: #334155;
}
.username { .username {
color: #cbd5e1; color: #cbd5e1;
} }

View File

@@ -1,687 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { onMount } from 'svelte';
import { api, ApiError, type AdminUserRecord } from '$lib/api';
import { currentUser } from '$lib/auth';
let admins = $state<AdminUserRecord[]>([]);
let loadError = $state<string | null>(null);
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
const me = $derived($currentUser);
let createOpen = $state(false);
let createForm = $state({ username: '', password: '', confirm: '' });
let createPending = $state(false);
let createError = $state<string | null>(null);
let passwordTarget = $state<AdminUserRecord | null>(null);
let passwordForm = $state({ password: '', confirm: '' });
let passwordPending = $state(false);
let passwordError = $state<string | null>(null);
let deleteTarget = $state<AdminUserRecord | null>(null);
let deletePending = $state(false);
let actionsOpenFor = $state<string | null>(null);
onMount(refresh);
async function refresh() {
loadError = null;
try {
admins = await api.admins.list();
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
}
}
function flash(kind: 'error' | 'info', message: string) {
banner = { kind, message };
setTimeout(() => {
if (banner?.message === message) banner = null;
}, 6000);
}
function openCreate() {
createForm = { username: '', password: '', confirm: '' };
createError = null;
createOpen = true;
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
createError = null;
if (createForm.password !== createForm.confirm) {
createError = 'Passwords do not match';
return;
}
createPending = true;
try {
await api.admins.create({
username: createForm.username.trim(),
password: createForm.password
});
createOpen = false;
await refresh();
flash('info', `Created admin "${createForm.username.trim()}".`);
} catch (e) {
createError = e instanceof ApiError ? e.message : 'failed to create admin';
} finally {
createPending = false;
}
}
function openPassword(row: AdminUserRecord) {
passwordTarget = row;
passwordForm = { password: '', confirm: '' };
passwordError = null;
actionsOpenFor = null;
}
async function submitPassword(event: SubmitEvent) {
event.preventDefault();
if (!passwordTarget) return;
passwordError = null;
if (passwordForm.password !== passwordForm.confirm) {
passwordError = 'Passwords do not match';
return;
}
passwordPending = true;
try {
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
const name = passwordTarget.username;
passwordTarget = null;
flash('info', `Password updated for "${name}".`);
} catch (e) {
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
} finally {
passwordPending = false;
}
}
async function toggleActive(row: AdminUserRecord) {
actionsOpenFor = null;
try {
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
}
}
function openDelete(row: AdminUserRecord) {
deleteTarget = row;
actionsOpenFor = null;
}
async function confirmDelete() {
if (!deleteTarget) return;
deletePending = true;
const target = deleteTarget;
try {
await api.admins.remove(target.id);
deleteTarget = null;
if (me && me.id === target.id) {
// Just deleted ourselves — sign out and bounce.
await api.auth.logout();
await goto(`${base}/login`);
return;
}
await refresh();
flash('info', `Deleted "${target.username}".`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
} finally {
deletePending = false;
}
}
function toggleActions(id: string) {
actionsOpenFor = actionsOpenFor === id ? null : id;
}
function relative(iso: string | null): string {
if (!iso) return 'Never';
const then = new Date(iso).getTime();
const now = Date.now();
const sec = Math.round((now - then) / 1000);
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
const day = Math.round(hr / 24);
if (day === 1) return 'Yesterday';
if (day < 7) return `${day} days ago`;
return new Date(iso).toLocaleDateString();
}
function absolute(iso: string | null): string {
return iso ? new Date(iso).toISOString() : '';
}
function shortDate(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
</script>
<header class="head">
<h1>Admin Users</h1>
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
</header>
{#if banner}
<div class="banner banner-{banner.kind}">{banner.message}</div>
{/if}
{#if loadError}
<div class="error">
{loadError}
<button type="button" class="retry" onclick={refresh}>Retry</button>
</div>
{:else if admins.length === 0}
<p class="empty">No admin users yet. Add one to get started.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>Username</div>
<div>Status</div>
<div>Created</div>
<div>Last login</div>
<div class="actions-col"></div>
</div>
{#each admins as row (row.id)}
<div class="row">
<div class="username-cell">
<span class="name">{row.username}</span>
{#if me && me.id === row.id}
<span class="you-tag">(you)</span>
{/if}
</div>
<div>
{#if row.is_active}
<span class="status status-active">● Active</span>
{:else}
<span class="status status-inactive">○ Inactive</span>
{/if}
</div>
<div>{shortDate(row.created_at)}</div>
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
<div class="actions-col">
<button
type="button"
class="kebab"
aria-label="Actions for {row.username}"
onclick={() => toggleActions(row.id)}
>
</button>
{#if actionsOpenFor === row.id}
<div class="menu">
<button type="button" onclick={() => openPassword(row)}>Change password</button>
<button type="button" onclick={() => toggleActive(row)}>
{row.is_active ? 'Deactivate' : 'Reactivate'}
</button>
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- New admin modal -->
{#if createOpen}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) createOpen = false;
}}
>
<form class="modal" onsubmit={submitCreate}>
<div class="modal-head">
<h2>New admin user</h2>
<button
type="button"
class="x"
aria-label="Close"
onclick={() => (createOpen = false)}>✕</button
>
</div>
<label>
<span>Username</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
bind:value={createForm.username}
required
/>
<small>Lowercase letters, digits, . _ -</small>
</label>
<label>
<span>Password</span>
<input
type="password"
autocomplete="new-password"
bind:value={createForm.password}
required
/>
<small>Minimum 8 characters</small>
</label>
<label>
<span>Confirm password</span>
<input
type="password"
autocomplete="new-password"
bind:value={createForm.confirm}
required
/>
</label>
{#if createError}
<div class="error">{createError}</div>
{/if}
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
<button type="submit" class="primary" disabled={createPending}>
{createPending ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</div>
{/if}
<!-- Change password modal -->
{#if passwordTarget}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) passwordTarget = null;
}}
>
<form class="modal" onsubmit={submitPassword}>
<div class="modal-head">
<h2>Change password — {passwordTarget.username}</h2>
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
>✕</button
>
</div>
<label>
<span>New password</span>
<input
type="password"
autocomplete="new-password"
bind:value={passwordForm.password}
required
/>
</label>
<label>
<span>Confirm password</span>
<input
type="password"
autocomplete="new-password"
bind:value={passwordForm.confirm}
required
/>
</label>
{#if passwordError}
<div class="error">{passwordError}</div>
{/if}
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
<button type="submit" class="primary" disabled={passwordPending}>
{passwordPending ? 'Updating…' : 'Update'}
</button>
</div>
</form>
</div>
{/if}
<!-- Delete confirmation modal -->
{#if deleteTarget}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) deleteTarget = null;
}}
>
<div class="modal">
<div class="modal-head">
<h2>Delete {deleteTarget.username}?</h2>
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
>✕</button
>
</div>
{#if me && me.id === deleteTarget.id}
<p>
You are about to delete <strong>your own</strong> account. You will be signed out immediately
and will not be able to sign back in with these credentials.
</p>
{:else}
<p>
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
This cannot be undone.
</p>
{/if}
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
{deletePending ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
</div>
{/if}
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.25rem;
margin: 0;
color: #e2e8f0;
}
.banner {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.banner-error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
}
.banner-info {
background: #0c2a36;
border: 1px solid #155e75;
color: #a5f3fc;
}
.empty {
color: #64748b;
text-align: center;
padding: 3rem 0;
border: 1px dashed #1e293b;
border-radius: 0.5rem;
}
.table {
display: flex;
flex-direction: column;
border: 1px solid #1e293b;
border-radius: 0.5rem;
overflow: visible;
background: #0b1220;
}
.row {
display: grid;
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.9rem;
}
.row:last-child {
border-bottom: none;
}
.head-row {
color: #94a3b8;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #0f172a;
}
.username-cell {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.name {
color: #e2e8f0;
font-weight: 500;
}
.you-tag {
color: #64748b;
font-size: 0.75rem;
}
.status {
font-size: 0.8rem;
}
.status-active {
color: #34d399;
}
.status-inactive {
color: #64748b;
}
.actions-col {
position: relative;
display: flex;
justify-content: flex-end;
}
.kebab {
background: transparent;
border: none;
color: #94a3b8;
font-size: 1.25rem;
cursor: pointer;
padding: 0 0.5rem;
border-radius: 0.25rem;
}
.kebab:hover {
background: #1e293b;
color: #e2e8f0;
}
.menu {
position: absolute;
top: 100%;
right: 0;
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.375rem;
display: flex;
flex-direction: column;
min-width: 12rem;
z-index: 10;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
.menu button {
background: transparent;
border: none;
color: #cbd5e1;
text-align: left;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.menu button:hover {
background: #1e293b;
color: #e2e8f0;
}
.menu button.danger {
color: #fca5a5;
}
.menu button.danger:hover {
background: #450a0a;
color: #fecaca;
}
.error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.retry {
background: transparent;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.25rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
button.primary {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.55rem 0.9rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
font-size: 0.875rem;
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.ghost {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
padding: 0.5rem 0.9rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
}
button.ghost:hover {
background: #1e293b;
color: #e2e8f0;
}
button.danger {
background: #b91c1c;
color: #fef2f2;
border: none;
padding: 0.55rem 0.9rem;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 600;
font-size: 0.875rem;
}
button.danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
}
.modal {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.5rem;
padding: 1.5rem;
min-width: 24rem;
max-width: 28rem;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal h2 {
margin: 0;
font-size: 1rem;
color: #e2e8f0;
}
.x {
background: transparent;
border: none;
color: #64748b;
font-size: 1.1rem;
cursor: pointer;
}
.x:hover {
color: #e2e8f0;
}
.modal label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.modal label small {
color: #64748b;
font-size: 0.75rem;
}
.modal input {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
box-sizing: border-box;
}
.modal input:focus {
outline: none;
border-color: #38bdf8;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.5rem;
}
p {
color: #cbd5e1;
font-size: 0.9rem;
margin: 0;
line-height: 1.5;
}
</style>

View File

@@ -2,6 +2,11 @@
import { base } from '$app/paths'; import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api'; import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify'; import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
const canCreate = $derived(canCreateApp(me));
let apps = $state<App[] | null>(null); let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null); let listError = $state<string | null>(null);
@@ -99,18 +104,20 @@
<section> <section>
<header class="page-header"> <header class="page-header">
<h1>Apps</h1> <h1>Apps</h1>
<button {#if canCreate}
type="button" <button
onclick={() => { type="button"
showCreate = !showCreate; onclick={() => {
if (!showCreate) resetCreate(); showCreate = !showCreate;
}} if (!showCreate) resetCreate();
> }}
{showCreate ? 'Cancel' : 'New app'} >
</button> {showCreate ? 'Cancel' : 'New app'}
</button>
{/if}
</header> </header>
{#if showCreate} {#if showCreate && canCreate}
<form class="create-form" onsubmit={(e) => submitCreate(e)}> <form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row"> <div class="row">
<label> <label>

View File

@@ -5,26 +5,44 @@
import { import {
api, api,
ApiError, ApiError,
type AdminDto,
type App, type App,
type AppDomain, type AppDomain,
type AppMemberDto,
type AppRole,
type Script type Script
} from '$lib/api'; } from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte'; import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser);
const SAMPLE_SOURCE = const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'settings'; type Tab = 'scripts' | 'domains' | 'members' | 'settings';
let slug = $derived(page.params.slug ?? ''); let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null); let app = $state<App | null>(null);
let myRole = $state<AppRole | null>(null);
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let loading = $state(true); let loading = $state(true);
let activeTab = $state<Tab>('scripts'); let activeTab = $state<Tab>('scripts');
let scripts = $state<Script[]>([]); let scripts = $state<Script[]>([]);
let domains = $state<AppDomain[]>([]); let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
// Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
// covers New script.
const canWrite = $derived(canWriteApp(me, myRole));
const canAdmin = $derived(canAdminApp(me, myRole));
// Script create // Script create
let showCreateScript = $state(false); let showCreateScript = $state(false);
@@ -55,6 +73,19 @@
let removingDomain = $state(false); let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null); let removeDomainError = $state<string | null>(null);
// Members tab
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
let addMemberUserId = $state('');
let addMemberRole = $state<AppRole>('viewer');
let addingMember = $state(false);
let addMemberError = $state<string | null>(null);
let memberToRemove = $state<AppMemberDto | null>(null);
let removingMember = $state(false);
let removeMemberError = $state<string | null>(null);
let roleChangeBusy = $state<string | null>(null);
let memberActionError = $state<string | null>(null);
async function loadApp() { async function loadApp() {
loading = true; loading = true;
loadError = null; loadError = null;
@@ -72,10 +103,15 @@
created_at: fetched.created_at, created_at: fetched.created_at,
updated_at: fetched.updated_at updated_at: fetched.updated_at
}; };
myRole = fetched.my_role;
editName = app.name; editName = app.name;
editDescription = app.description ?? ''; editDescription = app.description ?? '';
editSlug = app.slug; editSlug = app.slug;
await Promise.all([loadScripts(app.id), loadDomains(app.id)]); const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
await Promise.all(loaders);
} catch (e) { } catch (e) {
loadError = e instanceof Error ? e.message : String(e); loadError = e instanceof Error ? e.message : String(e);
} finally { } finally {
@@ -101,6 +137,42 @@
} }
} }
async function loadMembers(appId: string) {
try {
members = await api.appMembers.list(appId);
} catch (e) {
members = [];
memberActionError = e instanceof Error ? e.message : String(e);
}
}
async function loadEligibleUsers() {
eligibleLoadError = null;
try {
const all = await api.admins.list();
// Only inactive=false members are valid invite targets — the
// API rejects everyone else anyway, so filter upfront.
eligibleUsers = all.filter(
(u) => u.is_active && u.instance_role === 'member'
);
} catch (e) {
eligibleUsers = [];
// member-with-app_admin can hit /apps/.../members but cannot
// browse /admins (gated on InstanceManageUsers). The add form
// will render disabled with the explanatory message below.
eligibleLoadError =
e instanceof ApiError && e.status === 403
? 'Only instance owners/admins can browse the user directory to invite new members.'
: e instanceof Error
? e.message
: String(e);
}
}
const eligibleAfterFilter = $derived(
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
);
async function submitCreateScript(event: Event) { async function submitCreateScript(event: Event) {
event.preventDefault(); event.preventDefault();
if (!app) return; if (!app) return;
@@ -201,6 +273,76 @@
} }
} }
async function submitAddMember(event: Event) {
event.preventDefault();
if (!app || !addMemberUserId) return;
addingMember = true;
addMemberError = null;
try {
await api.appMembers.add(app.id, {
user_id: addMemberUserId,
role: addMemberRole
});
addMemberUserId = '';
addMemberRole = 'viewer';
await loadMembers(app.id);
} catch (e) {
addMemberError = e instanceof Error ? e.message : String(e);
} finally {
addingMember = false;
}
}
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
if (!app || member.role === role) return;
roleChangeBusy = member.user_id;
memberActionError = null;
try {
await api.appMembers.setRole(app.id, member.user_id, role);
await loadMembers(app.id);
} catch (e) {
memberActionError = e instanceof Error ? e.message : String(e);
} finally {
roleChangeBusy = null;
}
}
function askRemoveMember(member: AppMemberDto) {
removeMemberError = null;
memberToRemove = member;
}
async function confirmRemoveMember() {
if (!app || !memberToRemove) return;
removingMember = true;
removeMemberError = null;
try {
const removedSelf = !!me && memberToRemove.user_id === me.id;
await api.appMembers.remove(app.id, memberToRemove.user_id);
memberToRemove = null;
if (removedSelf) {
// We just revoked our own access to this app; the next
// fetch of /apps/{slug} would 403. Bounce back to the
// apps list rather than render a broken tab.
await goto(`${base}/apps`);
return;
}
await loadMembers(app.id);
} catch (e) {
removeMemberError = e instanceof Error ? e.message : String(e);
} finally {
removingMember = false;
}
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
function askDeleteApp() { function askDeleteApp() {
deleteAppError = null; deleteAppError = null;
confirmingDeleteApp = true; confirmingDeleteApp = true;
@@ -226,6 +368,16 @@
$effect(() => { $effect(() => {
void loadApp(); void loadApp();
}); });
// Defense-in-depth: a viewer / editor following a stale link to
// the Settings or Members tab gets bounced back to Scripts. The
// backend still 403s the underlying calls, but no point showing an
// empty tab.
$effect(() => {
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
activeTab = 'scripts';
}
});
</script> </script>
{#if loading && !app} {#if loading && !app}
@@ -258,26 +410,35 @@
class:active={activeTab === 'domains'} class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
> >
<button {#if canAdmin}
type="button" <button
class:active={activeTab === 'settings'} type="button"
onclick={() => (activeTab = 'settings')}>Settings</button class:active={activeTab === 'members'}
> onclick={() => (activeTab = 'members')}>Members ({members.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
{/if}
</nav> </nav>
{#if activeTab === 'scripts'} {#if activeTab === 'scripts'}
<section> <section>
<div class="row"> <div class="row">
<h2>Scripts</h2> <h2>Scripts</h2>
<button {#if canWrite}
type="button" <button
onclick={() => (showCreateScript = !showCreateScript)} type="button"
> onclick={() => (showCreateScript = !showCreateScript)}
{showCreateScript ? 'Cancel' : 'New script'} >
</button> {showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div> </div>
{#if showCreateScript} {#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}> <form class="create-form" onsubmit={submitCreateScript}>
<div class="row"> <div class="row">
<label> <label>
@@ -330,18 +491,20 @@
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture. wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p> </p>
<form class="create-form inline" onsubmit={submitCreateDomain}> {#if canAdmin}
<input <form class="create-form inline" onsubmit={submitCreateDomain}>
bind:value={createDomainPattern} <input
required bind:value={createDomainPattern}
placeholder="app.example.com" required
/> placeholder="app.example.com"
<button type="submit" disabled={creatingDomain}> />
{creatingDomain ? 'Adding…' : 'Add domain'} <button type="submit" disabled={creatingDomain}>
</button> {creatingDomain ? 'Adding…' : 'Add domain'}
</form> </button>
{#if createDomainError} </form>
<div class="error">{createDomainError}</div> {#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if} {/if}
{#if domains.length === 0} {#if domains.length === 0}
<p class="muted">No domain claims yet.</p> <p class="muted">No domain claims yet.</p>
@@ -353,19 +516,136 @@
<code>{d.pattern}</code> <code>{d.pattern}</code>
<span class="muted">{d.shape}</span> <span class="muted">{d.shape}</span>
</div> </div>
<button {#if canAdmin}
type="button" <button
class="secondary danger" type="button"
onclick={() => askRemoveDomain(d)} class="secondary danger"
> onclick={() => askRemoveDomain(d)}
Delete >
</button> Delete
</button>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</section> </section>
{:else if activeTab === 'settings'} {:else if activeTab === 'members' && canAdmin}
<section>
<h2>Members</h2>
<p class="muted">
Users with explicit access to this app. Instance owners and admins
already have implicit access — they are not listed here. Use the Users
page to invite a <code>member</code> first, then grant them app access
below.
</p>
<form class="create-form" onsubmit={submitAddMember}>
<div class="row">
<label class="grow">
<span>User</span>
<select
bind:value={addMemberUserId}
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
required
>
<option value="" disabled>Pick a member to invite…</option>
{#each eligibleAfterFilter as u (u.id)}
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Role</span>
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="app_admin">app admin</option>
</select>
</label>
</div>
{#if eligibleLoadError}
<p class="muted">{eligibleLoadError}</p>
{:else if eligibleAfterFilter.length === 0}
<p class="muted">
No eligible users to invite. Create a <code>member</code> on the Users
page first.
</p>
{/if}
{#if addMemberError}
<div class="error">{addMemberError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
>
{addingMember ? 'Adding…' : 'Add member'}
</button>
</div>
</form>
{#if memberActionError}
<div class="error">{memberActionError}</div>
{/if}
{#if members.length === 0}
<p class="muted">No explicit members yet.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>User</div>
<div>Instance</div>
<div>App role</div>
<div>Joined</div>
<div class="actions-col"></div>
</div>
{#each members as m (m.user_id)}
<div class="row member-row" class:inactive={!m.is_active}>
<div>
<strong>{m.username}</strong>
{#if m.email}<span class="muted">{m.email}</span>{/if}
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
</div>
<div><RoleChip role={m.instance_role} size="sm" /></div>
<div><RoleChip appRole={m.role} size="sm" /></div>
<div>{shortDate(m.created_at)}</div>
<div class="actions-col">
<ActionMenu
label="Member actions for {m.username}"
items={[
{
label: 'Make app admin',
disabled:
m.role === 'app_admin' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'app_admin')
},
{
label: 'Make editor',
disabled:
m.role === 'editor' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'editor')
},
{
label: 'Make viewer',
disabled:
m.role === 'viewer' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'viewer')
},
{
label: 'Remove from app',
danger: true,
onClick: () => askRemoveMember(m)
}
]}
/>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'settings' && canAdmin}
<section> <section>
<h2>Settings</h2> <h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}> <form class="create-form" onsubmit={(e) => saveSettings(e)}>
@@ -502,6 +782,26 @@
{/if} {/if}
</ConfirmModal> </ConfirmModal>
{/if} {/if}
{#if memberToRemove}
<ConfirmModal
title="Remove {memberToRemove.username} from {app.name}"
variant="danger"
confirmLabel="Remove member"
busyLabel="Removing…"
busy={removingMember}
onConfirm={confirmRemoveMember}
onCancel={() => (memberToRemove = null)}
>
<p>
<strong>{memberToRemove.username}</strong> will lose access to this
app. Their other app memberships and account are untouched.
</p>
{#if removeMemberError}
<p class="modal-error">{removeMemberError}</p>
{/if}
</ConfirmModal>
{/if}
{/if} {/if}
<style> <style>
@@ -744,4 +1044,60 @@
border-radius: 0.5rem; border-radius: 0.5rem;
background: #1e0a0a; background: #1e0a0a;
} }
.create-form select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.create-form .row > label.grow {
grid-column: span 2;
}
.table {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table .row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
align-items: center;
margin: 0;
}
.table .head-row {
background: transparent;
padding: 0.25rem 1rem;
color: #64748b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table .member-row.inactive {
opacity: 0.55;
}
.table .member-row strong {
margin-right: 0.4rem;
}
.table .member-row .muted {
font-size: 0.8rem;
}
.table .actions-col {
display: flex;
justify-content: flex-end;
}
</style> </style>

View File

@@ -0,0 +1,760 @@
<!--
/admin/profile — every authenticated principal lands here for their
own identity + API-key management. No role gating: a member can mint
keys for the apps they belong to just like an admin can. Users-admin
actions live under /admin/users.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
api,
ApiError,
ALL_SCOPES,
isInstanceScope,
type ApiKeyDto,
type App,
type MintApiKeyResponse,
type Scope
} from '$lib/api';
import { currentUser } from '$lib/auth';
import RoleChip from '$lib/RoleChip.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
const me = $derived($currentUser);
let keys = $state<ApiKeyDto[]>([]);
let apps = $state<App[]>([]);
let appBySlug = $derived(new Map(apps.map((a) => [a.id, a])));
let loadError = $state<string | null>(null);
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
// Surface the cross-page "access denied" notice when /users bounces
// a member back here. One-shot — clears as soon as the user
// navigates away or dismisses.
const deniedFromUsers = $derived(page.url.searchParams.get('denied') === 'users');
let mintOpen = $state(false);
let mintForm = $state<{
name: string;
scopes: Set<Scope>;
app_id: string | '';
expires_at: string;
}>({ name: '', scopes: new Set(), app_id: '', expires_at: '' });
let mintPending = $state(false);
let mintError = $state<string | null>(null);
let reveal = $state<MintApiKeyResponse | null>(null);
let revealAck = $state(false);
let copyState = $state<'idle' | 'copied'>('idle');
let revokeTarget = $state<ApiKeyDto | null>(null);
let revokePending = $state(false);
const NAME_MAX = 64;
const scopeIsInstance = (s: Scope) => isInstanceScope(s);
const boundToApp = $derived(mintForm.app_id !== '');
const canSubmit = $derived(
mintForm.name.trim().length > 0 &&
mintForm.name.trim().length <= NAME_MAX &&
mintForm.scopes.size > 0 &&
!mintPending
);
onMount(async () => {
await Promise.all([refreshKeys(), loadApps()]);
});
async function refreshKeys() {
try {
keys = await api.apiKeys.list();
loadError = null;
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load API keys';
}
}
async function loadApps() {
try {
apps = await api.apps.list();
} catch {
// Non-fatal: the form falls back to "no app options" and the
// list shows the bare UUID in the binding column.
apps = [];
}
}
function flash(kind: 'error' | 'info', message: string) {
banner = { kind, message };
setTimeout(() => {
if (banner?.message === message) banner = null;
}, 6000);
}
function openMint() {
mintForm = { name: '', scopes: new Set(), app_id: '', expires_at: '' };
mintError = null;
mintOpen = true;
}
function cancelMint() {
mintOpen = false;
mintError = null;
}
function toggleScope(s: Scope) {
const next = new Set(mintForm.scopes);
if (next.has(s)) next.delete(s);
else next.add(s);
mintForm = { ...mintForm, scopes: next };
}
// When the user binds the key to an app, instance:* scopes are
// mutually exclusive — drop them from the selection so submit
// doesn't 422.
$effect(() => {
if (!boundToApp) return;
const filtered = new Set<Scope>();
let dropped = false;
for (const s of mintForm.scopes) {
if (scopeIsInstance(s)) dropped = true;
else filtered.add(s);
}
if (dropped) {
mintForm = { ...mintForm, scopes: filtered };
}
});
async function submitMint(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
mintPending = true;
mintError = null;
try {
const r = await api.apiKeys.mint({
name: mintForm.name.trim(),
scopes: Array.from(mintForm.scopes),
app_id: mintForm.app_id === '' ? null : mintForm.app_id,
expires_at: mintForm.expires_at === ''
? null
: new Date(mintForm.expires_at + 'T23:59:59Z').toISOString()
});
reveal = r;
revealAck = false;
copyState = 'idle';
mintOpen = false;
await refreshKeys();
} catch (e) {
mintError = e instanceof ApiError ? e.message : 'failed to mint API key';
} finally {
mintPending = false;
}
}
async function copyToken() {
if (!reveal) return;
try {
await navigator.clipboard.writeText(reveal.raw_token);
copyState = 'copied';
setTimeout(() => (copyState = 'idle'), 2000);
} catch {
flash('error', 'Clipboard write failed — select and copy manually.');
}
}
function dismissReveal() {
reveal = null;
revealAck = false;
}
function openRevoke(key: ApiKeyDto) {
revokeTarget = key;
}
async function confirmRevoke() {
if (!revokeTarget) return;
revokePending = true;
const target = revokeTarget;
try {
await api.apiKeys.revoke(target.id);
revokeTarget = null;
keys = keys.filter((k) => k.id !== target.id);
flash('info', `Revoked "${target.name}".`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to revoke key');
} finally {
revokePending = false;
}
}
function appLabel(app_id: string | null): string {
if (!app_id) return 'Instance-wide';
const a = appBySlug.get(app_id);
return a ? a.slug : app_id.slice(0, 8) + '…';
}
function shortDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toISOString().slice(0, 10);
}
function relative(iso: string | null): string {
if (!iso) return 'Never';
const then = new Date(iso).getTime();
const sec = Math.round((Date.now() - then) / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
return shortDate(iso);
}
</script>
{#if me}
<section class="identity">
<div class="identity-head">
<h1>{me.username}</h1>
<RoleChip role={me.instance_role} />
</div>
<dl class="identity-meta">
<div>
<dt>Email</dt>
<dd>{me.email ?? 'No email set'}</dd>
</div>
<div>
<dt>User ID</dt>
<dd class="mono">{me.id}</dd>
</div>
</dl>
</section>
{/if}
{#if deniedFromUsers}
<div class="banner banner-info">
You don't have access to the Users page. Ask an admin if you need to manage users.
</div>
{/if}
{#if banner}
<div class="banner banner-{banner.kind}">{banner.message}</div>
{/if}
<section class="keys-section">
<header class="section-head">
<h2>API keys</h2>
{#if !mintOpen && !reveal}
<button type="button" class="primary" onclick={openMint}>+ Mint API key</button>
{/if}
</header>
{#if reveal}
<div class="reveal">
<h3>Save this token now — it will never be shown again.</h3>
<p class="reveal-sub">
Paste it into your CLI config or external integration. PiCloud only ever stores a hash; if
you lose it, mint a new one.
</p>
<div class="token-row">
<code class="token">{reveal.raw_token}</code>
<button type="button" class="ghost" onclick={copyToken}>
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
</button>
</div>
<label class="ack">
<input type="checkbox" bind:checked={revealAck} />
<span>I've saved this token somewhere safe.</span>
</label>
<div class="reveal-actions">
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
Done
</button>
</div>
</div>
{/if}
{#if mintOpen}
<form class="mint" onsubmit={submitMint}>
<div class="form-row">
<label class="field">
<span>Name</span>
<input
type="text"
bind:value={mintForm.name}
maxlength={NAME_MAX}
autocomplete="off"
placeholder="e.g. ci-deploy"
required
/>
<small>1{NAME_MAX} chars. Only you see it.</small>
</label>
<label class="field">
<span>Binding</span>
<select bind:value={mintForm.app_id}>
<option value="">Instance-wide</option>
{#each apps as a (a.id)}
<option value={a.id}>{a.slug} ({a.name})</option>
{/each}
</select>
<small>Pick an app to scope this key, or leave instance-wide.</small>
</label>
<label class="field">
<span>Expires</span>
<input type="date" bind:value={mintForm.expires_at} />
<small>Leave blank for no expiry.</small>
</label>
</div>
<fieldset class="scopes">
<legend>Scopes</legend>
<div class="scope-grid">
{#each ALL_SCOPES as scope (scope)}
{@const instanceScope = scopeIsInstance(scope)}
{@const disabled = boundToApp && instanceScope}
<label
class="scope-chip"
class:disabled
title={disabled ? "Bound keys can't carry instance scopes" : undefined}
>
<input
type="checkbox"
checked={mintForm.scopes.has(scope)}
disabled={disabled || mintPending}
onchange={() => toggleScope(scope)}
/>
<span class="scope-name">{scope}</span>
</label>
{/each}
</div>
<small class="scope-hint">
{mintForm.scopes.size === 0
? 'Pick at least one scope.'
: `${mintForm.scopes.size} scope${mintForm.scopes.size === 1 ? '' : 's'} selected.`}
</small>
</fieldset>
{#if mintError}
<div class="error">{mintError}</div>
{/if}
<div class="form-actions">
<button type="button" class="ghost" onclick={cancelMint}>Cancel</button>
<button type="submit" class="primary" disabled={!canSubmit}>
{mintPending ? 'Minting…' : 'Mint key'}
</button>
</div>
</form>
{/if}
{#if loadError}
<div class="error">
{loadError}
<button type="button" class="retry" onclick={refreshKeys}>Retry</button>
</div>
{:else if keys.length === 0 && !reveal && !mintOpen}
<p class="empty">
No API keys yet. Mint one to authenticate the CLI or external integrations.
</p>
{:else if keys.length > 0}
<div class="table">
<div class="row head-row">
<div>Name</div>
<div>Prefix</div>
<div>Scopes</div>
<div>Binding</div>
<div>Created</div>
<div>Last used</div>
<div>Expires</div>
<div class="actions-col"></div>
</div>
{#each keys as key (key.id)}
<div class="row">
<div class="name-cell">{key.name}</div>
<div class="mono prefix">pic_{key.prefix}</div>
<div class="scopes-cell">
{#each key.scopes as s (s)}
<span class="scope-pill">{s}</span>
{/each}
</div>
<div>{appLabel(key.app_id)}</div>
<div>{shortDate(key.created_at)}</div>
<div title={key.last_used_at ?? ''}>{relative(key.last_used_at)}</div>
<div>{key.expires_at ? shortDate(key.expires_at) : 'Never'}</div>
<div class="actions-col">
<button
type="button"
class="danger-link"
onclick={() => openRevoke(key)}
aria-label="Revoke {key.name}"
>
Revoke
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
{#if revokeTarget}
<ConfirmModal
title="Revoke API key?"
variant="danger"
confirmLabel="Revoke"
busy={revokePending}
busyLabel="Revoking…"
onConfirm={confirmRevoke}
onCancel={() => (revokeTarget = null)}
>
<p>
Revoking <strong>{revokeTarget.name}</strong> (<code>{revokeTarget.prefix}</code>) takes
effect immediately. Any CLI or integration using it will start returning <code>401</code>
on the next request.
</p>
<p class="muted">This can't be undone — mint a new key if you need one again.</p>
</ConfirmModal>
{/if}
<style>
.identity {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.identity-head {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.identity h1 {
margin: 0;
font-size: 1.25rem;
color: #e2e8f0;
}
.identity-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.75rem 1.5rem;
margin: 0;
}
.identity-meta div {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.identity-meta dt {
color: #64748b;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.identity-meta dd {
margin: 0;
color: #cbd5e1;
font-size: 0.9rem;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.banner-error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
}
.banner-info {
background: #0c2a36;
border: 1px solid #155e75;
color: #a5f3fc;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-head h2 {
margin: 0;
font-size: 1.05rem;
color: #e2e8f0;
}
.reveal {
background: #0b1220;
border: 1px solid #ca8a04;
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reveal h3 {
margin: 0;
font-size: 0.95rem;
color: #fbbf24;
}
.reveal-sub {
margin: 0;
color: #cbd5e1;
font-size: 0.85rem;
line-height: 1.4;
}
.token-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.token {
flex: 1;
background: #020617;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
overflow-x: auto;
white-space: nowrap;
}
.ack {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #cbd5e1;
cursor: pointer;
}
.reveal-actions {
display: flex;
justify-content: flex-end;
}
.mint {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.field input,
.field select {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.7rem;
font-size: 0.9rem;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #38bdf8;
}
.field small {
color: #64748b;
font-size: 0.72rem;
}
.scopes {
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.75rem 0.85rem;
color: #cbd5e1;
}
.scopes legend {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
padding: 0 0.4rem;
}
.scope-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.4rem 0.75rem;
margin-top: 0.25rem;
}
.scope-chip {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
cursor: pointer;
}
.scope-chip.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.scope-hint {
display: block;
margin-top: 0.55rem;
font-size: 0.75rem;
color: #64748b;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.55rem 0.8rem;
border-radius: 0.375rem;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.25rem;
}
.retry {
background: transparent;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.2rem 0.55rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
.empty {
color: #64748b;
text-align: center;
padding: 2.5rem 0;
border: 1px dashed #1e293b;
border-radius: 0.5rem;
}
.table {
display: flex;
flex-direction: column;
border: 1px solid #1e293b;
border-radius: 0.5rem;
background: #0b1220;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 1.3fr 0.9fr 2fr 1fr 0.8fr 0.8fr 0.8fr 0.7fr;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.85rem;
}
.row:last-child {
border-bottom: none;
}
.head-row {
color: #94a3b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #0f172a;
}
.name-cell {
color: #e2e8f0;
font-weight: 500;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.prefix {
color: #94a3b8;
}
.scopes-cell {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.scope-pill {
background: #1e293b;
color: #cbd5e1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.actions-col {
display: flex;
justify-content: flex-end;
}
.danger-link {
background: transparent;
color: #fca5a5;
border: none;
font-size: 0.8rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
}
.danger-link:hover {
background: #450a0a;
color: #fecaca;
}
button.primary {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 0.9rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.ghost {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
padding: 0.45rem 0.85rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.85rem;
}
button.ghost:hover {
background: #1e293b;
color: #e2e8f0;
}
.muted {
color: #94a3b8;
}
</style>

View File

@@ -6,12 +6,15 @@
api, api,
ApiError, ApiError,
type AppDomain, type AppDomain,
type AppRole,
type ExecutionLog, type ExecutionLog,
type Route, type Route,
type RouteInput, type RouteInput,
type Script, type Script,
type VersionInfo type VersionInfo
} from '$lib/api'; } from '$lib/api';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
import { logLevelColor, statusColor } from '$lib/styles'; import { logLevelColor, statusColor } from '$lib/styles';
import { import {
checkHostAgainstClaims, checkHostAgainstClaims,
@@ -21,6 +24,7 @@
pathKindMismatchWarning pathKindMismatchWarning
} from '$lib/route-utils'; } from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte'; import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import { format as formatRhai } from '$lib/rhai'; import { format as formatRhai } from '$lib/rhai';
/// Pretty-print a JSON string in place, leaving it untouched if the /// Pretty-print a JSON string in place, leaving it untouched if the
@@ -47,6 +51,11 @@
let appSlug = $state<string | null>(null); let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]); let appDomains = $state<AppDomain[]>([]);
let appMyRole = $state<AppRole | null>(null);
const me = $derived($currentUser);
const canWrite = $derived(canWriteApp(me, appMyRole));
const canAdmin = $derived(canAdminApp(me, appMyRole));
async function loadScript() { async function loadScript() {
scriptLoading = true; scriptLoading = true;
@@ -58,15 +67,16 @@
editableDescription = script.description ?? ''; editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds; editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) }; editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb and its // Resolve the owning app for the breadcrumb (slug),
// domain claims for the route form's suggestions + live // route-form host suggestions (domain claims), and UI
// validation. Both are non-fatal — the page works without // shadowing (my_role on this app). All non-fatal — the
// them. // page renders without them, just with reduced fidelity.
const appId = script.app_id; const appId = script.app_id;
void api.apps void api.apps
.get(appId) .get(appId)
.then((a) => { .then((a) => {
appSlug = a.slug; appSlug = a.slug;
appMyRole = a.my_role ?? null;
}) })
.catch(() => {}); .catch(() => {});
void api.domains void api.domains
@@ -366,16 +376,25 @@
} }
// ---------------- deletion ---------------- // ---------------- deletion ----------------
let confirmingDelete = $state(false);
let deleting = $state(false); let deleting = $state(false);
async function remove() { let deleteError = $state<string | null>(null);
function askDelete() {
deleteError = null;
confirmingDelete = true;
}
async function confirmDelete() {
if (!script) return; if (!script) return;
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
deleting = true; deleting = true;
deleteError = null;
try { try {
await api.scripts.remove(id); await api.scripts.remove(id);
await goto(base + '/'); await goto(base + '/');
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : String(e)); deleteError = e instanceof Error ? e.message : String(e);
} finally {
deleting = false; deleting = false;
} }
} }
@@ -386,6 +405,15 @@
void loadRoutes(); void loadRoutes();
void loadLogs(); void loadLogs();
}); });
// Defense-in-depth: anyone non-admin who lands on the Settings
// tab via a stale link gets bounced back to Edit. The tab button
// itself is also hidden.
$effect(() => {
if (!canAdmin && tab === 'settings') {
tab = 'edit';
}
});
</script> </script>
<section> <section>
@@ -410,9 +438,11 @@
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'} v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
</p> </p>
</div> </div>
<button type="button" class="danger" onclick={remove} disabled={deleting}> {#if canAdmin}
{deleting ? 'Deleting…' : 'Delete'} <button type="button" class="danger" onclick={askDelete} disabled={deleting}>
</button> {deleting ? 'Deleting…' : 'Delete'}
</button>
{/if}
</header> </header>
<nav class="tabs"> <nav class="tabs">
@@ -423,7 +453,9 @@
<span class="badge-count">{routes.length}</span> <span class="badge-count">{routes.length}</span>
{/if} {/if}
</button> </button>
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button> {#if canAdmin}
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
{/if}
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}> <button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
Executions Executions
</button> </button>
@@ -435,26 +467,35 @@
<section class="card"> <section class="card">
<header class="editor-header"> <header class="editor-header">
<h2>Source</h2> <h2>Source</h2>
<button type="button" class="ghost small" onclick={formatRhaiSource}> {#if canWrite}
Format <button type="button" class="ghost small" onclick={formatRhaiSource}>
</button> Format
</button>
{/if}
</header> </header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" /> <CodeEditor
bind:value={editableSource}
language="rhai"
minHeight="22rem"
readOnly={!canWrite}
/>
{#if rhaiFormatError} {#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div> <div class="error inline">{rhaiFormatError}</div>
{/if} {/if}
{#if saveSourceError} {#if saveSourceError}
<div class="error inline">{saveSourceError}</div> <div class="error inline">{saveSourceError}</div>
{/if} {/if}
<div class="actions"> {#if canWrite}
<button <div class="actions">
type="button" <button
onclick={saveSource} type="button"
disabled={savingSource || editableSource === script.source} onclick={saveSource}
> disabled={savingSource || editableSource === script.source}
{savingSource ? 'Saving…' : 'Save'} >
</button> {savingSource ? 'Saving…' : 'Save'}
</div> </button>
</div>
{/if}
</section> </section>
<section class="card"> <section class="card">
@@ -510,12 +551,14 @@
<section class="card wide"> <section class="card wide">
<header class="card-header"> <header class="card-header">
<h2>Routes</h2> <h2>Routes</h2>
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}> {#if canWrite}
{showAddRoute ? 'Cancel' : '+ Add route'} <button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
</button> {showAddRoute ? 'Cancel' : '+ Add route'}
</button>
{/if}
</header> </header>
{#if showAddRoute} {#if showAddRoute && canWrite}
<form class="route-form" onsubmit={submitRoute}> <form class="route-form" onsubmit={submitRoute}>
<label class="full"> <label class="full">
<span>Path</span> <span>Path</span>
@@ -626,9 +669,11 @@
: r.host} : r.host}
</span> </span>
<span class="path">{r.path}</span> <span class="path">{r.path}</span>
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}> {#if canWrite}
remove <button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
</button> remove
</button>
{/if}
</div> </div>
{#if info} {#if info}
<div class="route-url muted">{fullUrlForRoute(r)}</div> <div class="route-url muted">{fullUrlForRoute(r)}</div>
@@ -670,7 +715,7 @@
</section> </section>
<!-- ===================================================== SETTINGS ===== --> <!-- ===================================================== SETTINGS ===== -->
{:else if tab === 'settings'} {:else if tab === 'settings' && canAdmin}
<section class="card wide"> <section class="card wide">
<h2>General</h2> <h2>General</h2>
<label> <label>
@@ -786,6 +831,35 @@
{/if} {/if}
</section> </section>
{/if} {/if}
{#if confirmingDelete && script}
<ConfirmModal
title="Delete script “{script.name}”"
variant="danger"
confirmLabel="Delete script"
busyLabel="Deleting…"
confirmPhrase={script.name}
confirmPhrasePrompt="Type the script name to confirm:"
busy={deleting}
onConfirm={confirmDelete}
onCancel={() => (confirmingDelete = false)}
>
<p>
This will <strong>permanently delete</strong>
<strong>{script.name}</strong>, all its routes, and all its
execution logs. There is no undo.
</p>
{#if routes.length > 0}
<p class="muted">
{routes.length} route{routes.length === 1 ? '' : 's'} bound to
this script will be removed.
</p>
{/if}
{#if deleteError}
<p class="modal-error">{deleteError}</p>
{/if}
</ConfirmModal>
{/if}
{/if} {/if}
</section> </section>

View File

@@ -0,0 +1,986 @@
<!--
/admin/users — owner + admin only. Members get bounced to /profile
with ?denied=users. Replaces the pre-3.5 /admin/admins page; this
one knows about roles, email, and the last-owner/last-admin guards.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import {
api,
ApiError,
type AdminDto,
type InstanceRole
} from '$lib/api';
import { currentUser } from '$lib/auth';
import RoleChip from '$lib/RoleChip.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import { generatePassword } from '$lib/password-gen';
const me = $derived($currentUser);
const myRole = $derived(me?.instance_role);
const isOwner = $derived(myRole === 'owner');
// Member guard. The backend already 403s the list call, but
// surfacing a friendly redirect avoids the dead-end empty page.
$effect(() => {
if (me && me.instance_role === 'member') {
void goto(`${base}/profile?denied=users`);
}
});
let admins = $state<AdminDto[]>([]);
let loadError = $state<string | null>(null);
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
let search = $state('');
const filtered = $derived(
(() => {
const q = search.trim().toLowerCase();
if (!q) return admins;
return admins.filter(
(a) =>
a.username.toLowerCase().includes(q) ||
(a.email ?? '').toLowerCase().includes(q)
);
})()
);
// Invite (create) modal --------------------------------------------------
let inviteOpen = $state(false);
let inviteForm = $state<{ username: string; email: string; instance_role: 'admin' | 'member' }>({
username: '',
email: '',
instance_role: 'admin'
});
let invitePending = $state(false);
let inviteError = $state<string | null>(null);
// One-time password reveal (used by both invite + reset)
let revealPassword = $state<string | null>(null);
let revealForUsername = $state<string>('');
let revealKind = $state<'invite' | 'reset'>('invite');
let revealAck = $state(false);
let copyState = $state<'idle' | 'copied'>('idle');
// Edit modal -------------------------------------------------------------
let editTarget = $state<AdminDto | null>(null);
let editForm = $state<{
username: string;
email: string;
instance_role: InstanceRole;
}>({ username: '', email: '', instance_role: 'admin' });
let editPending = $state(false);
let editError = $state<string | null>(null);
// Delete modal -----------------------------------------------------------
let deleteTarget = $state<AdminDto | null>(null);
let deletePending = $state(false);
// Deactivate modal -------------------------------------------------------
// Reactivate is one-click (non-destructive); deactivate routes
// through the modal because it signs the user out and expires
// every API key they hold.
let deactivateTarget = $state<AdminDto | null>(null);
let deactivatePending = $state(false);
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const inviteUsernameValid = $derived(USERNAME_RE.test(inviteForm.username));
const inviteEmailValid = $derived(
inviteForm.email.trim() === '' || EMAIL_RE.test(inviteForm.email.trim())
);
const canInvite = $derived(inviteUsernameValid && inviteEmailValid && !invitePending);
const editUsernameValid = $derived(USERNAME_RE.test(editForm.username));
const editEmailValid = $derived(
editForm.email.trim() === '' || EMAIL_RE.test(editForm.email.trim())
);
const canSubmitEdit = $derived(editUsernameValid && editEmailValid && !editPending);
// Admin (non-owner) cannot touch owner rows for delete or role demote.
function canDelete(row: AdminDto): boolean {
if (isOwner) return true;
return row.instance_role !== 'owner';
}
const editRoleOptions = $derived<InstanceRole[]>(
isOwner ? ['owner', 'admin', 'member'] : ['admin', 'member']
);
onMount(refresh);
async function refresh() {
loadError = null;
try {
admins = await api.admins.list();
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load users';
}
}
function flash(kind: 'error' | 'info', message: string) {
banner = { kind, message };
setTimeout(() => {
if (banner?.message === message) banner = null;
}, 6000);
}
function openInvite() {
inviteForm = { username: '', email: '', instance_role: 'admin' };
inviteError = null;
inviteOpen = true;
}
async function submitInvite(event: SubmitEvent) {
event.preventDefault();
if (!canInvite) return;
invitePending = true;
inviteError = null;
const password = generatePassword(16);
try {
const created = await api.admins.create({
username: inviteForm.username,
password,
instance_role: inviteForm.instance_role,
email: inviteForm.email.trim() === '' ? null : inviteForm.email.trim()
});
admins = [...admins, created].sort((a, b) => a.username.localeCompare(b.username));
inviteOpen = false;
revealPassword = password;
revealForUsername = created.username;
revealKind = 'invite';
revealAck = false;
copyState = 'idle';
} catch (e) {
inviteError = e instanceof ApiError ? e.message : 'failed to create user';
} finally {
invitePending = false;
}
}
function openEdit(row: AdminDto) {
editTarget = row;
editForm = {
username: row.username,
email: row.email ?? '',
instance_role: row.instance_role
};
editError = null;
}
async function submitEdit(event: SubmitEvent) {
event.preventDefault();
if (!editTarget || !canSubmitEdit) return;
editPending = true;
editError = null;
const patch: {
username?: string;
email?: string | null;
instance_role?: InstanceRole;
} = {};
if (editForm.username !== editTarget.username) patch.username = editForm.username;
if ((editTarget.email ?? '') !== editForm.email.trim()) {
patch.email = editForm.email.trim() === '' ? null : editForm.email.trim();
}
if (editForm.instance_role !== editTarget.instance_role) {
patch.instance_role = editForm.instance_role;
}
try {
const updated = await api.admins.update(editTarget.id, patch);
admins = admins
.map((a) => (a.id === updated.id ? updated : a))
.sort((a, b) => a.username.localeCompare(b.username));
const name = updated.username;
editTarget = null;
flash('info', `Updated "${name}".`);
} catch (e) {
editError = e instanceof ApiError ? e.message : 'failed to update user';
} finally {
editPending = false;
}
}
async function resetPassword() {
if (!editTarget) return;
const target = editTarget;
const password = generatePassword(16);
editPending = true;
editError = null;
try {
await api.admins.update(target.id, { password });
editTarget = null;
revealPassword = password;
revealForUsername = target.username;
revealKind = 'reset';
revealAck = false;
copyState = 'idle';
} catch (e) {
editError = e instanceof ApiError ? e.message : 'failed to reset password';
} finally {
editPending = false;
}
}
async function reactivate(row: AdminDto) {
try {
const updated = await api.admins.update(row.id, { is_active: true });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
flash('info', `${updated.username} reactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
}
}
function askDeactivate(row: AdminDto) {
deactivateTarget = row;
}
async function confirmDeactivate() {
if (!deactivateTarget) return;
deactivatePending = true;
const target = deactivateTarget;
try {
const updated = await api.admins.update(target.id, { is_active: false });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
deactivateTarget = null;
flash('info', `${updated.username} deactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
} finally {
deactivatePending = false;
}
}
function openDelete(row: AdminDto) {
deleteTarget = row;
}
async function confirmDelete() {
if (!deleteTarget) return;
deletePending = true;
const target = deleteTarget;
try {
await api.admins.remove(target.id);
deleteTarget = null;
if (me && me.id === target.id) {
// Self-delete: bail out to login.
await api.auth.logout();
await goto(`${base}/login`);
return;
}
admins = admins.filter((a) => a.id !== target.id);
flash('info', `Deleted "${target.username}".`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to delete user');
} finally {
deletePending = false;
}
}
async function copyPassword() {
if (!revealPassword) return;
try {
await navigator.clipboard.writeText(revealPassword);
copyState = 'copied';
setTimeout(() => (copyState = 'idle'), 2000);
} catch {
flash('error', 'Clipboard write failed — select and copy manually.');
}
}
function dismissReveal() {
revealPassword = null;
revealAck = false;
}
function relative(iso: string | null): string {
if (!iso) return 'Never';
const sec = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
return new Date(iso).toISOString().slice(0, 10);
}
function shortDate(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
</script>
<header class="head">
<h1>Users</h1>
<div class="head-controls">
<input
type="search"
placeholder="Search by username or email…"
bind:value={search}
class="search"
/>
<button type="button" class="primary" onclick={openInvite}>+ Invite user</button>
</div>
</header>
{#if banner}
<div class="banner banner-{banner.kind}">{banner.message}</div>
{/if}
{#if loadError}
<div class="error">
{loadError}
<button type="button" class="retry" onclick={refresh}>Retry</button>
</div>
{:else if admins.length === 0}
<p class="empty">No users yet. Invite one to get started.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>Username</div>
<div>Role</div>
<div>Email</div>
<div>Status</div>
<div>Created</div>
<div>Last login</div>
<div class="actions-col"></div>
</div>
{#each filtered as row (row.id)}
<div class="row">
<div class="name-cell">
<span class="name">{row.username}</span>
{#if me && me.id === row.id}
<span class="you-tag">(you)</span>
{/if}
</div>
<div><RoleChip role={row.instance_role} size="sm" /></div>
<div class="email-cell">{row.email ?? '—'}</div>
<div>
{#if row.is_active}
<span class="status status-active">● Active</span>
{:else}
<span class="status status-inactive">○ Inactive</span>
{/if}
</div>
<div>{shortDate(row.created_at)}</div>
<div title={row.last_login_at ?? ''}>{relative(row.last_login_at)}</div>
<div class="actions-col">
<ActionMenu
label="User actions for {row.username}"
items={[
{ label: 'Edit', onClick: () => openEdit(row) },
{
label: row.is_active ? 'Deactivate' : 'Reactivate',
onClick: () =>
row.is_active ? askDeactivate(row) : reactivate(row)
},
{
label: 'Delete',
danger: true,
disabled: !canDelete(row),
onClick: () => openDelete(row)
}
]}
/>
</div>
</div>
{/each}
{#if filtered.length === 0 && admins.length > 0}
<div class="row empty-row">No matches for "{search}".</div>
{/if}
</div>
{/if}
<!-- Invite modal -->
{#if inviteOpen}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget && !invitePending) inviteOpen = false;
}}
>
<form class="modal" onsubmit={submitInvite}>
<div class="modal-head">
<h2>Invite user</h2>
<button
type="button"
class="x"
aria-label="Close"
disabled={invitePending}
onclick={() => (inviteOpen = false)}></button
>
</div>
<p class="modal-intro">
A random password will be generated and shown to you exactly once. PiCloud cannot send
email — copy and share through your own channel.
</p>
<label class="field">
<span>Username</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
bind:value={inviteForm.username}
required
/>
<small>232 chars. Lowercase letters, digits, <code>.</code> <code>_</code> <code>-</code>.</small>
{#if inviteForm.username && !inviteUsernameValid}
<small class="invalid">Doesn't match the allowed pattern.</small>
{/if}
</label>
<label class="field">
<span>Email <span class="opt">(optional)</span></span>
<input
type="email"
autocomplete="off"
spellcheck="false"
bind:value={inviteForm.email}
/>
{#if !inviteEmailValid}
<small class="invalid">Doesn't look like an email address.</small>
{/if}
</label>
<fieldset class="field">
<legend>Role</legend>
<label class="radio">
<input type="radio" bind:group={inviteForm.instance_role} value="admin" />
<span>Admin — can manage users, scripts, and all apps.</span>
</label>
<label class="radio">
<input type="radio" bind:group={inviteForm.instance_role} value="member" />
<span>Member — only sees apps they're added to.</span>
</label>
<small>
Owners can't be created here — promote via Edit after creation.
</small>
</fieldset>
{#if inviteError}
<div class="error">{inviteError}</div>
{/if}
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (inviteOpen = false)} disabled={invitePending}>
Cancel
</button>
<button type="submit" class="primary" disabled={!canInvite}>
{invitePending ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</div>
{/if}
<!-- Edit modal -->
{#if editTarget}
{@const target = editTarget}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget && !editPending) editTarget = null;
}}
>
<form class="modal" onsubmit={submitEdit}>
<div class="modal-head">
<h2>Edit {target.username}</h2>
<button
type="button"
class="x"
aria-label="Close"
disabled={editPending}
onclick={() => (editTarget = null)}></button
>
</div>
<label class="field">
<span>Username</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
bind:value={editForm.username}
required
/>
{#if editForm.username && !editUsernameValid}
<small class="invalid">232 chars, lowercase + digits + . _ - only.</small>
{/if}
</label>
<label class="field">
<span>Email <span class="opt">(optional)</span></span>
<input
type="email"
autocomplete="off"
spellcheck="false"
bind:value={editForm.email}
/>
{#if !editEmailValid}
<small class="invalid">Doesn't look like an email address.</small>
{/if}
</label>
<label class="field">
<span>Role</span>
<select bind:value={editForm.instance_role}>
{#each editRoleOptions as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<small>
{#if target.instance_role === 'owner' && !isOwner}
Only owners can change another owner's role.
{:else if !isOwner}
Admins can grant admin or member; only owners can grant owner.
{:else}
The last active owner can't be demoted — the request will 422 if that's the case.
{/if}
</small>
</label>
{#if editError}
<div class="error">{editError}</div>
{/if}
<div class="modal-actions split">
<button type="button" class="ghost" onclick={resetPassword} disabled={editPending}>
Reset password
</button>
<div class="modal-actions">
<button
type="button"
class="ghost"
onclick={() => (editTarget = null)}
disabled={editPending}
>
Cancel
</button>
<button type="submit" class="primary" disabled={!canSubmitEdit}>
{editPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</form>
</div>
{/if}
<!-- Password reveal (post-invite or post-reset) -->
{#if revealPassword}
<div class="modal-backdrop" role="presentation">
<div class="modal reveal-modal">
<div class="modal-head">
<h2>
{revealKind === 'invite' ? 'User created' : 'Password reset'}{revealForUsername}
</h2>
</div>
<p class="banner banner-warn">
Save this password now — it will never be shown again. PiCloud cannot send email yet,
so copy it and share through your own channel.
</p>
<div class="token-row">
<code class="token">{revealPassword}</code>
<button type="button" class="ghost" onclick={copyPassword}>
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
</button>
</div>
<label class="ack">
<input type="checkbox" bind:checked={revealAck} />
<span>I've shared this with the user.</span>
</label>
<div class="modal-actions">
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
Done
</button>
</div>
</div>
</div>
{/if}
<!-- Deactivate confirmation -->
{#if deactivateTarget}
{@const dt = deactivateTarget}
<ConfirmModal
title="Deactivate {dt.username}?"
variant="danger"
confirmLabel="Deactivate"
busyLabel="Deactivating…"
busy={deactivatePending}
onConfirm={confirmDeactivate}
onCancel={() => (deactivateTarget = null)}
>
<p>
Deactivating signs <strong>{dt.username}</strong> out immediately and
expires <strong>every API key</strong> they hold. Their sessions and keys
won't come back if you reactivate — they'll need to log in again and
mint new keys.
</p>
<p class="muted">
Reactivation is one click — this isn't permanent.
</p>
</ConfirmModal>
{/if}
<!-- Delete confirmation -->
{#if deleteTarget}
{@const dt = deleteTarget}
<ConfirmModal
title="Delete user?"
variant="danger"
confirmLabel="Delete user"
confirmPhrase={dt.username}
confirmPhrasePrompt="Type the username to confirm:"
busy={deletePending}
busyLabel="Deleting…"
onConfirm={confirmDelete}
onCancel={() => (deleteTarget = null)}
>
{#if me && me.id === dt.id}
<p>
You're about to delete <strong>your own</strong> account. You'll be signed out
immediately and won't be able to sign back in.
</p>
{:else}
<p>
This permanently removes <strong>{dt.username}</strong>, all their sessions, and all
their API keys. This cannot be undone.
</p>
{/if}
<p class="muted">
If they're the only remaining owner or active admin the server will reject the request
with a 422 — promote/activate someone else first.
</p>
</ConfirmModal>
{/if}
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.head h1 {
font-size: 1.25rem;
margin: 0;
color: #e2e8f0;
}
.head-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.search {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
color: #e2e8f0;
font-size: 0.85rem;
min-width: 16rem;
}
.search:focus {
outline: none;
border-color: #38bdf8;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.banner-error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
}
.banner-info {
background: #0c2a36;
border: 1px solid #155e75;
color: #a5f3fc;
}
.banner-warn {
background: #2a1d04;
border: 1px solid #ca8a04;
color: #fde68a;
margin: 0;
}
.empty {
color: #64748b;
text-align: center;
padding: 2.5rem 0;
border: 1px dashed #1e293b;
border-radius: 0.5rem;
}
.table {
display: flex;
flex-direction: column;
border: 1px solid #1e293b;
border-radius: 0.5rem;
background: #0b1220;
overflow: visible;
}
.row {
display: grid;
grid-template-columns: 1.3fr 0.7fr 1.5fr 0.9fr 0.8fr 0.9fr 2.5rem;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.85rem;
}
.row:last-child {
border-bottom: none;
}
.head-row {
color: #94a3b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #0f172a;
}
.empty-row {
grid-column: 1 / -1;
color: #64748b;
text-align: center;
padding: 1.25rem;
}
.name-cell {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.name {
color: #e2e8f0;
font-weight: 500;
}
.you-tag {
color: #64748b;
font-size: 0.72rem;
}
.email-cell {
color: #cbd5e1;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
font-size: 0.8rem;
}
.status-active {
color: #34d399;
}
.status-inactive {
color: #64748b;
}
.actions-col {
display: flex;
justify-content: flex-end;
}
button.primary {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 0.9rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.ghost {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
padding: 0.45rem 0.85rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.85rem;
}
button.ghost:hover {
background: #1e293b;
color: #e2e8f0;
}
.error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.55rem 0.8rem;
border-radius: 0.375rem;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.retry {
background: transparent;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.2rem 0.55rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 50;
}
.modal {
background: #0b1220;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 28rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
.reveal-modal {
border-color: #ca8a04;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal h2 {
margin: 0;
font-size: 1rem;
color: #e2e8f0;
}
.x {
background: transparent;
border: none;
color: #64748b;
font-size: 1.1rem;
cursor: pointer;
}
.x:hover {
color: #e2e8f0;
}
.modal-intro {
margin: 0;
font-size: 0.82rem;
color: #94a3b8;
line-height: 1.45;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: #cbd5e1;
border: none;
padding: 0;
margin: 0;
}
.field legend {
font-size: 0.85rem;
color: #cbd5e1;
padding: 0;
margin-bottom: 0.3rem;
}
.field input[type='text'],
.field input[type='email'],
.field select {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.7rem;
font-size: 0.9rem;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #38bdf8;
}
.field small {
color: #64748b;
font-size: 0.72rem;
}
.field small.invalid {
color: #fca5a5;
}
.field small code {
background: #1e293b;
color: #cbd5e1;
padding: 0 0.2rem;
border-radius: 0.2rem;
}
.opt {
color: #64748b;
font-weight: 400;
}
.radio {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.82rem;
color: #cbd5e1;
}
.token-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.token {
flex: 1;
background: #020617;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
overflow-x: auto;
white-space: nowrap;
}
.ack {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #cbd5e1;
cursor: pointer;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-actions.split {
justify-content: space-between;
}
.muted {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,75 @@
# Dashboard E2E tests
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
## Prerequisites
The tests drive a real dashboard against a real backend. Bring up both
before running:
```sh
# 1. Postgres
docker compose up -d postgres
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
PICLOUD_BIND=127.0.0.1:18080 \
PICLOUD_ADMIN_USERNAME=admin \
PICLOUD_ADMIN_PASSWORD=admin \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo run -p picloud
# 3. Browser binaries (one-time, ~200 MB)
cd dashboard && npm run test:e2e:install
```
The Vite dev server is started automatically by Playwright's `webServer`
config — you do not need to run `npm run dev` yourself.
## Running
```sh
cd dashboard
npm run test:e2e # headless, full suite
npm run test:e2e:ui # interactive UI runner
npx playwright test smoke # run a single spec
npx playwright show-report
```
## Env vars
| Var | Default | Notes |
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
## How isolation works
Tests share one backend + one Postgres. To avoid cross-test interference:
- A shared bootstrap admin session is captured once in
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
`storageState`.
- Each test creates resources with a unique slug / username produced by
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
fail the suite, so a test can pre-delete and still register the entry.
## Layout
```
tests/e2e/
global-setup.ts # health probe + admin login + storageState seed
smoke.spec.ts # A.5 smoke
fixtures/
auth.ts # UI login/logout helpers (for login-flow specs)
api.ts # bearer-token-backed APIRequestContext
ids.ts # unique slug/username generators (test-fixture)
cleanup.ts # afterEach resource teardown
```
[Playwright]: https://playwright.dev

View File

@@ -0,0 +1,335 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor' | 'app_admin';
}): Promise<{ appId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { appId, userId };
} finally {
await api.dispose();
}
}
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
// historical-slug takeover flow and adversarial inputs.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
function failOnDialog(page: Page): void {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
});
}
async function openCreateForm(page: Page): Promise<void> {
await page.goto('/admin/apps');
await page.getByRole('button', { name: 'New app' }).click();
}
async function createApp(
page: Page,
opts: { name: string; slug: string; description?: string }
): Promise<void> {
await openCreateForm(page);
await page.getByLabel('Name').fill(opts.name);
// Clear the auto-derived slug and type the test-controlled one so
// we know exactly which slug we'll register for cleanup.
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
await slugInput.fill(opts.slug);
if (opts.description !== undefined) {
await page.getByLabel('Description').fill(opts.description);
}
await page.getByRole('button', { name: 'Create app' }).click();
}
test.describe('B2 apps lifecycle', () => {
test('create app: slug auto-derives from name, app appears in list', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('lifecycle');
const displayName = slug.replace(/-/g, ' ');
await openCreateForm(page);
await page.getByLabel('Name').fill(displayName);
// Slug auto-derives — the input value is set, no extra typing.
const slugInput = page.getByLabel('Slug');
await expect(slugInput).toHaveValue(slug);
await page.getByRole('button', { name: 'Create app' }).click();
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
});
test('edit name + description in settings persists across reload', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('edit');
await createApp(page, { name: slug, slug });
cleanup.app(slug);
await page.getByRole('link', { name: new RegExp(slug) }).click();
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
await page.getByRole('button', { name: 'Settings' }).click();
const newName = `${slug} renamed`;
const newDesc = 'updated description';
await page.getByLabel('Name').fill(newName);
await page.getByLabel('Description').fill(newDesc);
await page.getByRole('button', { name: 'Save changes' }).click();
// Wait for the network round-trip to settle — the busy label
// flips back to "Save changes" when done.
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
await page.reload();
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByLabel('Name')).toHaveValue(newName);
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
});
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('delete');
await createApp(page, { name: slug, slug });
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
await page.getByRole('link', { name: new RegExp(slug) }).click();
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Delete app' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const phraseInput = dialog.getByRole('textbox');
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
await expect(confirmBtn).toBeDisabled();
await phraseInput.fill('wrong-phrase');
await expect(confirmBtn).toBeDisabled();
await phraseInput.fill(slug);
await expect(confirmBtn).toBeEnabled();
await confirmBtn.click();
await expect(page).toHaveURL(/\/admin\/apps$/);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
});
test('historical slug warning surfaces; force-takeover succeeds', async ({
page,
uniqueSlug
}) => {
const origSlug = uniqueSlug('hist');
const renamedSlug = `${origSlug}-r`;
// Historical-redirect rows are created on RENAME, not on
// delete. So: create app, rename it, original slug now lives
// in app_slug_history.
const api = await adminApi();
try {
const created = await api.post('/api/v1/admin/apps', {
data: { slug: origSlug, name: origSlug }
});
expect(created.ok()).toBe(true);
const renamed = await api.patch(
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
{ data: { slug: renamedSlug } }
);
expect(renamed.ok()).toBe(true);
} finally {
await api.dispose();
}
cleanup.app(renamedSlug); // the renamed app still exists
await openCreateForm(page);
await page.getByLabel('Name').fill(origSlug);
await page.getByLabel('Slug').fill('');
await page.getByLabel('Slug').fill(origSlug);
await page.getByRole('button', { name: 'Create app' }).click();
await expect(page.locator('.warning')).toBeVisible();
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
await page.getByRole('button', { name: /claim slug anyway/i }).click();
cleanup.app(origSlug); // the takeover created a new app
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
});
});
test.describe('B2 apps adversarial', () => {
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
const base = uniqueSlug('norm');
await openCreateForm(page);
await page.getByLabel('Name').fill(base);
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
// Simulate the user typing/pasting an invalid slug. The
// oninput handler runs slugify() and rewrites the input value.
await slugInput.fill(` Hello WORLD ${base}!`);
await expect(slugInput).toHaveValue(`hello-world-${base}`);
});
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
failOnDialog(page);
const slug = uniqueSlug('xss');
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
await createApp(page, { name: payload, slug, description: payload });
cleanup.app(slug);
// List page — the link's accessible name contains the literal
// payload text, not the parsed HTML.
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
// Detail page — open it; payload renders in the breadcrumb /
// header as text only.
await page.goto(`/admin/apps/${slug}`);
const xssRan = await page.evaluate(
() => (window as unknown as { __xss?: boolean }).__xss === true
);
expect(xssRan).toBe(false);
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
});
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
// The backend currently has no name length cap; the dashboard
// just needs to keep rendering when handed an unusually long
// value. Guards against layout / locator regressions when a
// future test or user creates an oversized app.
const slug = uniqueSlug('long');
const longName = 'A'.repeat(10_000);
await openCreateForm(page);
await page.getByLabel('Name').fill(longName);
await page.getByLabel('Slug').fill('');
await page.getByLabel('Slug').fill(slug);
await page.getByRole('button', { name: 'Create app' }).click();
const errorVisible = await page
.locator('.create-form .error')
.isVisible()
.catch(() => false);
if (errorVisible) {
// Server rejected — fine, no cleanup needed.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
return;
}
// Server accepted — confirm the dashboard still renders and is
// navigable. Detail page must load too.
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
});
});
test.describe('B2 apps role shadowing', () => {
test('viewer member sees no "New app" on the apps list', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vlist');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto('/admin/apps');
// Member can see the apps list (just the one they belong to)
// but the create-app affordance is hidden.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vdom');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Settings tab is absent.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
// Domains tab still listable, but no Add-domain submit.
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('editor sees New script but no Settings tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('edit');
const username = uniqueUsername('editor');
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
await expect(
page.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await page.context().close();
}
});
});

View File

@@ -0,0 +1,118 @@
import { expect, test, type Page } from '@playwright/test';
import { loginAsAdmin, logout } from '../fixtures/auth';
// Phase B1 — Auth & Navigation. Every interaction with the login form
// and the layout-level redirects, plus the obvious adversarial inputs.
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
function failOnDialog(page: Page): void {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
});
}
test.describe('B1 auth — unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('valid credentials land on the apps list', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
});
test('wrong password shows an inline error and stays on /login', async ({ page }) => {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(VALID_USERNAME);
await page.getByLabel('Password').fill('definitely-not-the-password');
await page.getByRole('button', { name: /sign in/i }).click();
const error = page.locator('.error');
await expect(error).toBeVisible();
await expect(error).not.toHaveText('');
await expect(page).toHaveURL(/\/admin\/login$/);
// localStorage must remain empty — a failed login should not
// leak a session token.
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
expect(token).toBeNull();
});
test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
await page.goto('/admin/login');
await page.getByRole('button', { name: /sign in/i }).click();
// HTML5 validation prevents submission; URL is unchanged and the
// username input is reported invalid.
await expect(page).toHaveURL(/\/admin\/login$/);
const usernameInvalid = await page
.getByLabel('Username')
.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(usernameInvalid).toBe(true);
await expect(page.locator('.error')).toBeHidden();
});
test('visiting an authed route redirects to /login', async ({ page }) => {
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
});
test('password field is type=password (no plaintext echo)', async ({ page }) => {
await page.goto('/admin/login');
await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
});
test('xss payload in username is escaped and does not execute', async ({ page }) => {
failOnDialog(page);
const payload = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
await page.goto('/admin/login');
await page.getByLabel('Username').fill(payload);
await page.getByLabel('Password').fill('whatever');
await page.getByRole('button', { name: /sign in/i }).click();
// Whatever the API does with that input, the page must remain
// safe: no script tag injected into the DOM, no global side
// effect, and a visible error (since the credentials don't
// match any user).
await expect(page.locator('.error')).toBeVisible();
const xssRan = await page.evaluate(
() => (window as unknown as { __xss?: boolean }).__xss === true
);
expect(xssRan).toBe(false);
const injectedScript = await page.locator('script:has-text("__xss")').count();
expect(injectedScript).toBe(0);
// The form must still be functional after the rejected attempt.
await page.getByLabel('Username').fill('');
await page.getByLabel('Username').fill(VALID_USERNAME);
await page.getByLabel('Password').fill('');
await page.getByLabel('Password').fill(VALID_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});
test.describe('B1 auth — authenticated', () => {
test('visiting /login while signed in bounces to /apps', async ({ page }) => {
await page.goto('/admin/login');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});
test.describe('B1 auth — logout', () => {
// Logout must NOT use the shared storageState token, or it would
// invalidate the session every other test relies on. Each run
// here logs in fresh so its session is disposable.
test.use({ storageState: { cookies: [], origins: [] } });
test('logout clears the session and lands on /login', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
await logout(page);
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
expect(token).toBeNull();
// And the authed area is now gated again.
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
});
});

View File

@@ -0,0 +1,47 @@
import { request, type APIRequestContext } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
interface StoredState {
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
let cachedToken: string | null = null;
async function readAdminToken(): Promise<string> {
if (cachedToken) return cachedToken;
const raw = await fs.readFile(STATE_PATH, 'utf8');
const state = JSON.parse(raw) as StoredState;
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
if (entry) {
cachedToken = entry.value;
return entry.value;
}
}
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
}
// Thin wrapper around Playwright's request context that injects the
// admin bearer token from the shared storageState. Use this for
// setup/teardown shortcuts when the *test itself* is about something
// else (e.g., a script-editor test that just needs an app to exist).
export async function adminApi(): Promise<APIRequestContext> {
const token = await readAdminToken();
return request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: {
authorization: `Bearer ${token}`,
'content-type': 'application/json'
}
});
}

View File

@@ -0,0 +1,21 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
// Drive the login form like a real user. globalSetup already saves a
// storageState for the shared admin, so most tests don't need this —
// it's reserved for specs that explicitly cover the login UI.
export async function loginAsAdmin(page: Page): Promise<void> {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(ADMIN_USERNAME);
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
}
export async function logout(page: Page): Promise<void> {
await page.getByRole('button', { name: /logout/i }).click();
await expect(page).toHaveURL(/\/admin\/login$/);
}

View File

@@ -0,0 +1,77 @@
import type { APIRequestContext } from '@playwright/test';
import { adminApi } from './api';
// Resources to delete after a test, in LIFO order. Tests register
// their creations and the registry tears everything down in
// `run()` — typically called from `test.afterEach`.
//
// A non-2xx status (other than 404) is treated as a real failure and
// logged to stderr. The previous shape silently swallowed every
// error, so a backend that started returning 500 on cleanup would
// have leaked orphans invisibly across runs. 404 stays tolerated —
// the test may have already deleted the resource itself.
interface CleanupItem {
label: string;
path: string;
}
export class CleanupRegistry {
private items: CleanupItem[] = [];
app(slugOrId: string): void {
this.items.push({
label: `app=${slugOrId}`,
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
});
}
adminUser(userId: string): void {
this.items.push({
label: `admin=${userId}`,
path: `/api/v1/admin/admins/${userId}`
});
}
apiKey(keyId: string): void {
this.items.push({
label: `key=${keyId}`,
path: `/api/v1/admin/api-keys/${keyId}`
});
}
async run(): Promise<void> {
if (this.items.length === 0) return;
const api = await adminApi();
try {
// Copy-then-reverse so a defensive double-`run()` (or a
// caller that inspects the registry after a partial
// teardown) doesn't see the items in a re-reversed order.
for (const item of [...this.items].reverse()) {
await deleteAndReport(api, item);
}
} finally {
await api.dispose();
this.items = [];
}
}
}
async function deleteAndReport(
api: APIRequestContext,
item: CleanupItem
): Promise<void> {
try {
const res = await api.delete(item.path);
// 2xx and 404 are both "this resource is no longer here" — fine.
if (!res.ok() && res.status() !== 404) {
console.warn(
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
);
}
} catch (err) {
// Network-level failure (request never reached the server,
// timeout, etc.). Log so a leak doesn't accumulate silently.
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
}
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
object-pattern first arg; these fixtures don't depend on any other
fixture so the pattern is intentionally empty. */
import { test as base } from '@playwright/test';
import { randomBytes } from 'node:crypto';
// Tests share a single backend/Postgres. To avoid collisions we tag
// every resource the test creates with a short random suffix plus the
// Playwright worker index. This way two workers running the same spec
// in parallel never fight over the same slug or username.
export function shortId(): string {
return randomBytes(3).toString('hex');
}
export function uniqueSlug(prefix: string, workerIndex: number): string {
const cleaned = prefix
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
}
export function uniqueUsername(prefix: string, workerIndex: number): string {
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
}
export const test = base.extend<{
uniqueSlug: (prefix: string) => string;
uniqueUsername: (prefix: string) => string;
}>({
uniqueSlug: async ({}, use, testInfo) => {
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
},
uniqueUsername: async ({}, use, testInfo) => {
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
}
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,46 @@
// Helpers for tests that drive the dashboard as a non-bootstrap admin
// (member with an app-membership row, custom InstanceRole, etc.).
//
// `loginAsUserToken` exchanges username/password for a bearer token
// via the admin API. `pageWithUserToken` opens a fresh browser
// context, seeds the dashboard's localStorage entry, and returns the
// page ready to navigate. Callers are responsible for closing the
// returned page's context.
import { expect, request, type Browser, type Page } from '@playwright/test';
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
export async function loginAsUserToken(
username: string,
password: string
): Promise<string> {
const probe = await request.newContext({ baseURL: API_BASE });
try {
const res = await probe.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await probe.dispose();
}
}
export async function pageWithUserToken(
browser: Browser,
token: string
): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
// Seed localStorage on the right origin, then navigate normally.
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}

View File

@@ -0,0 +1,146 @@
import { chromium, request } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
const AUTH_DIR = path.join(__dirname, '.auth');
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
export default async function globalSetup(): Promise<void> {
await assertBackendUp();
await fs.mkdir(AUTH_DIR, { recursive: true });
const token = await loginAsAdmin();
await sweepOrphans(token);
await persistAdminStorageState(token);
}
async function assertBackendUp(): Promise<void> {
const probe = await request.newContext();
try {
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
if (!res.ok()) {
throw new Error(
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
);
}
} catch (err) {
throw new Error(
`Could not reach backend at ${API_BASE}/healthz. ` +
`Bring it up before running E2E tests:\n\n` +
` docker compose up -d postgres\n` +
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
` cargo run -p picloud\n\n` +
`Underlying error: ${(err as Error).message}`
);
} finally {
await probe.dispose();
}
}
async function loginAsAdmin(): Promise<string> {
const ctx = await request.newContext();
try {
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
headers: { 'content-type': 'application/json' }
});
if (!res.ok()) {
const body = await res.text();
throw new Error(
`Admin login failed (${res.status()}): ${body}. ` +
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
);
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
throw new Error('Admin login response missing token field');
}
return payload.token;
} finally {
await ctx.dispose();
}
}
// Clean up apps + admin users left over from a previous crashed run.
// The convention is that every e2e-created resource has a slug
// starting with `e2e-` (apps) or a username starting with `e2e`
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
// not stop the suite from running.
async function sweepOrphans(token: string): Promise<void> {
const ctx = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${token}` }
});
try {
try {
const res = await ctx.get('/api/v1/admin/apps');
if (res.ok()) {
const apps = (await res.json()) as Array<{ slug: string }>;
for (const app of apps) {
if (!app.slug.startsWith('e2e-')) continue;
try {
await ctx.delete(
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
);
} catch {
// Individual delete failure is non-fatal — the per-test
// cleanup will catch it on the next run.
}
}
}
} catch {
// Listing failed; nothing to do but proceed.
}
try {
const res = await ctx.get('/api/v1/admin/admins');
if (res.ok()) {
const admins = (await res.json()) as Array<{ id: string; username: string }>;
for (const a of admins) {
if (!/^e2e/.test(a.username)) continue;
try {
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
} catch {
// Same per-row tolerance as above.
}
}
}
} catch {
// Listing failed; same as above.
}
} finally {
await ctx.dispose();
}
}
// The dashboard reads its session from localStorage under the key
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
// localStorage without a browser context, so launch a throwaway one,
// seed the value, then save storageState for every test to reuse.
async function persistAdminStorageState(token: string): Promise<void> {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
await context.storageState({ path: ADMIN_STATE_PATH });
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,158 @@
import { expect, request, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Full-stack integration scenarios. Unlike the per-page B1B8 specs,
// these drive a complete user journey across multiple pages and then
// verify the data plane / API surface behaves the way the dashboard
// promised it would.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
const cm = page.locator(locator).first();
await cm.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type(text);
}
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('public');
const domain = `${slug}.local`;
const routePath = `/${slug}/hello`;
const scriptName = `${slug}-hello`;
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
// 1. Create the app from the apps list.
await page.goto('/admin/apps');
await page.getByRole('button', { name: 'New app' }).click();
await page.getByLabel('Name').fill(slug);
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
await slugInput.fill(slug);
await page.getByRole('button', { name: 'Create app' }).click();
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
// 2. Open the app and claim the domain on the Domains tab.
await page.getByRole('link', { name: new RegExp(slug) }).click();
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
const domainForm = page.locator('form.create-form.inline');
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
await expect(page.locator('.domain-row')).toContainText(domain);
// 3. Create the script on the Scripts tab.
await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click();
await page.getByRole('button', { name: /^New script$/ }).click();
await page.getByLabel('Name').fill(scriptName);
await fillCodeMirror(page, '.cm-content', scriptSource);
await page.getByRole('button', { name: /^Create script$/ }).click();
// 4. Open the script and bind a route on the Routing tab.
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
await page.getByRole('button', { name: 'Routing' }).click();
await page.getByRole('button', { name: '+ Add route' }).click();
const routeForm = page.locator('form.route-form');
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
await routeForm.getByLabel('Method').selectOption('GET');
await routeForm.getByLabel(/^Host/).fill(domain);
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText(routePath);
// 5. Invoke via the public URL, with the Host header pointing at
// the claimed domain. The dev backend listens on 127.0.0.1; the
// orchestrator resolves the app from Host, then the route.
const publicCtx = await request.newContext({ baseURL: API_BASE });
try {
const res = await publicCtx.get(routePath, { headers: { host: domain } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { source: string; slug: string };
expect(body.source).toBe('public');
expect(body.slug).toBe(slug);
} finally {
await publicCtx.dispose();
}
});
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
page,
uniqueUsername
}) => {
// Worker-aware unique helper instead of Date.now() — keeps two
// workers from minting the same name on the same millisecond.
const name = uniqueUsername('cli');
// 1. Mint the key from /profile and capture the revealed token.
await page.goto('/admin/profile');
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
const mintForm = page.locator('form.mint');
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
// script:read is enough to read the scripts list — that's our
// "CLI verb" below.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
await expect(reveal).toBeVisible();
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
expect(rawToken).toBeTruthy();
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
// 2. Act like a CLI: call the API directly with Bearer <token>.
const cli = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
});
try {
const ok = await cli.get('/api/v1/admin/scripts');
expect(ok.status()).toBe(200);
const body = (await ok.json()) as unknown;
expect(Array.isArray(body)).toBe(true);
// Sanity: a route the scope doesn't cover must reject.
// `script:read` cannot list instance admins (that's
// instance:admin territory).
const denied = await cli.get('/api/v1/admin/admins');
expect(denied.status()).toBe(403);
// 3. Revoke via the dashboard.
await page.reload();
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
await expect(revokeBtn).toBeVisible();
await revokeBtn.click();
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
await expect(revokeBtn).toHaveCount(0);
// 4. Same CLI call must now fail auth.
const afterRevoke = await cli.get('/api/v1/admin/scripts');
expect(afterRevoke.status()).toBe(401);
} finally {
await cli.dispose();
}
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
const api = await adminApi();
try {
const list = await api.get('/api/v1/admin/api-keys');
if (list.ok()) {
const all = (await list.json()) as Array<{ id: string; name: string }>;
const k = all.find((x) => x.name === name);
if (k) cleanup.apiKey(k.id);
}
} finally {
await api.dispose();
}
});

View File

@@ -0,0 +1,168 @@
import { expect } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
// Phase B5 — App Members. Setup creates one or two extra admin
// users via the API; tests drive the Members tab through the
// dashboard like a real app admin would.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createApp(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function createMemberUser(username: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/admins', {
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
test.describe('B5 app members', () => {
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('inv');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
// Invite. Both selects sit in `form.create-form`; locate them
// by position to avoid getByLabel ambiguity (the Svelte
// markup nests both labels in a flex row, which makes their
// accessible names overlap).
const form = page.locator('form.create-form');
await form.locator('select').nth(0).selectOption({ label: username });
await form.locator('select').nth(1).selectOption('editor');
await page.getByRole('button', { name: /^Add member$/ }).click();
await expect(page.locator('.member-row')).toContainText(username);
// Remove via action menu + confirm modal.
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
await expect(page.locator('.member-row')).toHaveCount(0);
});
test('role change via action menu updates the role chip', async ({
page,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('role');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
// Seed the membership via API to skip the invite UI.
const api = await adminApi();
try {
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
data: { user_id: userId, role: 'viewer' }
});
expect(res.ok()).toBe(true);
} finally {
await api.dispose();
}
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
const row = page.locator('.member-row', { hasText: username });
await expect(row).toContainText(/editor/i);
});
test('non-app-admin viewers do not see the Members tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('viewer');
const password = 'e2e-member-pw';
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
// Grant viewer membership (not app_admin) so the user can see
// the app at all.
const api = await adminApi();
try {
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
data: { user_id: userId, role: 'viewer' }
});
expect(res.ok()).toBe(true);
} finally {
await api.dispose();
}
const token = await loginAsUserToken(username, password);
const viewerPage = await pageWithUserToken(browser, token);
try {
await viewerPage.goto(`/admin/apps/${slug}`);
// Scripts tab loads — that's what a viewer sees.
await expect(
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Members tab button is absent for non-app-admins.
await expect(
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await viewerPage.context().close();
}
});
});
test.describe('B5 app members adversarial', () => {
test('role dropdown exposes only the documented values', async ({
page,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('rolelist');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
const form = page.locator('form.create-form');
const roleSelect = form.locator('select').nth(1);
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
Array.from(el.options).map((o) => o.value)
);
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
});
});

View File

@@ -0,0 +1,150 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
// and adversarial inputs.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createApp(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function openMintForm(page: Page): Promise<void> {
await page.goto('/admin/profile');
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
}
async function registerKeyCleanupByName(name: string): Promise<void> {
const api = await adminApi();
try {
const res = await api.get('/api/v1/admin/api-keys');
const all = (await res.json()) as Array<{ id: string; name: string }>;
const k = all.find((x) => x.name === name);
if (k) cleanup.apiKey(k.id);
} finally {
await api.dispose();
}
}
test.describe('B7 profile + API keys', () => {
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
const name = `e2e-mint-${Date.now()}`;
await openMintForm(page);
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
// Pick a non-instance scope so we don't need to worry about
// mutual exclusion here. The scope-chip is a <label> wrapping
// the checkbox — clicking the label toggles it.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
await expect(reveal).toBeVisible();
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
await registerKeyCleanupByName(name);
await expect(page.getByText(name)).toBeVisible();
});
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('keyapp');
const appId = await createApp(slug);
cleanup.app(slug);
await openMintForm(page);
// Default binding is Instance-wide — instance scopes are
// enabled.
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
await expect(instChip).not.toHaveClass(/disabled/);
// Switch binding to the app. The chip becomes disabled.
await page.getByLabel(/Binding/i).selectOption(appId);
await expect(instChip).toHaveClass(/disabled/);
});
test('revoke key removes it from the list', async ({ page }) => {
const name = `e2e-revoke-${Date.now()}`;
// Seed a key via API so the test focuses on the revoke UI.
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/api-keys', {
data: { name, scopes: ['script:read'] }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
cleanup.apiKey(body.id);
} finally {
await api.dispose();
}
await page.goto('/admin/profile');
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
await expect(revokeBtn).toBeVisible();
await revokeBtn.click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
// Assert the row's revoke button is gone (the flash banner
// also mentions the name, so a plain getByText would still
// match — anchor on the row-scoped button instead).
await expect(revokeBtn).toHaveCount(0);
});
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
await page.goto('/admin/profile?denied=users');
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
});
});
test.describe('B7 profile adversarial', () => {
test('empty name keeps the mint button disabled', async ({ page }) => {
await openMintForm(page);
// Trying to click would HTML5-validate; instead verify the
// button is disabled while name is empty.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
});
test('copy-token button copies the full token, not a truncated form', async ({
page,
context
}) => {
// Permission must be granted explicitly; chromium will throw
// otherwise when calling navigator.clipboard.readText().
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const name = `e2e-copy-${Date.now()}`;
await openMintForm(page);
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
const tokenInDom = await reveal.locator('code.token').textContent();
expect(tokenInDom).toBeTruthy();
await reveal.getByRole('button', { name: /^Copy$/ }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe(tokenInDom);
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
await registerKeyCleanupByName(name);
});
});

View File

@@ -0,0 +1,189 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B4 — Routing tab in the script editor. Add / remove / match
// preview + validation paths (host check, path-kind mismatch, reserved
// prefix, duplicate conflict, adversarial paths).
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(appRes.ok()).toBe(true);
const appBody = (await appRes.json()) as { id: string };
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptBody = (await scriptRes.json()) as { id: string };
return { appId: appBody.id, scriptId: scriptBody.id };
} finally {
await api.dispose();
}
}
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Routing' }).click();
}
async function addRoute(
page: Page,
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
): Promise<void> {
await page.getByRole('button', { name: '+ Add route' }).click();
const form = page.locator('form.route-form');
await form.getByLabel('Path', { exact: true }).fill(opts.path);
if (opts.pathKind) {
await form.getByLabel('Path kind').selectOption(opts.pathKind);
}
if (opts.method !== undefined) {
await form.getByLabel('Method').selectOption(opts.method);
}
if (opts.host !== undefined) {
await form.getByLabel(/^Host/).fill(opts.host);
}
}
test.describe('B4 routing', () => {
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('addr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/greet', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/greet');
// Match preview confirms the route resolves.
await page.getByLabel('URL').fill('http://localhost/greet');
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
await expect(page.locator('pre.preview')).toContainText('script_id');
});
test('remove route updates the list', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('remr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/transient', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/transient');
// removeRoute() uses window.confirm — accept it.
page.once('dialog', (d) => void d.accept());
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
await expect(page.locator('.route-list')).toHaveCount(0);
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('dupr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/twice');
// Same path + method again — must conflict.
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
});
test('path-kind mismatch warns inline when /:name is set to exact', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('mism');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
// Override to a wrong kind — auto-detect would have picked
// `param`; selecting `exact` should fire the warning.
await page.getByLabel('Path kind').selectOption('exact');
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
});
test('host validation warns when the host is not a claimed domain', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('unclaim');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/x');
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
// One of the inline warnings is the unclaimed-host explainer.
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
});
});
test.describe('B4 routing adversarial', () => {
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('reserv');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
await expect(page.locator('.route-form .error.inline')).toContainText(
/reserved|api|prefix/i
);
// Empty-state copy renders when no routes exist; the path
// itself must not appear anywhere on the routing tab.
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('xss payload in path stored or rejected — never executes on render', async ({
page,
uniqueSlug
}) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
const slug = uniqueSlug('pxss');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, {
path: '/<script>alert(1)</script>',
method: 'GET'
});
await page.getByRole('button', { name: /^Create route$/ }).click();
// Either accepted (rendered as text in the list) or rejected
// (error inline). Both fine — what's NOT fine is an alert
// dialog or an injected <script> tag in the list.
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
expect(xssScripts).toBe(0);
});
});

View File

@@ -0,0 +1,337 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppScriptAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor';
}): Promise<{ scriptId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptId = ((await scriptRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { scriptId, userId };
} finally {
await api.dispose();
}
}
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
// /admin/scripts/{id}. Setup uses the API to create the app (and
// sometimes a baseline script) so each test can focus on the editor
// flow it actually covers.
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createAppViaApi(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
return body.id;
} finally {
await api.dispose();
}
}
async function createScriptViaApi(
appId: string,
name: string,
source = HELLO_RHAI
): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name, source }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
return body.id;
} finally {
await api.dispose();
}
}
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
const cm = page.locator(locator).first();
await cm.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type(text);
}
test.describe('B3 scripts CRUD', () => {
test('create script via UI navigates to scripts list with the new entry', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('cscr');
await createAppViaApi(slug);
cleanup.app(slug);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^New script$/ }).click();
await page.getByLabel('Name').fill('echo');
// The CodeMirror editor starts empty in create mode; type a
// minimal valid script.
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
await page.getByRole('button', { name: 'Create script' }).click();
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
});
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('edit');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'edit-target');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
await fillCodeMirror(page, '.cm-content', updated);
await page.getByRole('button', { name: /^Save$/ }).click();
// Save button becomes disabled once the buffer matches the
// just-saved source — that's our settle signal.
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
await page.reload();
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
});
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('invrhai');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
await page
.locator('.editor-header')
.getByRole('button', { name: 'Format' })
.click();
await expect(page.locator('.error.inline').first()).toBeVisible();
});
});
test.describe('B3 test-invoke', () => {
test('valid JSON body returns status + body in the result panel', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('inv-ok');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
// Body editor is the second .cm-content (source is first).
const bodyEditor = page.locator('.cm-content').nth(1);
await bodyEditor.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type('{"hello":"world"}');
await page.getByRole('button', { name: /^Send$/ }).click();
await expect(page.locator('.status')).toContainText('HTTP 200');
await expect(page.locator('.result pre')).toContainText('ok');
});
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('inv-bad');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
const bodyEditor = page.locator('.cm-content').nth(1);
await bodyEditor.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type('{not valid json,');
// The Format button for the request body sits inside the
// Test-invoke card next to the body editor.
await page
.locator('.json-block')
.first()
.getByRole('button', { name: 'Format' })
.click();
await expect(page.locator('.error.inline').first()).toBeVisible();
});
});
test.describe('B3 settings', () => {
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('settz');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'settings-target');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Settings' }).click();
const timeout = page.getByLabel(/Timeout/);
await timeout.fill('0');
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(invalid).toBe(true);
});
});
test.describe('B3 scripts role shadowing', () => {
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vscr');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Header Delete is hidden for non-admins.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Save/Format on the Edit tab are hidden for viewers.
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
await expect(
page.locator('.editor-header').getByRole('button', { name: 'Format' })
).toHaveCount(0);
// Test invoke is still visible (everyone with read access).
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
// Routing tab loads, no +Add route.
await page.getByRole('button', { name: /Routing/ }).click();
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
// Settings tab is absent for non-admins.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer: CodeMirror is read-only', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vro');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
const cm = page.locator('.cm-content').first();
await expect(cm).toBeVisible();
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
// is in effect; that's the canonical signal for read-only mode.
await expect(cm).toHaveAttribute('contenteditable', 'false');
} finally {
await page.context().close();
}
});
test('editor: Save visible, Delete header hidden', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('escr');
const username = uniqueUsername('editor');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'editor'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Editor sees Save (disabled until the buffer changes — that's fine).
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
// Delete stays admin-only.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Settings stays admin-only.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
});
test.describe('B3 adversarial', () => {
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('loop');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(
appId,
'inf-loop',
'loop { let x = 1; }'
);
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: /^Send$/ }).click();
// Either the status renders with a 5xx code, or an error
// banner shows up. Either way, the page recovers.
await Promise.race([
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
]);
// The dashboard must remain interactive after the timeout.
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByLabel(/Timeout/)).toBeVisible();
});
});

View File

@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
// Phase B8 — Cross-cutting security. Things that aren't tied to a
// single page: session handling, secret leakage, error states for
// missing resources, and a sanity check that no XSS sink fires
// anywhere in the dashboard's main authed routes.
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
test.describe('B8 cross-cutting security', () => {
test('expired/stale token: any authed call redirects to /login', async ({ page }) => {
// Replace the storageState token with an obvious garbage
// value; the fetch wrapper treats 401 as "go to /login".
await page.goto('/admin/login');
await page.evaluate(() => {
localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token');
});
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
});
test('login response cookie is HttpOnly', async ({ request }) => {
const res = await request.post('/api/v1/admin/auth/login', {
data: { username: VALID_USERNAME, password: VALID_PASSWORD },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
const headers = res.headers();
const setCookie = headers['set-cookie'];
// Backend may or may not set a cookie (the dashboard primarily
// uses bearer-in-localStorage). If it does, it must be
// HttpOnly so XSS can't exfiltrate it.
if (setCookie) {
expect(setCookie.toLowerCase()).toContain('httponly');
}
});
test('bootstrap password is not present in the DOM after login', async ({ page }) => {
await page.goto('/admin/apps');
const body = await page.locator('body').innerText();
expect(body).not.toContain(VALID_PASSWORD);
});
test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => {
await page.goto('/admin/apps/does-not-exist-e2e-9999');
// Page must render *something* and the layout must remain
// intact (header link to Apps still works).
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
// And surface the failure to the user — either a "couldn't
// load" message or a "back to apps" link.
const errorOrBack = page.locator('.error, a[href$="/admin/apps"]');
await expect(errorOrBack.first()).toBeVisible();
});
test('xss probe across major surfaces never fires a dialog', async ({ page }) => {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(
`XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"`
);
});
// Cover each main authed route. None should evaluate any
// payload that earlier tests may have stored, and none should
// inject inline <script> tags from server responses.
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
await page.goto(path);
await page.waitForLoadState('domcontentloaded');
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
// Svelte itself injects no inline <script> in the
// production bundle; vite dev does, but never with
// onerror/alert payload text in them.
const evilInline = await page
.locator('script:has-text("alert"), script:has-text("__xss")')
.count();
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
}
});
});

View File

@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';
import { loginAsAdmin } from './fixtures/auth';
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
test.describe('smoke', () => {
test.describe('unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('root redirects to login and shows the form', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
});
test('valid credentials land on the apps page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
});
});
test('admin storageState already lands on apps', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});

View File

@@ -0,0 +1,224 @@
import { expect, type Browser, type Page, request } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B6 — Instance Users (/admin/users). Covers the bootstrap
// admin's view of the user directory: invite, edit, deactivate,
// search, delete, plus the member-role redirect and adversarial
// inputs to the invite form.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createMember(username: string, password = 'e2e-member-pw'): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/admins', {
data: { username, password, instance_role: 'member' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function loginToken(username: string, password: string): Promise<string> {
const ctx = await request.newContext({ baseURL: API_BASE });
try {
const res = await ctx.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await ctx.dispose();
}
}
async function pageWithToken(browser: Browser, token: string): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}
test.describe('B6 instance users', () => {
test('invite happy path: form → reveal modal → user in list', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('inv');
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill(username);
await modal.getByRole('radio', { name: /^Member/ }).check();
await modal.getByRole('button', { name: /^Create user$/ }).click();
// Reveal modal shows the one-time password.
const reveal = page.locator('.reveal-modal');
await expect(reveal).toBeVisible();
await expect(reveal).toContainText(/User created — /);
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
await reveal.getByRole('checkbox', { name: /shared this/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
// Now in the table.
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible();
// API cleanup — we don't have the user id from the UI alone.
const api = await adminApi();
try {
const list = await api.get('/api/v1/admin/admins');
const all = (await list.json()) as Array<{ id: string; username: string }>;
const u = all.find((x) => x.username === username);
if (u) cleanup.adminUser(u.id);
} finally {
await api.dispose();
}
});
test('username live validation: bad chars → submit disabled', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill('UPPER_CASE_invalid');
await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i);
await modal.getByRole('radio', { name: /^Member/ }).check();
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
});
test('search filters the table by username', async ({ page, uniqueUsername }) => {
const target = uniqueUsername('hit');
const decoy = uniqueUsername('miss');
const ids = await Promise.all([createMember(target), createMember(decoy)]);
ids.forEach((id) => cleanup.adminUser(id));
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(target);
await expect(page.locator('.row', { hasText: target })).toBeVisible();
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
});
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('toggle');
const userId = await createMember(username);
cleanup.adminUser(userId);
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(username);
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await expect(row).toBeVisible();
// Deactivate opens the confirm modal.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(username);
// Cancel leaves the user active.
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
await expect(dialog).toHaveCount(0);
await expect(row).not.toContainText(/inactive/i);
// Open again and confirm — user becomes inactive.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
await expect(row).toContainText(/inactive/i);
// Reactivate is still one-click (non-destructive — no modal).
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
await expect(row).not.toContainText(/inactive/i);
});
test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('del');
const userId = await createMember(username);
cleanup.adminUser(userId);
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(username);
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Delete$/ }).click();
const dialog = page.getByRole('dialog');
const confirm = dialog.getByRole('button', { name: /^Delete user$/ });
await expect(confirm).toBeDisabled();
await dialog.getByRole('textbox').fill('not-the-username');
await expect(confirm).toBeDisabled();
await dialog.getByRole('textbox').fill(username);
await expect(confirm).toBeEnabled();
await confirm.click();
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0);
});
test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({
browser,
uniqueUsername
}) => {
const username = uniqueUsername('memvw');
const password = 'e2e-member-pw';
const userId = await createMember(username, password);
cleanup.adminUser(userId);
const token = await loginToken(username, password);
const memberPage = await pageWithToken(browser, token);
try {
await memberPage.goto('/admin/users');
await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/);
await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible();
} finally {
await memberPage.context().close();
}
});
});
test.describe('B6 instance users adversarial', () => {
test('username too short: live invalid + submit disabled', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2
await expect(modal.locator('small.invalid')).toBeVisible();
await modal.getByRole('radio', { name: /^Member/ }).check();
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
});
test('email with script tag fails validation, never executes', async ({ page }) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel(/Email/).fill('<script>alert(1)</script>@x');
await expect(modal.locator('small.invalid')).toContainText(/email/i);
});
});

View File

@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['src/lib/rhai/**/*.test.ts'], include: ['src/lib/**/*.test.ts'],
environment: 'node' environment: 'node'
} }
}); });

View File

@@ -61,6 +61,7 @@ services:
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
restart: unless-stopped
ports: ports:
- "${PICLOUD_HOST_PORT:-8000}:80" - "${PICLOUD_HOST_PORT:-8000}:80"
volumes: volumes:

View File

@@ -126,10 +126,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version | | | Version |
|---|---| |---|---|
| Product | `0.5.1` | | Product | `0.6.0` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) | | SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` endpoints, `?app=` filter on script list) | | API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` and `/api/v1/admin/api-keys/*` endpoints, `?app=` filter on script list, `Authorization: Bearer pic_…` credential type, 403 responses on previously-401-only admin endpoints when the caller lacks the required capability) |
| Schema | `5` (matches `migrations/0005_apps.sql`) | | Schema | `6` (matches `migrations/0006_users_authz.sql`) |
| Wire | `1` (reserved; cluster mode not implemented) | | Wire | `1` (reserved; cluster mode not implemented) |
Read live from `GET /version` on any running instance. Read live from `GET /version` on any running instance.

View File

@@ -1,7 +1,7 @@
# Project Blueprint: Lightweight Event-Based Serverless Cloud # Project Blueprint: Lightweight Event-Based Serverless Cloud
**Status**: Phase 4 — Blueprint Complete **Status**: Phase 4 — Blueprint Complete
**Last Updated**: 2026-04-10 **Last Updated**: 2026-05-27
**Audience**: Solo developer (DIY self-hosted) **Audience**: Solo developer (DIY self-hosted)
--- ---
@@ -1022,6 +1022,183 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
--- ---
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`.
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
**Why this slot**: same logic as Phase 3b. Adding a `Principal` parameter and a capability check to surfaces that don't exist yet is free; retrofitting them onto live SDK services after v1.1 ships is a refactor of every gate.
### Principal Model
One `Principal` value represents a human admin user. Service accounts (CI bots, Rhai scripts calling out) get **schema room** in this phase but no runtime support — `users.kind` style differentiation lands when Phase 4's `users.*` SDK arrives. Until then, every authenticated request resolves to exactly one admin row, whether the credential is a session cookie or a bearer API key.
```rust
pub struct Principal {
pub user_id: UserId, // alias of AdminUserId for the transition
pub instance_role: InstanceRole,
pub scopes: Option<Vec<Scope>>, // None = cookie session (full role authority)
// Some = API key (intersect with role)
pub app_binding: Option<AppId>, // API key bound to one app; denies other apps
}
```
### Instance Roles (one per user)
| Role | Powers |
|---|---|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
| `admin` | create apps, invite users, implicit `app_admin` on every app. Cannot manage instance-wide settings (sandbox ceiling, etc.) or other owners. |
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
### App-Scoped Roles (zero-to-many per user × app)
| Role | Grants |
|---|---|
| `app_admin` | settings, domain claims, delete app, **delete scripts** |
| `editor` | create + edit scripts, routes, sandbox config (no script delete) |
| `viewer` | read scripts + execution logs |
Implicit grants from instance role: every `owner` and every `admin` is `app_admin` on every app — a single-human install would otherwise have to add itself to each new app's `app_members`. Explicit `app_members` rows are the only path for `member` users.
Script **save** uses `AppWriteScript` (editor+); script **delete** uses `AppAdmin` (app_admin+). Editors can iterate on a script's source freely but cannot remove it — destructive cleanup stays with the role that also owns the app.
### Auth Methods — Same Principal, Different Extractor
Two credential types feed the same middleware:
1. **Session cookie** (Phase 3a, unchanged) — `picloud_session=<token>`. Extracted by header or cookie. SHA-256 lookup against `admin_sessions.token_hash`. Sliding 24h TTL. Produces `Principal { scopes: None, app_binding: None }`.
2. **Bearer API key** (new) — `Authorization: Bearer pic_<base32(32 random bytes)>`. The `pic_` prefix is the discriminator: present → API key path; absent → session path. The 8 chars immediately after `pic_` are indexed (`api_keys.prefix`); the full body after `pic_` is Argon2id-verified against each candidate's `hash`. Last-used timestamp updates inline.
Both paths converge on the same `Principal` extension; handlers cannot tell which credential was presented unless they introspect `principal.scopes`.
### API Key Format & Storage
- Raw form: `pic_<base32(32 random bytes, no padding)>` — ~56 chars total.
- Stored: 8-char prefix + Argon2id PHC hash of the body. Raw value returned **exactly once** in the `POST /api/v1/admin/api-keys` response; never logged, never readable again.
- Optional `expires_at`. Lookup queries always filter `expires_at IS NULL OR expires_at > NOW()`.
- Optional `app_id` ("bound key") — every `App*(other_app)` capability is denied for this key, regardless of the user's role.
### Scope Set (intentionally narrow)
Exactly seven scopes; no further subdivision until a real use case appears:
`script:read`, `script:write`, `route:write`, `domain:manage`, `log:read`, `app:admin`, `instance:admin`
Mint-time validation rejects unknown values. Bound keys (`app_id` set) cannot carry `instance:*` scopes — the combination is irreconcilable (a bound credential cannot claim instance-wide authority) and is rejected with 422.
### Effective Capability — `can(principal, capability)`
```
allow = role_grants(principal.instance_role, capability)
∧ (principal.scopes.is_none() required_scope(capability) ∈ principal.scopes)
∧ (principal.app_binding.is_none() capability.app_id() == principal.app_binding)
```
`role_grants` collapses the three tables (instance role + implicit app grants + explicit `app_members`) into a single yes/no. Each handler calls `state.authz.require(&principal, Capability::AppWrite(script.app_id))` after loading the resource (so the capability binds to the resource's actual `app_id`, not a path param the caller controls).
### Deactivation Symmetry
Phase 3a's `set_active(false)` wipes that user's `admin_sessions`. Phase 3.5 extends it to also set `expires_at = NOW()` on every row in `api_keys WHERE user_id = $1` — both credential surfaces become inert at the same moment, no enumeration window.
### CLI Auth Posture (forward note)
The eventual `picloud` CLI authenticates by **paste-the-token**, not OAuth: the user runs `picloud login`, the dashboard mints a fresh key (or the user mints one via `POST /api/v1/admin/api-keys`), and the CLI prompts for the raw token. The CLI binary itself is deferred; the dashboard surface and the bearer credential type land here so the CLI is a thin wrapper when it arrives.
### Schema (Migration 0006)
```sql
ALTER TABLE admin_users
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
CHECK (instance_role IN ('owner','admin','member')),
ADD COLUMN email TEXT UNIQUE,
ADD COLUMN mfa_secret TEXT; -- reserved slot, not built
CREATE TABLE app_members (
app_id UUID NOT NULL REFERENCES apps(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 (app_id, user_id)
);
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
hash TEXT NOT NULL, -- Argon2id PHC
prefix TEXT NOT NULL, -- first 8 chars after `pic_`
name TEXT NOT NULL,
scopes TEXT[] NOT NULL, -- intersected with role at check time
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NULL,
last_used_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
-- Reserved (not built this phase):
-- invites (token, email, instance_role, app_id, app_role, invited_by, expires_at, consumed_at)
-- service_accounts (id, name, owning_user_id, …)
```
### New Endpoints (additive — no API major bump)
```
POST /api/v1/admin/api-keys — { name, scopes[], app_id?, expires_at? }
→ 201 { …, raw_token } (raw returned exactly once)
GET /api/v1/admin/api-keys — list caller's own keys (no raw)
DELETE /api/v1/admin/api-keys/{id} — caller's own only
```
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
### App Member Management Endpoints
Exposes the `app_members` table as a first-class CRUD surface so app admins can manage who they share an app with from the dashboard, not just from SQL.
```
GET /api/v1/admin/apps/{id_or_slug}/members — list members (ordered by username),
joined with admin_users for
username / email / instance_role / is_active
POST /api/v1/admin/apps/{id_or_slug}/members — { user_id, role } → 201 enriched DTO
409 on duplicate (promotions go through PATCH)
422 if target user is_active = false
422 if target user instance_role != 'member'
(owners/admins have implicit authority;
an explicit row would be dead weight)
PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id} — { role } → 200 enriched DTO
404 if no existing membership
DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id} — 204 (idempotent — 204 also when missing)
```
All four are gated on `Capability::AppAdmin(app_id)`. Editors and viewers get 403 on list and never see the dashboard's Members tab.
**`my_role` on the app lookup endpoint.** `GET /api/v1/admin/apps/{id_or_slug}` now returns an additional `my_role: Option<AppRole>`, computed server-side from the principal: `Owner → app_admin`, `Admin → editor`, `Member → app_members.role`. The dashboard uses this single field to decide whether to render the Members tab (visible iff `my_role == app_admin`), keeping API and UI gate logic identical.
**No last-app-admin guard.** Unlike the last-owner protection on `admin_users`, removing the final `app_admin` row from `app_members` is allowed. Every `owner` instance-role user implicitly satisfies `Capability::AppAdmin(_)` via the top-level `role_grants` branch, so no app can become permanently orphaned — an owner can always re-issue grants. The `admin` instance role is only implicit *editor*, so it does **not** provide a fallback path; the owner guarantee alone is what makes the no-guard position safe.
**Dead-row sweep on promotion (deferred).** Promoting a user from `member``admin`/`owner` leaves their `app_members` rows in place. They become inert (implicit grants supersede), but are not auto-deleted. A future hook can sweep them; harmless for now.
Additive within `/api/v1/admin/...` — no API major bump per [docs/versioning.md](docs/versioning.md).
### Out of Scope (Phase 3.5)
Schema room only, not built:
- **Invites** — email-based join flow; `invites` table reserved in the migration comment block.
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl is the supported interface this phase — member management has a dashboard tab; see above), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
---
## 12. Development Roadmap ## 12. Development Roadmap
### Phase 1: MVP ✓ (Shipped) ### Phase 1: MVP ✓ (Shipped)
@@ -1048,13 +1225,15 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
### Phase 3: v1.0.x — Foundations (Current focus) ### Phase 3: v1.0.x — Foundations (Current focus)
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive. Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC. **3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred. **3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data. **3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
--- ---