15 Commits

Author SHA1 Message Date
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
MechaCat02
a393f11344 feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most.

App create form: the slug field used to come before the name field and
demanded the operator hand-roll a valid slug. Now the name field comes
first and the slug is derived from it live, GitLab-style — Unicode
NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß`
mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped
at the backend's 63-char limit. The auto-sync releases as soon as the
operator edits the slug manually, and re-engages if they clear it. The
slug input itself runs every keystroke and paste through the same
normalizer, so dirty input never reaches the form state.

Route create form: the three-way host-kind `<select>` plus a sometimes-
disabled input was confusing — operators routinely picked the wrong
kind, typed a host the app didn't claim, and only saw the error after
hitting Create. Replace with a single text input that infers the kind
from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` →
strict), shows the detected kind as a colored chip beside the field, and
suggests the app's existing domain claims via a `<datalist>`. The same
matching logic the backend runs in `validate_route_host_against_app`
now lives in `route-utils.ts` so the form can surface a soft "not
covered by any claim" warning *before* submit. Path also pre-fills to
`/` so the most common case is one click away.

Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version
sync, npm metadata cleanup) is folded in here since it surfaced during
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:20 +02:00
MechaCat02
ad5492a4bd feat(manager-core,dashboard): cascading app delete with styled confirmation modal
Deleting an app used to require zero scripts and zero domain claims —
practical for empty apps, painful for anything else. Add an opt-in
cascade so the operator can wipe an app in one click while keeping the
safe default for the no-flag case.

Backend: `DELETE /api/v1/admin/apps/{id}?force=true` runs a single
transaction that removes every script in the app (routes and execution
logs cascade via `script_id` FK), then deletes the app row (domains and
slug-history cascade off it). Without `?force=true` the handler still
returns the same `409 HasScripts { script_count }` payload it always did.

Frontend: a new `ConfirmModal.svelte` replaces the bare `window.confirm`
on this page. It's reusable — danger/neutral variants, optional
GitHub-style "type the slug to confirm" gate, ESC/backdrop cancel,
busy state, and a generic body slot — so future destructive actions can
adopt the same pattern instead of growing more browser dialogs. The app
delete confirmation now spells out exactly what disappears (script
count, domain claim list, "all routes & logs") and only enables the red
button once the slug is retyped. The domain-claim delete is also
wired through the modal so this page no longer uses `window.confirm`
anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:05 +02:00
MechaCat02
ee0dbc428f chore(compose): require bootstrap admin env vars instead of defaulting to admin/admin
The previous interpolation used `${PICLOUD_ADMIN_USERNAME:-admin}` and
`${PICLOUD_ADMIN_PASSWORD:-admin}`, which made docker compose silently
bootstrap a production stack with `admin`/`admin` whenever the operator
forgot to set them. Flip to `${VAR:?…}` so an unset value aborts
`docker compose up` with a clear "set this var" message; dev still gets
the convenient default through the gitignored `.env` (documented in
`.env.example`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:00:52 +02:00
MechaCat02
4c41374db4 feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.

Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
  detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
  rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
  swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
  existing scripts/routes backfill into it. Fresh installs additionally
  get the Hello World seed via seed_hello_world_if_fresh after
  migrations run (idempotent — only fires when the default app has no
  scripts).

Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.

Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.

Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.

Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.

Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:03:05 +02:00
62 changed files with 8195 additions and 614 deletions

View File

@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
# Public base URL the dashboard uses to render full URLs for user routes.
# Set to the host:port (and scheme) users actually reach in their browser.
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
# ---------- Bootstrap admin ----------
# Required. Used once on first startup to seed the admin_users table.
# Ignored on subsequent boots if the table is non-empty. For prod,
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
# raw password never lands in env or compose files; see blueprint §11.5.
PICLOUD_ADMIN_USERNAME=admin
PICLOUD_ADMIN_PASSWORD=admin

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
**Current focus (Phase 3, pre-v1.1):** admin auth gate, then multi-app scoping. The latter introduces `apps` as the top-level isolation boundary for scripts, routes, domains, and (later) data. See blueprint §11.5 for the design. Every v1.1+ feature must assume `app_id` exists as a scoping dimension.
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
## Three-Service Architecture

25
Cargo.lock generated
View File

@@ -408,6 +408,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "der"
version = "0.7.10"
@@ -1305,12 +1311,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "picloud"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-test",
"chrono",
"figment",
"picloud-executor-core",
"picloud-manager-core",
@@ -1325,11 +1332,12 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "picloud-executor"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"anyhow",
"picloud-executor-core",
@@ -1341,7 +1349,7 @@ dependencies = [
[[package]]
name = "picloud-executor-core"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"chrono",
"picloud-shared",
@@ -1355,7 +1363,7 @@ dependencies = [
[[package]]
name = "picloud-manager"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"anyhow",
"picloud-manager-core",
@@ -1367,13 +1375,14 @@ dependencies = [
[[package]]
name = "picloud-manager-core"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"argon2",
"async-trait",
"axum",
"base64",
"chrono",
"data-encoding",
"picloud-orchestrator-core",
"picloud-shared",
"rand 0.8.6",
@@ -1390,7 +1399,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"anyhow",
"picloud-orchestrator-core",
@@ -1402,7 +1411,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator-core"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"async-trait",
"axum",
@@ -1421,7 +1430,7 @@ dependencies = [
[[package]]
name = "picloud-shared"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"async-trait",
"chrono",

View File

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

View File

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

View File

@@ -0,0 +1,117 @@
-- Phase 3b multi-app scoping — see blueprint §11.5.
--
-- Apps are the top-level isolation boundary for scripts, routes, domain
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
-- route trie; cross-app resource access is not possible.
--
-- This migration is unconditional:
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
-- 2. Always inserts a "default" app claiming `localhost` so existing
-- installs get a usable home for their pre-existing scripts/routes.
-- 3. Backfills app_id on scripts, routes, execution_logs from the
-- default app row, then promotes the columns to NOT NULL + FK.
--
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
-- World script into it. Doing the seed in Rust keeps it testable and
-- lets the script source live in a real .rhai file.
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- URL-safe identifier; mutable via the rename flow which records
-- the prior slug in app_slug_history for permanent 301 redirects.
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
-- check) lives in Rust handlers, not SQL.
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Domain claims. Most-specific wins at request time; same-shape
-- collisions are rejected at claim time via the UNIQUE(shape_key).
-- shape_key encoding:
-- exact:<lowercased-host> for shape='exact'
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
-- (parameterized is the same shape as wildcard for collision — the
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
CREATE TABLE app_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
shape_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
-- Permanent 301 redirects after a slug rename. A row dies only when
-- another app explicitly claims the retired slug (with confirmation in
-- the UI). On_delete cascade: if the owning app is deleted, its history
-- row goes too (otherwise the redirect would point at a dead app).
CREATE TABLE app_slug_history (
slug TEXT PRIMARY KEY,
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed the default app + a localhost claim. Used by both upgrade and
-- fresh-install paths; the Rust bootstrap layers Hello World on top
-- only when the install was fresh.
WITH default_app AS (
INSERT INTO apps (slug, name, description)
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
RETURNING id
)
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
-- Add app_id to scripts. The default app already exists (above), so
-- there is exactly one row to look up.
ALTER TABLE scripts ADD COLUMN app_id UUID;
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
ALTER TABLE scripts
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
-- Per-app name uniqueness. Two apps can each have a script called
-- "echo"; previously they could not.
DROP INDEX scripts_name_uidx;
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
-- Add app_id to routes, mirroring the script's app.
ALTER TABLE routes ADD COLUMN app_id UUID;
UPDATE routes
SET app_id = scripts.app_id
FROM scripts
WHERE routes.script_id = scripts.id;
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
ALTER TABLE routes
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
-- Replace the route uniqueness index so two apps can claim identical
-- (host_kind, host, path_kind, path, method) tuples — they live in
-- separate route trees and never see each other.
DROP INDEX routes_unique_binding_idx;
CREATE UNIQUE INDEX routes_unique_binding_idx
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
CREATE INDEX routes_app_id_idx ON routes (app_id);
-- Add app_id to execution_logs. Materialized at write time so future
-- script-moves (or eventual export/import) don't silently retag history.
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
UPDATE execution_logs
SET app_id = scripts.app_id
FROM scripts
WHERE execution_logs.script_id = scripts.id;
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
ALTER TABLE execution_logs
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
CREATE INDEX execution_logs_app_id_created_at_idx
ON execution_logs (app_id, created_at DESC);

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

@@ -0,0 +1,15 @@
// Hello World — the reference example seeded into the default app on
// fresh installs. Bound to GET /hello.
let who = ctx.request.body;
let name = if who != () && type_of(who) == "map" && who.contains("name") {
who.name
} else {
"world"
};
return #{
statusCode: 200,
headers: #{ "Content-Type": "application/json" },
body: #{ message: `Hello, ${name}!` }
};

View File

@@ -7,7 +7,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::AdminUserId;
use picloud_shared::{AdminUserId, InstanceRole};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
@@ -20,6 +20,12 @@ pub enum AdminUserRepositoryError {
#[error("username already taken: {0}")]
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
@@ -30,6 +36,8 @@ pub struct AdminUserRow {
pub id: AdminUserId,
pub username: String,
pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
@@ -44,6 +52,7 @@ pub struct AdminUserCredentials {
pub username: String,
pub password_hash: String,
pub is_active: bool,
pub instance_role: InstanceRole,
}
#[async_trait]
@@ -58,10 +67,14 @@ pub trait AdminUserRepository: Send + Sync {
username: &str,
) -> Result<Option<AdminUserCredentials>, 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.
async fn create(
&self,
username: &str,
password_hash: &str,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError>;
async fn update_username(
&self,
@@ -73,6 +86,14 @@ pub trait AdminUserRepository: Send + Sync {
id: AdminUserId,
password_hash: &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(
&self,
id: AdminUserId,
@@ -90,6 +111,15 @@ pub trait AdminUserRepository: Send + Sync {
&self,
id: AdminUserId,
) -> 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 {
@@ -107,13 +137,14 @@ impl PostgresAdminUserRepository {
impl AdminUserRepository for PostgresAdminUserRepository {
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
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",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
row.map(TryInto::try_into).transpose()
}
async fn get_by_username(
@@ -121,13 +152,14 @@ impl AdminUserRepository for PostgresAdminUserRepository {
username: &str,
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
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",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
row.map(TryInto::try_into).transpose()
}
async fn get_credentials_by_username(
@@ -135,42 +167,46 @@ impl AdminUserRepository for PostgresAdminUserRepository {
username: &str,
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
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",
)
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
row.map(TryInto::try_into).transpose()
}
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
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",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
rows.into_iter().map(TryInto::try_into).collect()
}
async fn create(
&self,
username: &str,
password_hash: &str,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let res = sqlx::query_as::<_, AdminUserRecord>(
"INSERT INTO admin_users (username, password_hash) \
VALUES ($1, $2) \
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
"INSERT INTO admin_users (username, password_hash, instance_role) \
VALUES ($1, $2, $3) \
RETURNING id, username, is_active, instance_role, email, \
created_at, updated_at, last_login_at",
)
.bind(username)
.bind(password_hash)
.bind(instance_role.as_str())
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Ok(row) => row.try_into(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
),
@@ -186,7 +222,8 @@ impl AdminUserRepository for PostgresAdminUserRepository {
let res = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET username = $2, updated_at = NOW() \
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(username)
@@ -194,7 +231,7 @@ impl AdminUserRepository for PostgresAdminUserRepository {
.await;
match res {
Ok(Some(row)) => Ok(row.into()),
Ok(Some(row)) => row.try_into(),
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
@@ -211,14 +248,34 @@ impl AdminUserRepository for PostgresAdminUserRepository {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
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(password_hash)
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(AdminUserRepositoryError::NotFound(id))
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_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(
@@ -229,14 +286,15 @@ impl AdminUserRepository for PostgresAdminUserRepository {
let row = sqlx::query_as::<_, AdminUserRecord>(
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
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(is_active)
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(AdminUserRepositoryError::NotFound(id))
row.ok_or(AdminUserRepositoryError::NotFound(id))
.and_then(TryInto::try_into)
}
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
@@ -277,6 +335,33 @@ impl AdminUserRepository for PostgresAdminUserRepository {
.await?;
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)]
@@ -284,21 +369,28 @@ struct AdminUserRecord {
id: uuid::Uuid,
username: String,
is_active: bool,
instance_role: String,
email: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
}
impl From<AdminUserRecord> for AdminUserRow {
fn from(r: AdminUserRecord) -> Self {
Self {
impl TryFrom<AdminUserRecord> for AdminUserRow {
type Error = AdminUserRepositoryError;
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
username: r.username,
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,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
}
})
}
}
@@ -308,15 +400,20 @@ struct AdminCredsRecord {
username: String,
password_hash: String,
is_active: bool,
instance_role: String,
}
impl From<AdminCredsRecord> for AdminUserCredentials {
fn from(r: AdminCredsRecord) -> Self {
Self {
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
type Error = AdminUserRepositoryError;
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
Ok(Self {
id: r.id.into(),
username: r.username,
password_hash: r.password_hash,
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::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::Router;
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::AdminUserId;
use picloud_shared::{AdminUserId, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::api_key_repo::ApiKeyRepository;
use crate::auth::hash_password;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
/// a strict ASCII subset so the lookup column stays predictable, and
@@ -36,6 +38,13 @@ const PASSWORD_MIN: usize = 8;
pub struct AdminsState {
pub users: Arc<dyn AdminUserRepository>,
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 {
@@ -57,6 +66,8 @@ pub struct AdminDto {
pub id: AdminUserId,
pub username: String,
pub is_active: bool,
pub instance_role: InstanceRole,
pub email: Option<String>,
pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
@@ -67,6 +78,8 @@ impl From<AdminUserRow> for AdminDto {
id: r.id,
username: r.username,
is_active: r.is_active,
instance_role: r.instance_role,
email: r.email,
created_at: r.created_at,
last_login_at: r.last_login_at,
}
@@ -77,6 +90,15 @@ impl From<AdminUserRow> for AdminDto {
pub struct CreateAdminRequest {
pub username: 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,
}
const fn default_create_role() -> InstanceRole {
InstanceRole::Admin
}
#[derive(Debug, Deserialize, Default)]
@@ -84,6 +106,7 @@ pub struct PatchAdminRequest {
pub username: Option<String>,
pub password: Option<String>,
pub is_active: Option<bool>,
pub instance_role: Option<InstanceRole>,
}
// ----------------------------------------------------------------------------
@@ -92,15 +115,29 @@ pub struct PatchAdminRequest {
async fn list_admins(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
let rows = state.users.list().await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn get_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<Json<AdminDto>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
state
.users
.get(id)
@@ -112,24 +149,49 @@ async fn get_admin(
async fn create_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAdminRequest>,
) -> 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();
validate_username(username)?;
validate_password(&input.password)?;
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)
.await?;
Ok((StatusCode::CREATED, Json(row.into())))
}
async fn patch_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
Json(input): Json<PatchAdminRequest>,
) -> Result<Json<AdminDto>, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
// Verify the target exists upfront — keeps the error path uniform
// for "rename a missing user" etc.
let _ = state
let current = state
.users
.get(id)
.await?
@@ -154,6 +216,26 @@ async fn patch_admin(
// for the initial cut.)
}
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 {
// Last-active-admin guard: only when transitioning to inactive.
if !new_active {
@@ -161,14 +243,40 @@ async fn patch_admin(
if remaining == 0 {
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?);
// Deactivation invalidates all of the user's sessions. Cheap
// and safer than waiting for sliding-window expiry.
// Deactivation invalidates BOTH credential surfaces — sessions
// (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 let Err(err) = state.sessions.delete_for_user(id).await {
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 +293,15 @@ async fn patch_admin(
async fn delete_admin(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<StatusCode, AdminApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::InstanceManageUsers,
)
.await?;
let target = state
.users
.get(id)
@@ -197,9 +312,18 @@ async fn delete_admin(
if remaining == 0 {
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?;
// Sessions cascade via FK; no explicit delete needed.
// Sessions + api_keys cascade via FK; no explicit delete needed.
Ok(StatusCode::NO_CONTENT)
}
@@ -252,6 +376,18 @@ pub enum AdminApiError {
#[error("cannot leave the system with zero active admins")]
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}")]
Hash(String),
@@ -259,16 +395,39 @@ pub enum AdminApiError {
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 {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
(StatusCode::CONFLICT, self.to_string())
}
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
Self::Repo(
AdminUserRepositoryError::DuplicateUsername(_)
| AdminUserRepositoryError::DuplicateEmail(_),
) => (StatusCode::CONFLICT, self.to_string()),
Self::InvalidUsername(_)
| Self::InvalidPassword(_)
| Self::LastActiveAdmin
| Self::LastActiveOwner
| Self::CannotEscalate
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
(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(_)) => {
(StatusCode::NOT_FOUND, self.to_string())
}

View File

@@ -5,17 +5,20 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
Extension, Json, Router,
};
use picloud_shared::{
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
ValidationError,
};
use serde::Deserialize;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
pub struct AdminState<R, L> {
pub repo: Arc<R>,
pub logs: Arc<L>,
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
/// filter on list. Trait-object so apps_repo can stay separate.
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 sandbox_ceiling: SandboxCeiling,
}
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
Self {
repo: self.repo.clone(),
logs: self.logs.clone(),
apps: self.apps.clone(),
authz: self.authz.clone(),
validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling,
}
@@ -70,6 +82,9 @@ where
#[derive(Debug, Deserialize)]
pub struct CreateScriptRequest {
/// Owning app. Required since Phase 3b — scripts cannot exist
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub source: String,
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
pub sandbox: ScriptSandbox,
}
#[derive(Debug, Deserialize)]
pub struct ListScriptsQuery {
/// Optional filter: list scripts belonging to a single app, by id
/// or slug. Absent = all scripts across all apps (admin-global view).
#[serde(default)]
pub app: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateScriptRequest {
pub name: Option<String>,
@@ -113,31 +136,83 @@ where
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Query(q): Query<ListScriptsQuery>,
) -> 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 {
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
return Ok(Json(state.repo.list_for_app(app).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
/// for redirects, but here we just need the live current id; if a
/// retired slug is given, we follow it to the current app silently.
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
let id = AppId::from(uuid);
apps.get_by_id(id)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
return Ok(id);
}
let lookup = apps
.get_by_slug_or_history(ident)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
Ok(lookup.app.id)
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
) -> Result<Json<Script>, ApiError> {
state
.repo
.get(id)
.await?
.map(Json)
.ok_or(ApiError::NotFound(id))
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
)
.await?;
Ok(Json(script))
}
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateScriptRequest>,
) -> 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.sandbox_ceiling.check(&input.sandbox)?;
// Refuse early if the app_id doesn't exist — a clean 422 beats a
// raw FK violation surfacing as 500.
if state.apps.get_by_id(input.app_id).await?.is_none() {
return Err(ApiError::AppNotFound(input.app_id.to_string()));
}
let created = state
.repo
.create(NewScript {
app_id: input.app_id,
name: input.name,
description: input.description,
source: input.source,
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
Json(input): Json<UpdateScriptRequest>,
) -> 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() {
state.validator.validate(src)?;
}
@@ -183,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
) -> Result<StatusCode, 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?;
state.repo.delete(id).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -203,9 +294,17 @@ const fn default_limit() -> i64 {
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
) -> 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
// unbounded over time so a paged read is the only sane default.
let limit = q.limit.clamp(1, 200);
@@ -223,6 +322,9 @@ pub enum ApiError {
#[error("script not found: {0}")]
NotFound(ScriptId),
#[error("app not found: {0}")]
AppNotFound(String),
#[error("conflict: {0}")]
Conflict(String),
@@ -232,18 +334,42 @@ pub enum ApiError {
#[error("{0}")]
Ceiling(#[from] CeilingError),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
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 {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
(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(_)) => {
(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,92 @@
//! Hello-World seed for fresh installs.
//!
//! Idempotent. Runs after migrations and after admin bootstrap. Only
//! seeds when the default app is empty (no scripts, no routes); on
//! upgrades it does nothing so existing content isn't polluted.
use std::sync::Arc;
use picloud_shared::{App, AppId, HostKind, PathKind};
use crate::app_repo::AppRepository;
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository};
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelloWorldOutcome {
/// Default app already has scripts (or doesn't exist) — left alone.
SkippedExisting,
/// Inserted the hello.rhai script and the `/hello` route.
Seeded,
}
#[derive(Debug, thiserror::Error)]
pub enum SeedError {
#[error("default app not found — did the migration run?")]
MissingDefaultApp,
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
pub async fn seed_hello_world_if_fresh(
apps: Arc<dyn AppRepository>,
scripts: Arc<dyn ScriptRepository>,
routes: Arc<dyn RouteRepository>,
) -> Result<HelloWorldOutcome, SeedError> {
let default = apps
.get_by_slug("default")
.await?
.ok_or(SeedError::MissingDefaultApp)?;
// Idempotence: only seed when both scripts AND routes are empty.
// (Either alone is suspicious enough to skip — the operator may have
// already started shaping the default app.)
let existing_scripts = scripts.list_for_app(default.id).await?;
let existing_routes = routes.list_for_app(default.id).await?;
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
return Ok(HelloWorldOutcome::SkippedExisting);
}
seed_into(&*scripts, &*routes, &default).await?;
Ok(HelloWorldOutcome::Seeded)
}
async fn seed_into(
scripts: &dyn ScriptRepository,
routes: &dyn RouteRepository,
default: &App,
) -> Result<(), ScriptRepositoryError> {
let script = scripts
.create(NewScript {
app_id: default.id,
name: "hello".to_string(),
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
source: HELLO_RHAI_SOURCE.to_string(),
timeout_seconds: Some(5),
memory_limit_mb: None,
sandbox: None,
})
.await?;
routes
.create(NewRoute {
app_id: default.id,
script_id: script.id,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/hello".to_string(),
// Accept any method so both `curl /hello` and
// `curl -d '{"name":"X"}' /hello` work out of the box.
method: None,
})
.await?;
Ok(())
}
#[allow(dead_code)]
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled

View File

@@ -0,0 +1,152 @@
//! CRUD over the `app_domains` table.
//!
//! Parsing + shape_key derivation live in `orchestrator-core`'s
//! `routing::pattern::parse_app_domain` — this repo just stores what
//! the API handler hands it. Same-shape collisions surface as a unique
//! constraint violation on `shape_key`, mapped here to a clean error.
use async_trait::async_trait;
use picloud_shared::{AppDomain, AppId, DomainShape};
use sqlx::PgPool;
use uuid::Uuid;
use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)]
pub struct NewAppDomain {
pub app_id: AppId,
pub pattern: String,
pub shape: DomainShape,
pub shape_key: String,
}
#[async_trait]
pub trait AppDomainRepository: Send + Sync {
/// All domain claims across all apps — used by the orchestrator's
/// `AppDomainTable` to build its lookup cache at startup and after
/// every write.
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
}
pub struct PostgresAppDomainRepository {
pool: PgPool,
}
impl PostgresAppDomainRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppDomainRepository for PostgresAppDomainRepository {
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, DomainRow>(
"SELECT id, app_id, pattern, shape, shape_key, created_at \
FROM app_domains ORDER BY pattern",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, DomainRow>(
"SELECT id, app_id, pattern, shape, shape_key, created_at \
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, DomainRow>(
"SELECT id, app_id, pattern, shape, shape_key, created_at \
FROM app_domains WHERE id = $1",
)
.bind(domain_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
let res = sqlx::query_as::<_, DomainRow>(
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
VALUES ($1, $2, $3, $4) \
RETURNING id, app_id, pattern, shape, shape_key, created_at",
)
.bind(input.app_id.into_inner())
.bind(&input.pattern)
.bind(shape_str(input.shape))
.bind(&input.shape_key)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(format!(
"domain {:?} (or another claim of the same shape) is already claimed",
input.pattern
)))
}
Err(e) => Err(e.into()),
}
}
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
.bind(domain_id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::Conflict(format!(
"domain {domain_id} not found"
)));
}
Ok(())
}
}
const fn shape_str(s: DomainShape) -> &'static str {
match s {
DomainShape::Exact => "exact",
DomainShape::Wildcard => "wildcard",
DomainShape::Parameterized => "parameterized",
}
}
#[derive(sqlx::FromRow)]
struct DomainRow {
id: Uuid,
app_id: Uuid,
pattern: String,
shape: String,
shape_key: String,
created_at: chrono::DateTime<chrono::Utc>,
}
impl From<DomainRow> for AppDomain {
fn from(r: DomainRow) -> Self {
Self {
id: r.id,
app_id: r.app_id.into(),
pattern: r.pattern,
shape: match r.shape.as_str() {
"wildcard" => DomainShape::Wildcard,
"parameterized" => DomainShape::Parameterized,
_ => DomainShape::Exact,
},
shape_key: r.shape_key,
created_at: r.created_at,
}
}
}

View File

@@ -0,0 +1,212 @@
//! 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};
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>,
}
#[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>;
/// 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>;
}
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 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()
}
}
/// 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,
})
}
}

View File

@@ -0,0 +1,423 @@
//! CRUD over the `apps` and `app_slug_history` tables.
//!
//! Slug validation (regex, reserved-word check) lives in the API
//! handler; this repo enforces only what Postgres enforces (uniqueness,
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
//! that writes the history row in the same transaction.
use async_trait::async_trait;
use picloud_shared::{AdminUserId, App, AppId};
use sqlx::PgPool;
use crate::repo::ScriptRepositoryError;
/// Result of looking up an app by slug or via the redirect history.
#[derive(Debug, Clone)]
pub struct AppLookup {
pub app: App,
/// `true` when the slug was found in `app_slug_history` rather than
/// directly on `apps`. Dashboards should issue a redirect.
pub redirected: bool,
}
#[async_trait]
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>;
/// 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_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history(
&self,
slug: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError>;
/// Create that also consumes a matching `app_slug_history` row, if
/// any. Used after the operator has confirmed they want to break old
/// redirects.
async fn create_with_takeover(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError>;
async fn update(
&self,
id: AppId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<App, ScriptRepositoryError>;
/// Rename and record the old slug in `app_slug_history` (so
/// retired URLs keep redirecting). If `take_over_history` is true,
/// any existing history row for `new_slug` is consumed.
async fn rename_slug(
&self,
id: AppId,
new_slug: &str,
take_over_history: bool,
) -> Result<App, ScriptRepositoryError>;
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
/// Delete the app along with all its scripts (which in turn cascades
/// routes and execution logs via their `script_id` FK). Domains and
/// app-slug-history rows cascade off the app row itself. Runs in a
/// single transaction so a partial delete cannot be observed.
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
}
pub struct PostgresAppRepository {
pool: PgPool,
}
impl PostgresAppRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppRepository for PostgresAppRepository {
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps ORDER BY name",
)
.fetch_all(&self.pool)
.await?;
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> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps WHERE id = $1",
)
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps WHERE slug = $1",
)
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug_or_history(
&self,
slug: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
if let Some(app) = self.get_by_slug(slug).await? {
return Ok(Some(AppLookup {
app,
redirected: false,
}));
}
if let Some(app) = self.slug_in_history(slug).await? {
return Ok(Some(AppLookup {
app,
redirected: true,
}));
}
Ok(None)
}
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
FROM app_slug_history h \
JOIN apps a ON a.id = h.current_app_id \
WHERE h.slug = $1",
)
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn create(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError> {
let res = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \
VALUES ($1, $2, $3) \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(slug)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(e) => Err(e.into()),
}
}
async fn create_with_takeover(
&self,
slug: &str,
name: &str,
description: Option<&str>,
) -> Result<App, ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
.bind(slug)
.execute(&mut *tx)
.await?;
let row = sqlx::query_as::<_, AppRow>(
"INSERT INTO apps (slug, name, description) \
VALUES ($1, $2, $3) \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(slug)
.bind(name)
.bind(description)
.fetch_one(&mut *tx)
.await;
let row = match row {
Ok(r) => r,
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(format!(
"slug {slug:?} is already in use"
)));
}
Err(e) => return Err(e.into()),
};
tx.commit().await?;
Ok(row.into())
}
async fn update(
&self,
id: AppId,
name: Option<&str>,
description: Option<Option<&str>>,
) -> Result<App, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>(
"UPDATE apps SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(id.into_inner())
.bind(name)
.bind(description.is_some())
.bind(description.and_then(|d| d))
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
}
async fn rename_slug(
&self,
id: AppId,
new_slug: &str,
take_over_history: bool,
) -> Result<App, ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
// 1. Read the current slug (so we can record it in history).
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
.bind(id.into_inner())
.fetch_optional(&mut *tx)
.await?;
let Some((current_slug,)) = current else {
return Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
)));
};
if current_slug == new_slug {
// No-op rename; just return the row.
let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \
FROM apps WHERE id = $1",
)
.bind(id.into_inner())
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
return Ok(row.into());
}
// 2. If renaming back to this app's own retired slug, just
// consume the history row silently (no warning, no takeover
// flag required).
let owns_history: Option<(uuid::Uuid,)> =
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
.bind(new_slug)
.fetch_optional(&mut *tx)
.await?;
match owns_history {
Some((owner,)) if owner == id.into_inner() => {
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
.bind(new_slug)
.execute(&mut *tx)
.await?;
}
Some(_) if take_over_history => {
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
.bind(new_slug)
.execute(&mut *tx)
.await?;
}
Some(_) => {
return Err(ScriptRepositoryError::Conflict(format!(
"slug {new_slug:?} is in history; rename with takeover to claim it"
)));
}
None => {}
}
// 3. Record the current slug in history (replacing any older
// entry — the same slug can pass through history multiple
// times across many renames).
sqlx::query(
"INSERT INTO app_slug_history (slug, current_app_id) \
VALUES ($1, $2) \
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
retired_at = NOW()",
)
.bind(&current_slug)
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
// 4. Apply the rename. Unique violation = another live app
// already holds this slug.
let row = sqlx::query_as::<_, AppRow>(
"UPDATE apps SET slug = $2, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, slug, name, description, created_at, updated_at",
)
.bind(id.into_inner())
.bind(new_slug)
.fetch_one(&mut *tx)
.await;
let row = match row {
Ok(r) => r,
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ScriptRepositoryError::Conflict(format!(
"slug {new_slug:?} is already in use by another app"
)));
}
Err(e) => return Err(e.into()),
};
tx.commit().await?;
Ok(row.into())
}
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
))),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
// ON DELETE RESTRICT on scripts.app_id — surface a clean
// "has dependents" error rather than a raw SQL message.
Err(ScriptRepositoryError::Conflict(
"app still contains scripts; delete or move them first".into(),
))
}
Err(e) => Err(e.into()),
}
}
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(id.into_inner())
.execute(&mut *tx)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::Conflict(format!(
"app {id} not found"
)));
}
tx.commit().await?;
Ok(())
}
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
.bind(id.into_inner())
.fetch_one(&self.pool)
.await?;
Ok(count.0)
}
}
#[derive(sqlx::FromRow)]
struct AppRow {
id: uuid::Uuid,
slug: String,
name: String,
description: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<AppRow> for App {
fn from(r: AppRow) -> Self {
Self {
id: r.id.into(),
slug: r.slug,
name: r.name,
description: r.description,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}

View File

@@ -0,0 +1,598 @@
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
//!
//! All endpoints are guarded by `require_admin`. Per-app permissions
//! are deferred (every authenticated admin can act on every app); the
//! middleware seam exists for when that lands.
//!
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
//! list rejected. Slug renames record the old slug in
//! `app_slug_history` for permanent 301 redirects; reclaiming a
//! historical slug requires `"force_takeover": true` in the request.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;
const SLUG_MIN: usize = 1;
const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
];
#[derive(Clone)]
pub struct AppsState {
pub apps: Arc<dyn AppRepository>,
pub domains: Arc<dyn AppDomainRepository>,
pub routes: Arc<dyn RouteRepository>,
/// Cached host → app_id lookup; replaced after every domain CRUD
/// operation so the orchestrator sees changes immediately.
pub domain_table: Arc<AppDomainTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
}
pub fn apps_router(state: AppsState) -> Router {
Router::new()
.route("/apps", get(list_apps).post(create_app))
.route(
"/apps/{id_or_slug}",
get(get_app).patch(patch_app).delete(delete_app),
)
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
.route(
"/apps/{id_or_slug}/domains",
get(list_domains).post(create_domain),
)
.route(
"/apps/{id_or_slug}/domains/{domain_id}",
delete(delete_domain),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AppDto {
#[serde(flatten)]
pub app: App,
}
#[derive(Debug, Deserialize)]
pub struct CreateAppRequest {
pub slug: String,
pub name: String,
pub description: Option<String>,
/// Set to `true` to consume an existing `app_slug_history` row for
/// the requested slug (breaking old redirects).
#[serde(default)]
pub force_takeover: bool,
}
#[derive(Debug, Deserialize)]
pub struct PatchAppRequest {
pub name: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_optional")]
#[allow(clippy::option_option)]
pub description: Option<Option<String>>,
pub slug: Option<String>,
#[serde(default)]
pub force_takeover: bool,
}
#[allow(clippy::option_option)]
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(d).map(Some)
}
#[derive(Debug, Deserialize)]
pub struct SlugCheckRequest {
pub new_slug: String,
}
#[derive(Debug, Serialize)]
pub struct SlugCheckResponse {
pub ok: bool,
pub conflict_kind: Option<&'static str>,
pub current_app: Option<App>,
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDomainRequest {
pub pattern: String,
}
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
/// a cascading delete that also removes every script in the app (and
/// thereby their routes and execution logs). Without it the request is
/// rejected when the app still contains scripts.
#[derive(Debug, Default, Deserialize)]
pub struct DeleteAppQuery {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct AppLookupResponse {
#[serde(flatten)]
pub app: App,
/// When the operator hits the API with a retired slug, this points
/// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_apps(
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(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAppRequest>,
) -> Result<(StatusCode, Json<App>), AppsApiError> {
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
validate_slug(&input.slug)?;
// Historical-slug check before insert: if the slug is in history
// and the caller hasn't asked to force takeover, surface a clean
// 409 so the dashboard can present a "this will break old links"
// confirmation.
if !input.force_takeover {
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
return Err(AppsApiError::SlugInHistory(current));
}
}
let created = if input.force_takeover {
s.apps
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
.await?
} else {
s.apps
.create(&input.slug, &input.name, input.description.as_deref())
.await?
};
Ok((StatusCode::CREATED, Json(created)))
}
async fn get_app(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<AppLookupResponse>, AppsApiError> {
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 {
Some(lookup.app.slug.clone())
} else {
None
};
Ok(Json(AppLookupResponse {
app: lookup.app,
redirect_to,
}))
}
async fn patch_app(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<PatchAppRequest>,
) -> Result<Json<App>, AppsApiError> {
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
// don't conflate the two errors).
let after_meta = if input.name.is_some() || input.description.is_some() {
s.apps
.update(
current.id,
input.name.as_deref(),
input.description.as_ref().map(|d| d.as_deref()),
)
.await?
} else {
current
};
// Slug rename is a separate operation; the rename method does its
// own history bookkeeping in a transaction.
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
validate_slug(new_slug)?;
match s
.apps
.rename_slug(after_meta.id, new_slug, input.force_takeover)
.await
{
Ok(app) => app,
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
return Err(AppsApiError::SlugInHistory(current));
}
return Err(AppsApiError::Conflict(msg));
}
Err(e) => return Err(e.into()),
}
} else {
after_meta
};
Ok(Json(after_rename))
}
async fn delete_app(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> {
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 {
s.apps.delete_cascade(app.id).await?;
} else {
// Soft pre-check for a clean error; the DB FK is the real guard
// (ON DELETE RESTRICT on scripts.app_id).
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
if n_scripts > 0 {
return Err(AppsApiError::HasScripts(n_scripts));
}
s.apps.delete(app.id).await?;
}
refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn slug_check(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SlugCheckRequest>,
) -> 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) {
Err(AppsApiError::InvalidSlug(reason)) => {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("invalid"),
current_app: None,
reason: Some(reason),
}));
}
Err(other) => return Err(other),
Ok(()) => {}
}
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("current"),
current_app: Some(app),
reason: Some("another app currently uses this slug".into()),
}));
}
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
return Ok(Json(SlugCheckResponse {
ok: false,
conflict_kind: Some("historical"),
current_app: Some(app),
reason: Some("slug is a retired redirect; using it will break old links".into()),
}));
}
Ok(Json(SlugCheckResponse {
ok: true,
conflict_kind: None,
current_app: None,
reason: None,
}))
}
async fn list_domains(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
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?))
}
async fn create_domain(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<CreateDomainRequest>,
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
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 created = s
.domains
.create(NewAppDomain {
app_id: app.id,
pattern: input.pattern,
shape: parsed.shape,
shape_key: parsed.shape_key,
})
.await?;
refresh_domain_cache(&s).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn delete_domain(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppsApiError> {
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 {
return Err(AppsApiError::DomainNotFound(domain_id));
};
if domain.app_id != app.id {
return Err(AppsApiError::DomainNotFound(domain_id));
}
// Guard: routes inside this app may reference this exact host
// pattern. The host-kind on the route is `strict` or `wildcard`
// (Any routes don't pin a specific host). We block deletion in
// either case and let the operator clean up first.
let strict = s
.routes
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
.await?;
let wild_suffix = domain
.pattern
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let wild = if wild_suffix.is_empty() {
0
} else {
s.routes
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
.await?
};
if strict + wild > 0 {
return Err(AppsApiError::DomainHasRoutes(strict + wild));
}
s.domains.delete(domain_id).await?;
refresh_domain_cache(&s).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
if let Ok(uuid) = ident.parse::<Uuid>() {
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?
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
}
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
return Err(AppsApiError::InvalidSlug(format!(
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
)));
}
if !slug
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return Err(AppsApiError::InvalidSlug(
"slug must start with [a-z0-9]".into(),
));
}
for c in slug.chars() {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err(AppsApiError::InvalidSlug(
"slug may only contain lowercase letters, digits, and '-'".into(),
));
}
}
if RESERVED_SLUGS.contains(&slug) {
return Err(AppsApiError::InvalidSlug(format!(
"slug {slug:?} is reserved for system use"
)));
}
Ok(())
}
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
/// Called after every domain CRUD operation.
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
let all = state.domains.list_all().await?;
let compiled = all
.into_iter()
.filter_map(|d| {
// Parse the stored pattern; skip on parse error rather than
// poisoning the entire cache. The handlers reject bad input,
// so this is purely defensive against a future migration
// that loosens the constraints.
pattern::parse_app_domain(&d.pattern)
.ok()
.map(|p| CompiledAppDomain {
app_id: d.app_id,
pattern: p.pattern,
shape_key: p.shape_key,
})
})
.collect();
state.domain_table.replace(compiled);
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AppsApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("domain not found: {0}")]
DomainNotFound(Uuid),
#[error("invalid slug: {0}")]
InvalidSlug(String),
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
SlugInHistory(App),
#[error("app still contains {0} script(s); delete or move them first")]
HasScripts(i64),
#[error("domain has {0} route(s) bound to it; delete the routes first")]
DomainHasRoutes(i64),
#[error("invalid pattern: {0}")]
Pattern(#[from] pattern::ParseError),
#[error("conflict: {0}")]
Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
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 IntoResponse for AppsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::DomainNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::InvalidSlug(_) | Self::Pattern(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::SlugInHistory(current) => (
StatusCode::CONFLICT,
json!({
"error": self.to_string(),
"conflict_kind": "historical",
"current_app": current,
}),
),
Self::HasScripts(n) => (
StatusCode::CONFLICT,
json!({ "error": self.to_string(), "script_count": n }),
),
Self::DomainHasRoutes(n) => (
StatusCode::CONFLICT,
json!({ "error": self.to_string(), "route_count": n }),
),
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
(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)) => {
tracing::error!(error = %e, "apps api db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt
use argon2::Argon2;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
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)]
mod tests {
use super::*;
@@ -129,4 +190,42 @@ mod tests {
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
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

@@ -22,8 +22,10 @@ use picloud_shared::AdminUserId;
use serde::{Deserialize, Serialize};
use serde_json::json;
use picloud_shared::Principal;
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 {
// /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.
let guarded = Router::new()
.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()
.route("/auth/login", post(login))
@@ -158,11 +160,25 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
(StatusCode::NO_CONTENT, headers).into_response()
}
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
Json(AdminUserDto {
id: admin.id,
username: admin.username,
async fn me(
State(state): State<AuthState>,
Extension(principal): Extension<Principal>,
) -> 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,
})
.into_response(),
Ok(None) => invalid_credentials(),
Err(err) => {
tracing::error!(?err, "admin_users lookup for /me failed");
internal_error()
}
}
}
// ----------------------------------------------------------------------------

View File

@@ -116,7 +116,15 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
(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,
)
.await?;
info!(username = %username, "bootstrapped initial admin user");
Ok(())
}
@@ -130,7 +138,7 @@ mod tests {
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::AdminUserId;
use picloud_shared::{AdminUserId, InstanceRole};
use std::sync::Mutex;
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
@@ -167,11 +175,14 @@ mod tests {
&self,
username: &str,
_password_hash: &str,
instance_role: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = AdminUserRow {
id: AdminUserId::new(),
username: username.to_string(),
is_active: true,
instance_role,
email: None,
created_at: Utc::now(),
updated_at: Utc::now(),
last_login_at: None,
@@ -193,6 +204,13 @@ mod tests {
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_instance_role(
&self,
_i: AdminUserId,
_r: InstanceRole,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn set_active(
&self,
_i: AdminUserId,
@@ -215,6 +233,15 @@ mod tests {
) -> Result<i64, AdminUserRepositoryError> {
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]
@@ -245,7 +272,9 @@ mod tests {
#[tokio::test]
async fn populated_db_is_noop() {
let repo = InMemoryRepo::default();
repo.create("seeded", "x").await.unwrap();
repo.create("seeded", "x", InstanceRole::Owner)
.await
.unwrap();
let env = BootstrapEnv {
username: Some("alice".into()),
password: Some("supersecret".into()),

View File

@@ -1,12 +1,17 @@
//! `require_admin` axum middleware: gates a router on a valid admin
//! session. Accepts the token from either the `picloud_session` cookie
//! or an `Authorization: Bearer …` header — same token system serves
//! the dashboard and CLI/CI clients.
//! Authentication middleware — resolves the caller's `Principal` from
//! either a session cookie / Bearer session-token OR an API key
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
//! request extension so downstream handlers see one shape.
//!
//! On success, injects `AuthedAdmin` as a request extension so handlers
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
//! returns 401 with a generic JSON body (no enumeration about whether
//! the token was wrong vs. the user was deactivated).
//! Capability checks live in `crate::authz` and are called per-handler
//! (after the relevant resource is loaded, so the capability binds to
//! the actual resource's `app_id`). This middleware is gate-only: it
//! 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::time::Duration;
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response};
use chrono::Utc;
use picloud_shared::AdminUserId;
use picloud_shared::{AdminUserId, Principal};
use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository;
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";
/// Shared state for auth: the two repos plus the configured sliding
/// session TTL. Cheap to clone (`Arc` everywhere).
/// Prefix on the wire that selects the API-key path. The body that
/// 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)]
pub struct AuthState {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration,
}
/// Request-extension type that authenticated handlers extract via
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
/// Legacy request-extension alias retained so the (only remaining)
/// 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)]
pub struct AuthedAdmin {
pub id: AdminUserId,
pub username: String,
}
/// Middleware function. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
pub async fn require_admin(
/// Middleware entry point. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
/// 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>,
mut req: Request<Body>,
next: Next,
@@ -53,48 +74,162 @@ pub async fn require_admin(
let Some(token) = extract_token(&req) else {
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 {
Ok(Some(lookup)) => lookup,
Ok(None) => return unauthorized(),
Ok(Some(l)) => l,
Ok(None) => return Ok(None),
Err(err) => {
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 {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return unauthorized(),
Ok(_) => return Ok(None),
Err(err) => {
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
// surfaces as a request error rather than silent stale sessions.
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
// than silent stale sessions. Same shape as Phase 3a.
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 {
tracing::error!(?err, "admin_sessions touch failed");
return internal_error();
return Err(InternalError);
}
req.extensions_mut().insert(AuthedAdmin {
id: user.id,
username: user.username,
});
next.run(req).await
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: None,
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)
/// 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> {
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = value.to_str() {
@@ -121,6 +256,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
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 {
(
StatusCode::UNAUTHORIZED,
@@ -141,6 +281,7 @@ fn internal_error() -> Response {
mod tests {
use super::*;
use axum::http::Request;
use picloud_shared::InstanceRole;
fn req_with_header(name: &str, value: &str) -> Request<Body> {
Request::builder()
@@ -155,6 +296,12 @@ mod tests {
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]
fn ignores_bearer_with_no_token() {
let r = req_with_header("authorization", "Bearer ");
@@ -182,4 +329,20 @@ mod tests {
let r = Request::builder().body(Body::empty()).unwrap();
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,599 @@
//! 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 `editor` on every app (per blueprint §11.6). They
/// can create apps and manage users, but NOT touch instance-wide
/// settings or take app-admin-only actions on apps they're not
/// explicitly app_admin of. Everything not in this set falls through
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
const fn admin_grants(cap: Capability) -> bool {
matches!(
cap,
Capability::InstanceCreateApp
| Capability::InstanceManageUsers
| Capability::AppRead(_)
| Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppLogRead(_)
)
}
/// 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_or_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
let app = AppId::new();
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::InstanceManageUsers)
.await
.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
// Editor-like grants succeed
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
Decision::Allow,
);
assert_eq!(
can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap(),
Decision::Allow,
);
// App-admin grants do not
assert_eq!(
can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap(),
Decision::Deny,
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[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
);
}
#[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,10 +8,18 @@ pub mod admin_session_repo;
pub mod admin_user_repo;
pub mod admin_users_api;
pub mod api;
pub mod api_key_repo;
pub mod api_keys_api;
pub mod app_bootstrap;
pub mod app_domain_repo;
pub mod app_members_repo;
pub mod app_repo;
pub mod apps_api;
pub mod auth;
pub mod auth_api;
pub mod auth_bootstrap;
pub mod auth_middleware;
pub mod authz;
pub mod log_sink;
pub mod migrations;
pub mod repo;
@@ -30,11 +38,28 @@ pub use admin_user_repo::{
};
pub use admin_users_api::{admins_router, AdminsState};
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_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
pub use app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
};
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
pub use apps_api::{apps_router, AppsState};
pub use auth_api::auth_router;
pub use auth_bootstrap::{
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 repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,

View File

@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
sqlx::query(
"INSERT INTO execution_logs ( \
id, script_id, request_id, \
id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
) VALUES ( \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
)",
)
.bind(log.id)
.bind(log.app_id.into_inner())
.bind(log.script_id.into_inner())
.bind(log.request_id.into_inner())
.bind(&log.request_path)

View File

@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
use picloud_shared::{
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
@@ -21,7 +23,18 @@ pub enum ScriptRepositoryError {
#[async_trait]
pub trait ScriptRepository: Send + Sync {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
/// Every script across all apps. Mostly for tests and admin
/// "global" views; the dashboard reaches scripts via `list_for_app`.
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
/// 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 update(
&self,
@@ -35,6 +48,7 @@ pub trait ScriptRepository: Send + Sync {
/// constraints; the repo enforces them in the DB regardless.
#[derive(Debug, Clone)]
pub struct NewScript {
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub source: String,
@@ -78,7 +92,7 @@ impl PostgresScriptRepository {
impl ScriptRepository for PostgresScriptRepository {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, ScriptRow>(
"SELECT id, name, description, version, source, \
"SELECT id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
FROM scripts WHERE id = $1",
)
@@ -90,7 +104,7 @@ impl ScriptRepository for PostgresScriptRepository {
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT id, name, description, version, source, \
"SELECT id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
FROM scripts ORDER BY name",
)
@@ -99,17 +113,48 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ScriptRow>(
"SELECT id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
FROM scripts WHERE app_id = $1 ORDER BY name",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_user(
&self,
user_id: AdminUserId,
) -> 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> {
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({}));
let res = sqlx::query_as::<_, ScriptRow>(
"INSERT INTO scripts ( \
name, description, source, \
app_id, name, description, source, \
timeout_seconds, memory_limit_mb, sandbox \
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
RETURNING id, name, description, version, source, \
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
RETURNING id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
)
.bind(input.app_id.into_inner())
.bind(&input.name)
.bind(input.description.as_deref())
.bind(&input.source)
@@ -123,7 +168,7 @@ impl ScriptRepository for PostgresScriptRepository {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(format!(
"a script named {:?} already exists",
"a script named {:?} already exists in this app",
input.name
)))
}
@@ -141,12 +186,13 @@ impl ScriptRepository for PostgresScriptRepository {
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
// Sandbox is replaced wholesale when present; per-field merging
// happens in the API layer (clearer semantics for a "PUT a new
// sandbox config" call).
// sandbox config" call). app_id is immutable — moving a script
// to another app is a copy-and-delete, not an in-place edit.
let sandbox_json = patch
.sandbox
.as_ref()
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
let row = sqlx::query_as::<_, ScriptRow>(
let res = sqlx::query_as::<_, ScriptRow>(
"UPDATE scripts SET \
name = COALESCE($2, name), \
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
@@ -157,7 +203,7 @@ impl ScriptRepository for PostgresScriptRepository {
version = version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING id, name, description, version, source, \
RETURNING id, app_id, name, description, version, source, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
)
.bind(id.into_inner())
@@ -169,10 +215,18 @@ impl ScriptRepository for PostgresScriptRepository {
.bind(patch.memory_limit_mb)
.bind(sandbox_json)
.fetch_optional(&self.pool)
.await?;
.await;
row.map(Into::into)
.ok_or(ScriptRepositoryError::NotFound(id))
match res {
Ok(Some(row)) => Ok(row.into()),
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(ScriptRepositoryError::Conflict(
"a script with that name already exists in this app".into(),
))
}
Err(e) => Err(e.into()),
}
}
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
@@ -191,6 +245,7 @@ impl ScriptRepository for PostgresScriptRepository {
#[derive(sqlx::FromRow)]
struct ScriptRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
name: String,
description: Option<String>,
version: i32,
@@ -211,6 +266,7 @@ impl From<ScriptRow> for Script {
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
Self {
id: r.id.into(),
app_id: r.app_id.into(),
name: r.name,
description: r.description,
version: r.version,
@@ -284,7 +340,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
offset: i64,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, ExecutionLogRow>(
"SELECT id, script_id, request_id, \
"SELECT id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \
response_code, response_body, \
logs, duration_ms, status, created_at \
@@ -306,6 +362,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
#[derive(sqlx::FromRow)]
struct ExecutionLogRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
script_id: uuid::Uuid,
request_id: uuid::Uuid,
request_path: Option<String>,
@@ -331,6 +388,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
};
Self {
id: r.id,
app_id: r.app_id.into(),
script_id: r.script_id.into(),
request_id: RequestId::from(r.request_id),
request_path: r.request_path.unwrap_or_default(),

View File

@@ -10,42 +10,56 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
Extension, Json, Router,
};
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::repo::ScriptRepositoryError;
use crate::app_domain_repo::AppDomainRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::route_repo::{NewRoute, RouteRepository};
pub struct RouteAdminState<RR> {
pub struct RouteAdminState<RR, SR> {
pub routes: Arc<RR>,
/// Used to resolve `script_id → app_id` when creating routes (the
/// route inherits the script's app) and to scope conflict checks.
pub scripts: Arc<SR>,
/// Used to validate the route's host against the parent app's
/// declared domain claims.
pub domains: Arc<dyn AppDomainRepository>,
pub table: Arc<RouteTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
}
impl<RR> Clone for RouteAdminState<RR> {
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
fn clone(&self) -> Self {
Self {
routes: self.routes.clone(),
scripts: self.scripts.clone(),
domains: self.domains.clone(),
table: self.table.clone(),
authz: self.authz.clone(),
}
}
}
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
where
RR: RouteRepository + 'static,
SR: ScriptRepository + 'static,
{
Router::new()
.route(
"/scripts/{id}/routes",
get(list_routes::<RR>).post(create_route::<RR>),
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
)
.route("/routes/{route_id}", delete(delete_route::<RR>))
.route("/routes:check", post(check_route::<RR>))
.route("/routes:match", post(match_route::<RR>))
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
.route("/routes:check", post(check_route::<RR, SR>))
.route("/routes:match", post(match_route::<RR, SR>))
.with_state(state)
}
@@ -67,6 +81,10 @@ pub struct CreateRouteRequest {
#[derive(Debug, Deserialize)]
pub struct CheckRouteRequest {
/// Required: which app's route table this hypothetical route would
/// join. Conflict checks are strictly intra-app (cross-app route
/// errors would leak tenant info — see blueprint §11.5).
pub app_id: AppId,
pub host_kind: HostKind,
#[serde(default)]
pub host: String,
@@ -84,6 +102,9 @@ pub struct CheckRouteResponse {
#[derive(Debug, Deserialize)]
pub struct MatchRouteRequest {
/// Which app's route table to dispatch against. The dashboard's
/// route-preview tester always knows the current app context.
pub app_id: AppId,
pub url: String,
#[serde(default = "default_method")]
pub method: String,
@@ -111,15 +132,28 @@ pub struct MatchedRoute {
// Handlers
// ----------------------------------------------------------------------------
async fn list_routes<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>,
) -> 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?))
}
async fn create_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
@@ -130,8 +164,28 @@ async fn create_route<RR: RouteRepository>(
input.host_param_name.as_deref(),
)?;
// Within-kind conflict check against existing routes.
let existing = state.routes.list_all().await?;
// Look up the script's owning app — every route inherits it.
let script = state
.scripts
.get(script_id)
.await?
.ok_or(RouteApiError::ScriptNotFound(script_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
// domain claims. `HostKind::Any` is always permitted (catches every
// host the app already owns). Specific hosts must match a claim.
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
.await?;
// Within-app conflict check (cross-app is impossible by construction).
let existing = state.routes.list_for_app(app_id).await?;
if let Some((conflicting, reason)) = first_conflict(
&existing,
input.host_kind,
@@ -149,6 +203,7 @@ async fn create_route<RR: RouteRepository>(
let created = state
.routes
.create(NewRoute {
app_id,
script_id,
host_kind: input.host_kind,
host: input.host,
@@ -162,23 +217,47 @@ async fn create_route<RR: RouteRepository>(
Ok((StatusCode::CREATED, Json(created)))
}
async fn delete_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Path(route_id): Path<Uuid>,
) -> 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?;
refresh_table(&state).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn check_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CheckRouteRequest>,
) -> 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)?;
pattern::parse_host(input.host_kind, &input.host, None)?;
let existing = state.routes.list_all().await?;
let existing = state.routes.list_for_app(input.app_id).await?;
let conflict = first_conflict(
&existing,
input.host_kind,
@@ -201,16 +280,25 @@ async fn check_route<RR: RouteRepository>(
}))
}
async fn match_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
State(state): State<RouteAdminState<RR, SR>>,
Extension(principal): Extension<Principal>,
Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(input.app_id),
)
.await?;
let parsed = url::Url::parse(&input.url)
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
let host = parsed.host_str().unwrap_or("").to_string();
let path = parsed.path().to_string();
let result = state.table.match_request(&host, &input.method, &path);
let result = state
.table
.match_request_for_app(input.app_id, &host, &input.method, &path);
Ok(Json(MatchRouteResponse {
matched: result.map(|r| MatchedRoute {
route_id: r.matched.route_id,
@@ -263,12 +351,12 @@ fn first_conflict(
Ok(None)
}
async fn refresh_table<RR: RouteRepository>(
state: &RouteAdminState<RR>,
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?;
state.table.replace(compiled);
state.table.replace_all(compiled);
Ok(())
}
@@ -277,6 +365,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.map(|r| {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
@@ -286,6 +375,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
.collect()
}
/// Validate that a new route's (host_kind, host) is consistent with at
/// least one of the parent app's domain claims. `HostKind::Any` is
/// always permitted — it catches every host the app already owns.
async fn validate_route_host_against_app(
domains: &dyn AppDomainRepository,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<(), RouteApiError> {
if matches!(host_kind, HostKind::Any) {
return Ok(());
}
let claims = domains.list_for_app(app_id).await?;
if claims.is_empty() {
return Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: vec![],
});
}
let host_lower = host.to_ascii_lowercase();
for claim in &claims {
let claim_lower = claim.pattern.to_ascii_lowercase();
match (host_kind, claim.shape) {
// Strict route under exact claim: must match exactly.
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
if host_lower == claim_lower {
return Ok(());
}
}
// Strict route under wildcard/parameterized: must end with
// ".<suffix>" where the claim's suffix is the part after
// `*.` or `{...}.`.
(
HostKind::Strict,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let needle = format!(".{suffix}");
if !suffix.is_empty() && host_lower.ends_with(&needle) {
return Ok(());
}
}
// Wildcard route: must match a wildcard or parameterized
// claim with identical suffix.
(
HostKind::Wildcard,
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
) => {
let claim_suffix = claim_lower
.split_once('.')
.map(|(_, s)| s.to_string())
.unwrap_or_default();
if claim_suffix == host_lower {
return Ok(());
}
}
// Wildcard route under exact claim: not allowed (would
// shadow other apps' subdomains the operator didn't claim).
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
(HostKind::Any, _) => unreachable!("handled above"),
}
}
Err(RouteApiError::HostNotClaimed {
host: host.to_string(),
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
})
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
@@ -304,10 +466,37 @@ pub enum RouteApiError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("script not found: {0}")]
ScriptNotFound(ScriptId),
#[error("route not found: {0}")]
RouteNotFound(Uuid),
#[error("host {host:?} is not claimed by this app")]
HostNotClaimed {
host: String,
available_claims: Vec<String>,
},
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
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 {
fn into_response(self) -> Response {
let (status, body) = match &self {
@@ -326,10 +515,34 @@ impl IntoResponse for RouteApiError {
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }),
),
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
Self::ScriptNotFound(_)
| Self::RouteNotFound(_)
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND,
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 {
host,
available_claims,
} => (
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({
"error": self.to_string(),
"host": host,
"available_claims": available_claims,
}),
),
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
StatusCode::CONFLICT,
serde_json::json!({ "error": self.to_string() }),

View File

@@ -1,10 +1,10 @@
//! CRUD over the `routes` table.
//!
//! The orchestrator's `RouteTable` is repopulated from this repo after
//! every write — see the route_admin module for the binding.
//! The orchestrator's `AppRouteTables` is repopulated from this repo
//! after every write — see the route_admin module for the binding.
use async_trait::async_trait;
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
use sqlx::PgPool;
use uuid::Uuid;
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)]
pub struct NewRoute {
pub app_id: AppId,
pub script_id: ScriptId,
pub host_kind: HostKind,
pub host: String,
@@ -24,12 +25,25 @@ pub struct NewRoute {
#[async_trait]
pub trait RouteRepository: Send + Sync {
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_script(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
/// Count routes whose host_kind/host pair matches a pattern in
/// `app_id`. Used by the domain-claim delete guard.
async fn count_for_app_host(
&self,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<i64, ScriptRepositoryError>;
}
pub struct PostgresRouteRepository {
@@ -47,7 +61,7 @@ impl PostgresRouteRepository {
impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, script_id, host_kind, host, host_param_name, \
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
FROM routes ORDER BY created_at",
)
@@ -56,12 +70,36 @@ impl RouteRepository for PostgresRouteRepository {
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> {
let rows = 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 app_id = $1 ORDER BY created_at",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_script(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, script_id, host_kind, host, host_param_name, \
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at",
)
@@ -74,12 +112,13 @@ impl RouteRepository for PostgresRouteRepository {
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
script_id, host_kind, host, host_param_name, \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method \
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, script_id, host_kind, host, host_param_name, \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind))
.bind(&input.host)
@@ -112,6 +151,24 @@ impl RouteRepository for PostgresRouteRepository {
}
Ok(())
}
async fn count_for_app_host(
&self,
app_id: AppId,
host_kind: HostKind,
host: &str,
) -> Result<i64, ScriptRepositoryError> {
let count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM routes \
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
)
.bind(app_id.into_inner())
.bind(host_kind_str(host_kind))
.bind(host)
.fetch_one(&self.pool)
.await?;
Ok(count.0)
}
}
const fn host_kind_str(k: HostKind) -> &'static str {
@@ -133,6 +190,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
#[derive(sqlx::FromRow)]
struct RouteRow {
id: Uuid,
app_id: Uuid,
script_id: Uuid,
host_kind: String,
host: String,
@@ -147,6 +205,7 @@ impl From<RouteRow> for Route {
fn from(r: RouteRow) -> Self {
Self {
id: r.id,
app_id: r.app_id.into(),
script_id: r.script_id.into(),
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,

View File

@@ -3,6 +3,64 @@
## tables
table: admin_sessions
token_hash: text NOT NULL
user_id: uuid NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
expires_at: timestamp with time zone NOT NULL
last_used_at: timestamp with time zone NOT NULL default=now()
table: admin_users
id: uuid NOT NULL default=gen_random_uuid()
username: text NOT NULL
password_hash: text NOT NULL
is_active: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
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
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
pattern: text NOT NULL
shape: text NOT NULL
shape_key: text NOT NULL
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
slug: text NOT NULL
current_app_id: uuid NOT NULL
retired_at: timestamp with time zone NOT NULL default=now()
table: apps
id: uuid NOT NULL default=gen_random_uuid()
slug: text NOT NULL
name: text NOT NULL
description: text NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: execution_logs
id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL
@@ -16,6 +74,7 @@ table: execution_logs
duration_ms: integer NOT NULL default=0
status: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
table: routes
id: uuid NOT NULL default=gen_random_uuid()
@@ -27,6 +86,7 @@ table: routes
path: text NOT NULL
method: text NULL
created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL
table: scripts
id: uuid NOT NULL default=gen_random_uuid()
@@ -39,42 +99,119 @@ table: scripts
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
sandbox: jsonb NOT NULL default='{}'::jsonb
app_id: uuid NOT NULL
## indexes
indexes on admin_sessions:
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
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_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:
app_domains_app_id_idx: public.app_domains USING btree (app_id)
app_domains_pkey: public.app_domains USING btree (id)
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:
app_slug_history_pkey: public.app_slug_history USING btree (slug)
indexes on apps:
apps_pkey: public.apps USING btree (id)
apps_slug_key: public.apps USING btree (slug)
indexes on execution_logs:
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
execution_logs_pkey: public.execution_logs USING btree (id)
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (id)
routes_script_id_idx: public.routes USING btree (script_id)
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
indexes on scripts:
scripts_name_uidx: public.scripts USING btree (lower(name))
scripts_app_id_idx: public.scripts USING btree (app_id)
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
scripts_pkey: public.scripts USING btree (id)
## constraints
constraints on admin_sessions:
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
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)
[UNIQUE] admin_users_email_key: UNIQUE (email)
[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:
[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
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
[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:
[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)
constraints on apps:
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
[UNIQUE] apps_slug_key: UNIQUE (slug)
constraints on execution_logs:
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
constraints on routes:
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
constraints on scripts:
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
## applied migrations
0001: init
0002: sandbox
0003: routes
0004: admin auth
0005: apps
0006: users authz

View File

@@ -17,22 +17,26 @@ use axum::{
use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
};
use serde_json::Value as Json_;
use uuid::Uuid;
use crate::client::ExecutorClient;
use crate::resolver::{ResolverError, ScriptResolver};
use crate::routing::RouteTable;
use crate::routing::{AppDomainTable, RouteTable};
/// State shared by data-plane handlers.
pub struct DataPlaneState<E, R> {
pub executor: Arc<E>,
pub resolver: Arc<R>,
pub log_sink: Arc<dyn ExecutionLogSink>,
/// Routing table for user-defined paths. Shared with the manager
/// (admin router writes; this side reads).
/// Host → app_id resolver. Run before `routes` to filter to the
/// owning app's slice. Shared with the manager (writes invalidate
/// the cache by replacing the table).
pub app_domains: Arc<AppDomainTable>,
/// Routing table for user-defined paths, partitioned per app.
/// Shared with the manager (admin router writes; this side reads).
pub routes: Arc<RouteTable>,
}
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
executor: self.executor.clone(),
resolver: self.resolver.clone(),
log_sink: self.log_sink.clone(),
app_domains: self.app_domains.clone(),
routes: self.routes.clone(),
}
}
@@ -109,6 +114,7 @@ where
// audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails.
let log = build_execution_log(
script.app_id,
id,
request_id,
request_path,
@@ -145,7 +151,23 @@ where
.to_string();
let headers = request.headers().clone();
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
// then run the existing matcher on that app's slice. No app claims
// this host → flat 404; the path doesn't get the chance to fire.
let Some(app_id) = state.app_domains.resolve_app(&host) else {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no app claims host {host:?}")
})),
)
.into_response());
};
let Some(matched) = state
.routes
.match_request_for_app(app_id, &host, &method, &path)
else {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
@@ -191,6 +213,7 @@ where
let finished = Utc::now();
let log = build_execution_log(
script.app_id,
matched.matched.script_id,
request_id,
request_path,
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
#[allow(clippy::too_many_arguments)]
fn build_execution_log(
app_id: AppId,
script_id: ScriptId,
request_id: RequestId,
request_path: String,
@@ -336,6 +360,7 @@ fn build_execution_log(
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,

View File

@@ -0,0 +1,165 @@
//! Host → app_id resolver. The first phase of the orchestrator's
//! two-phase dispatch (the second phase is the per-app route matcher
//! in `routing::table::RouteTable`).
//!
//! Cached in memory; the manager rebuilds the table after each
//! domain-claim CRUD operation (same pattern as `RouteTable`).
use std::sync::RwLock;
use picloud_shared::AppId;
use super::pattern::{HostPattern, HostSpecificity};
/// A parsed domain claim ready for runtime matching.
#[derive(Debug, Clone)]
pub struct CompiledAppDomain {
pub app_id: AppId,
pub pattern: HostPattern,
pub shape_key: String,
}
#[derive(Default)]
pub struct AppDomainTable {
inner: RwLock<Vec<CompiledAppDomain>>,
}
impl AppDomainTable {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Atomic full replacement; called at startup and after every
/// domain CRUD operation.
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
let mut guard = self.inner.write().expect("app domain table poisoned");
*guard = domains;
}
/// Resolve a request's `Host` header to an `AppId`. Most-specific
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
/// `None` when no claim covers `host` (orchestrator should 404).
#[must_use]
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
let host = strip_port(host).to_ascii_lowercase();
let guard = self.inner.read().expect("app domain table poisoned");
let mut best: Option<(HostSpecificity, AppId)> = None;
for claim in guard.iter() {
if let Some(()) = host_matches(&claim.pattern, &host) {
let s = claim.pattern.specificity();
if best.is_none_or(|(prev, _)| s > prev) {
best = Some((s, claim.app_id));
}
}
}
best.map(|(_, app_id)| app_id)
}
#[must_use]
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
self.inner
.read()
.expect("app domain table poisoned")
.clone()
}
}
fn strip_port(host: &str) -> &str {
host.split(':').next().unwrap_or(host)
}
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
match pattern {
HostPattern::Any => Some(()),
HostPattern::Strict(s) => {
if s.eq_ignore_ascii_case(host) {
Some(())
} else {
None
}
}
HostPattern::Wildcard { suffix, .. } => {
let dotted = format!(".{}", suffix.to_ascii_lowercase());
host.strip_suffix(&dotted)
.filter(|p| !p.is_empty())
.map(|_| ())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::routing::pattern::parse_app_domain;
use uuid::Uuid;
fn id() -> AppId {
AppId::from(Uuid::new_v4())
}
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
let d = parse_app_domain(raw).unwrap();
CompiledAppDomain {
app_id,
pattern: d.pattern,
shape_key: d.shape_key,
}
}
#[test]
fn resolves_exact_over_wildcard() {
let app_a = id();
let app_b = id();
let table = AppDomainTable::new();
table.replace(vec![
compile(app_a, "foo.example.com"),
compile(app_b, "*.example.com"),
]);
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
}
#[test]
fn longer_wildcard_beats_shorter() {
let inner = id();
let outer = id();
let table = AppDomainTable::new();
table.replace(vec![
compile(inner, "*.api.example.com"),
compile(outer, "*.example.com"),
]);
assert_eq!(
table.resolve_app("v1.api.example.com"),
Some(inner),
"more-specific wildcard should win"
);
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
}
#[test]
fn parameterized_resolves_like_wildcard() {
let app = id();
let table = AppDomainTable::new();
table.replace(vec![compile(app, "{tenant}.example.com")]);
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
assert!(table.resolve_app("example.com").is_none());
}
#[test]
fn returns_none_when_no_claim() {
let app = id();
let table = AppDomainTable::new();
table.replace(vec![compile(app, "foo.example.com")]);
assert!(table.resolve_app("nope.com").is_none());
assert!(table.resolve_app("").is_none());
}
#[test]
fn strips_port() {
let app = id();
let table = AppDomainTable::new();
table.replace(vec![compile(app, "localhost")]);
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
}
}

View File

@@ -40,10 +40,13 @@ pub struct Matched {
pub script_id: picloud_shared::ScriptId,
}
/// A single route ready for matching.
/// A single route ready for matching. `app_id` is carried so the
/// caller (the orchestrator's `AppRouteTables`) can partition the
/// table; the matcher itself doesn't read it.
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub route_id: uuid::Uuid,
pub app_id: picloud_shared::AppId,
pub script_id: picloud_shared::ScriptId,
pub host: HostPattern,
pub path: PathPattern,
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
mod tests {
use super::super::pattern::parse_path;
use super::*;
use picloud_shared::{PathKind, ScriptId};
use picloud_shared::{AppId, PathKind, ScriptId};
use uuid::Uuid;
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
CompiledRoute {
route_id: Uuid::new_v4(),
app_id: AppId::new(),
script_id: ScriptId::new(),
host,
path: parse_path(path_kind, raw).unwrap(),

View File

@@ -17,12 +17,16 @@
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
//! wildcard suffix breaks ties between wildcards.
pub mod app_domains;
pub mod conflict;
pub mod matcher;
pub mod pattern;
pub mod table;
pub use app_domains::{AppDomainTable, CompiledAppDomain};
pub use conflict::{conflicts, ConflictReason};
pub use matcher::{MatchResult, Matched};
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
pub use pattern::{
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
};
pub use table::RouteTable;

View File

@@ -251,6 +251,106 @@ pub fn parse_host(
}
}
// ----------------------------------------------------------------------------
// App-domain patterns
// ----------------------------------------------------------------------------
use picloud_shared::DomainShape;
/// Result of parsing a user-supplied app domain claim. Carries the
/// host pattern (used at request time), the shape (used at write time
/// for collision checks), and the normalized shape_key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedAppDomain {
pub pattern: HostPattern,
pub shape: DomainShape,
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
/// for both wildcard AND parameterized — they share a shape per
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
/// check").
pub shape_key: String,
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
/// for `{tenant}.example.com`. Currently informational; the binding
/// is surfaced into request context in a future iteration.
pub binding: Option<String>,
}
/// Parse a user-supplied app domain claim. Accepts:
/// * `app.example.com` — exact host
/// * `*.example.com` — wildcard suffix
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
///
/// Distinct from `parse_host` (which is for route host fields): the
/// route parser still rejects `{...}` syntax — see
/// `ParseError::ReservedHostBraceSyntax`.
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ParseError::EmptyHost);
}
let lowered = trimmed.to_ascii_lowercase();
// Wildcard: starts with "*."
if let Some(suffix) = lowered.strip_prefix("*.") {
if suffix.is_empty() {
return Err(ParseError::EmptyWildcardSuffix);
}
return Ok(ParsedAppDomain {
pattern: HostPattern::Wildcard {
suffix: suffix.to_string(),
capture: None,
},
shape: DomainShape::Wildcard,
shape_key: format!("wildcard:{suffix}"),
binding: None,
});
}
// Parameterized: starts with "{name}." where `name` is an ident.
if let Some(stripped) = lowered.strip_prefix('{') {
let (binding, rest) = stripped
.split_once('}')
.ok_or(ParseError::ReservedHostBraceSyntax)?;
if binding.is_empty()
|| !binding
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
{
return Err(ParseError::InvalidParamName(binding.to_string()));
}
let suffix = rest
.strip_prefix('.')
.ok_or(ParseError::ReservedHostBraceSyntax)?;
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
return Err(ParseError::ReservedHostBraceSyntax);
}
return Ok(ParsedAppDomain {
pattern: HostPattern::Wildcard {
suffix: suffix.to_string(),
capture: Some(binding.to_string()),
},
shape: DomainShape::Parameterized,
// Same shape_key as the equivalent wildcard — parameter
// name is a binding, not a discriminator.
shape_key: format!("wildcard:{suffix}"),
binding: Some(binding.to_string()),
});
}
// Anything else: exact host. Reject braces anywhere in the body
// (they'd be a malformed parameterized form).
if lowered.contains('{') || lowered.contains('}') {
return Err(ParseError::ReservedHostBraceSyntax);
}
Ok(ParsedAppDomain {
pattern: HostPattern::Strict(lowered.clone()),
shape: DomainShape::Exact,
shape_key: format!("exact:{lowered}"),
binding: None,
})
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
@@ -393,6 +493,49 @@ mod tests {
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
}
#[test]
fn parse_app_domain_exact() {
let d = parse_app_domain("App.Example.COM").unwrap();
assert_eq!(d.shape, DomainShape::Exact);
assert_eq!(d.shape_key, "exact:app.example.com");
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
assert!(d.binding.is_none());
}
#[test]
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
let w = parse_app_domain("*.example.com").unwrap();
let p = parse_app_domain("{tenant}.example.com").unwrap();
assert_eq!(w.shape, DomainShape::Wildcard);
assert_eq!(p.shape, DomainShape::Parameterized);
// Same shape_key — they collide at claim time (blueprint §11.5).
assert_eq!(w.shape_key, "wildcard:example.com");
assert_eq!(p.shape_key, "wildcard:example.com");
assert_eq!(p.binding.as_deref(), Some("tenant"));
}
#[test]
fn parse_app_domain_rejects_garbage() {
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
assert!(matches!(
parse_app_domain("*."),
Err(ParseError::EmptyWildcardSuffix)
));
assert!(matches!(
parse_app_domain("{}.example.com"),
Err(ParseError::InvalidParamName(_))
));
assert!(matches!(
parse_app_domain("{1tenant}.example.com"),
Err(ParseError::InvalidParamName(_))
));
// Mid-host braces — disallowed.
assert!(matches!(
parse_app_domain("foo.{tenant}.example.com"),
Err(ParseError::ReservedHostBraceSyntax)
));
}
#[test]
fn leading_literal_count_works() {
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();

View File

@@ -1,17 +1,22 @@
//! In-memory snapshot of compiled routes, shared by manager (writes)
//! and orchestrator (reads).
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
//!
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
//! read without contending against the writer; in MVP-single-process
//! we just use `RwLock` and accept the cheap contention.
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
//! has resolved Host → app_id, then runs the existing matcher on that
//! slice. The matcher is unchanged; this type is just a per-app bucket.
use std::collections::HashMap;
use std::sync::RwLock;
use picloud_shared::AppId;
use super::matcher::{r#match, CompiledRoute, MatchResult};
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
/// via `replace_all`); contention against readers is minimal so a plain
/// `RwLock` is fine.
#[derive(Default)]
pub struct RouteTable {
inner: RwLock<Vec<CompiledRoute>>,
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
}
impl RouteTable {
@@ -20,24 +25,54 @@ impl RouteTable {
Self::default()
}
/// Replace the whole table atomically. The manager calls this after
/// each successful route CRUD operation (by re-reading from DB).
pub fn replace(&self, routes: Vec<CompiledRoute>) {
/// Replace every per-app slice atomically. The manager calls this
/// after each successful route CRUD operation; in cluster mode the
/// orchestrator's HTTP-fed receiver will too.
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
for r in routes {
by_app.entry(r.app_id).or_default().push(r);
}
let mut guard = self.inner.write().expect("route table poisoned");
*guard = routes;
*guard = by_app;
}
/// Dispatch a request to a matching route, or `None`.
/// Dispatch a request to a matching route within `app_id`, or
/// `None`. Returns `None` when the app has no routes at all.
#[must_use]
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
pub fn match_request_for_app(
&self,
app_id: AppId,
host: &str,
method: &str,
path: &str,
) -> Option<MatchResult> {
let guard = self.inner.read().expect("route table poisoned");
r#match(guard.iter(), host, method, path)
let slice = guard.get(&app_id)?;
r#match(slice.iter(), host, method, path)
}
/// Returns a clone of the currently compiled routes; intended for
/// the dashboard's "list routes" admin endpoint.
/// Returns a clone of the currently compiled routes for `app_id`;
/// intended for admin endpoints like "list this app's routes".
#[must_use]
pub fn snapshot(&self) -> Vec<CompiledRoute> {
self.inner.read().expect("route table poisoned").clone()
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
self.inner
.read()
.expect("route table poisoned")
.get(&app_id)
.cloned()
.unwrap_or_default()
}
/// All compiled routes across all apps. Used by tests and the
/// global admin "every route on this install" view.
#[must_use]
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
self.inner
.read()
.expect("route table poisoned")
.values()
.flat_map(|v| v.iter().cloned())
.collect()
}
}

View File

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

View File

@@ -10,13 +10,16 @@ use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
};
use picloud_orchestrator_core::routing::RouteTable;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
};
@@ -35,6 +38,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration,
}
@@ -44,7 +48,8 @@ impl AuthDeps {
pub fn from_pool(pool: PgPool) -> Self {
Self {
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(),
}
}
@@ -80,14 +85,37 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// Authz: app_members repo doubles as the AuthzRepo impl for the
// per-handler capability checks introduced in Phase 3.5.
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
// Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?;
let compiled = compile_routes(&initial)
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
route_table.replace(compiled);
route_table.replace_all(compiled);
// Same shape for app domains (Host → app_id cache).
let app_domain_table = Arc::new(AppDomainTable::new());
let initial_domains = domains_repo.list_all().await?;
let compiled_domains: Vec<_> = initial_domains
.iter()
.filter_map(|d| {
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
.ok()
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
app_id: d.app_id,
pattern: p.pattern,
shape_key: p.shape_key,
})
})
.collect();
app_domain_table.replace(compiled_domains);
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(),
@@ -95,41 +123,69 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo,
apps: apps_repo.clone(),
authz: authz.clone(),
validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(),
};
let route_admin = RouteAdminState {
routes: route_repo,
routes: route_repo.clone(),
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
domains: domains_repo.clone(),
table: route_table.clone(),
authz: authz.clone(),
};
let data_plane = DataPlaneState {
executor,
resolver,
log_sink,
app_domains: app_domain_table.clone(),
routes: route_table,
};
let apps_state = AppsState {
apps: apps_repo,
domains: domains_repo,
routes: route_repo,
domain_table: app_domain_table,
authz: authz.clone(),
};
let auth_state = AuthState {
users: auth.users.clone(),
sessions: auth.sessions.clone(),
keys: auth.keys.clone(),
ttl: auth.ttl,
};
let admins_state = AdminsState {
users: auth.users,
sessions: auth.sessions,
keys: auth.keys.clone(),
authz,
};
let api_keys_state = ApiKeysState { keys: auth.keys };
// /admin/auth/login + /logout are unguarded by design (login is how
// you get in). /admin/auth/me applies the middleware internally so
// 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()
.merge(admin_router(admin))
.merge(route_admin_router(route_admin))
.merge(admins_router(admins_state))
.layer(from_fn_with_state(auth_state.clone(), require_admin));
.merge(apps_router(apps_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
// facade above; the bare module path is retained so it's discoverable.
let _ = apps_api::AppsState::clone;
let api_v1 = Router::new()
.nest("/admin", auth_router(auth_state))
@@ -201,6 +257,18 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list().await
}
async fn list_for_app(
&self,
app_id: picloud_shared::AppId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
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(
&self,
input: picloud_manager_core::NewScript,

View File

@@ -11,7 +11,9 @@ use std::time::Duration;
use picloud::{build_app, init_db, AuthDeps};
use picloud_manager_core::{
auth::{hash_password, validate_password_hash},
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
PostgresScriptRepository,
};
use tracing_subscriber::EnvFilter;
@@ -43,6 +45,24 @@ async fn run_server() -> anyhow::Result<()> {
let auth = AuthDeps::from_pool(pool.clone());
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
// install (no scripts and no routes). Idempotent on upgrades.
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
match seed_hello_world_if_fresh(apps, scripts, routes).await {
Ok(HelloWorldOutcome::Seeded) => {
tracing::info!("hello-world seed inserted into the default app");
}
Ok(HelloWorldOutcome::SkippedExisting) => {
tracing::debug!("hello-world seed skipped (default app already populated)");
}
Err(err) => {
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
}
}
// Background session-prune sweep. Cheap; keeps the table from
// growing unbounded. Expired rows are also rejected at lookup time,
@@ -60,6 +80,34 @@ async fn run_server() -> anyhow::Result<()> {
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>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(600));

View File

@@ -23,12 +23,20 @@ use sqlx::PgPool;
/// the bearer token into the TestServer as a default header so every
/// request in the test passes the `require_admin` middleware.
async fn server(pool: PgPool) -> TestServer {
let (server, _app_id) = server_with_app(pool).await;
server
}
/// Like `server`, but also returns the default app's id — needed by
/// any test that creates scripts (every script now requires `app_id`).
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
use picloud_manager_core::auth::hash_password;
use picloud_shared::InstanceRole;
let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("test-pw").expect("hash");
auth.users
.create("test-admin", &hash)
.create("test-admin", &hash, InstanceRole::Owner)
.await
.expect("seed admin");
@@ -45,7 +53,32 @@ async fn server(pool: PgPool) -> TestServer {
.expect("login should return token")
.to_string();
server.add_header("authorization", format!("Bearer {token}"));
server
// Note: user-route dispatch needs an explicit `host: <claim>` header
// on each request (the axum_test client doesn't default to a real
// host). The default app claims `localhost`; user-route tests below
// add the header per request via `.add_header("host", "localhost")`
// so per-test overrides for other apps cleanly replace it.
// The 0005 migration unconditionally inserts a `default` app; fetch
// its id so tests can attach scripts to it without re-running the
// Rust-side hello-world seed (which only fires from main.rs).
// The get-app handler returns `{ ...App, redirect_to?: ... }` —
// the app fields are flattened at the response root.
let app: Value = server.get("/api/v1/admin/apps/default").await.json();
let app_id = app["id"]
.as_str()
.unwrap_or_else(|| panic!("default app id missing from response: {app}"))
.to_string();
(server, app_id)
}
/// Merge `{ "app_id": <default> }` into a create-script body. Saves
/// repeating the same field in 25+ tests.
fn with_app(app_id: &str, mut body: Value) -> Value {
body.as_object_mut()
.expect("script body must be a JSON object")
.insert("app_id".into(), Value::String(app_id.to_string()));
body
}
// ============================================================================
@@ -67,30 +100,37 @@ async fn healthz_responds_ok(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_script_returns_201_with_full_record(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "echo",
"description": "test",
"source": "#{ statusCode: 200, body: 42 }",
}))
}),
))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json();
assert_eq!(body["name"], "echo");
assert_eq!(body["version"], 1);
assert_eq!(body["timeout_seconds"], 30);
assert_eq!(body["app_id"], app_id);
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_with_invalid_syntax_returns_422(pool: PgPool) {
let r = server(pool)
.await
let (s, app_id) = server_with_app(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
.json(&with_app(
&app_id,
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json();
@@ -100,14 +140,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn duplicate_name_returns_409(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
s.post("/api/v1/admin/scripts")
.json(&json!({ "name": "dup", "source": "42" }))
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "dup", "source": "43" }))
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
@@ -115,10 +155,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_returns_all_scripts(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
for name in ["alpha", "bravo", "charlie"] {
s.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": "1" }))
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
.await
.assert_status(axum::http::StatusCode::CREATED);
}
@@ -133,10 +173,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -155,10 +195,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_with_invalid_source_returns_422(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -173,10 +213,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_then_get_returns_404(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "d", "source": "1" }))
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -207,13 +247,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_echoes_body_back(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "echo",
"source": "#{ statusCode: 200, body: ctx.request.body }",
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -230,13 +273,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_passes_through_status_and_headers(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "header-test",
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -263,13 +309,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_logs_capture_invocations(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "logger",
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -320,10 +369,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "no-sandbox", "source": "1" }))
.json(&with_app(
&app_id,
json!({ "name": "no-sandbox", "source": "1" }),
))
.await
.json();
assert_eq!(created["sandbox"], json!({}));
@@ -332,14 +384,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "tight",
"source": "1",
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
}))
}),
))
.await
.json();
assert_eq!(
@@ -359,14 +414,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
// Default conservative ceiling caps max_operations at 10_000_000.
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "too-loose",
"source": "1",
"sandbox": { "max_operations": 100_000_000 }
}))
}),
))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json();
@@ -376,14 +434,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "typo",
"source": "1",
"sandbox": { "max_operashuns": 500 }
}))
}),
))
.await;
// serde's deny_unknown_fields causes axum to reject with 422 or
// 400 depending on extractor; the routing is irrelevant here, just
@@ -397,15 +458,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
// Tight max_operations on a loop the default would happily run.
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "tight-exec",
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
"sandbox": { "max_operations": 500 }
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -422,14 +486,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "patch-target",
"source": "1",
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();
@@ -455,10 +522,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
// Custom routing
// ============================================================================
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
let v: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": source }))
.json(&with_app(app_id, json!({ "name": name, "source": source })))
.await
.json();
v["id"].as_str().unwrap().to_string()
@@ -467,9 +534,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_exact_dispatches_to_script(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(
&s,
&app_id,
"greet",
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
)
@@ -483,7 +551,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/greet").await;
let r = s.get("/greet").add_header("host", "localhost").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["msg"], "hi");
@@ -493,9 +561,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_param_captures_path_vars(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(
&s,
&app_id,
"greet-name",
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
)
@@ -509,7 +578,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/greet/alice").await;
let r = s.get("/greet/alice").add_header("host", "localhost").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["name"], "alice");
@@ -518,9 +587,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_prefix_captures_rest(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(
&s,
&app_id,
"echo-prefix",
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
)
@@ -534,19 +604,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/echo/foo/bar").await;
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["rest"], "foo/bar");
s.get("/echo").await.assert_status_not_found();
s.get("/echo")
.add_header("host", "localhost")
.await
.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_query_string_exposed_to_script(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(
&s,
&app_id,
"qs",
"#{ statusCode: 200, body: ctx.request.query }",
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
@@ -556,7 +635,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/qs?a=1&b=two").await;
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body, json!({ "a": "1", "b": "two" }));
@@ -565,8 +644,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_invalid_pattern_returns_422(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "x", "1").await;
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
@@ -581,8 +660,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_conflict_returns_409(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "x", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
@@ -608,8 +687,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_reserved_path_returns_422(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "x", "1").await;
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
@@ -624,8 +703,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_match_preview_endpoint(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "g", "1").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "g", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
@@ -637,7 +716,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
let r = s
.post("/api/v1/admin/routes:match")
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
.json(&json!({
"app_id": app_id,
"url": "http://localhost:8000/greet/alice",
"method": "GET"
}))
.await;
r.assert_status_ok();
let body: Value = r.json();
@@ -648,8 +731,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_delete_removes_dispatch(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
let (s, app_id) = server_with_app(pool).await;
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
let created: Value = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
@@ -661,27 +744,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
.json();
let route_id = created["id"].as_str().unwrap();
s.get("/g").await.assert_status_ok();
s.get("/g")
.add_header("host", "localhost")
.await
.assert_status_ok();
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
.await
.assert_status(axum::http::StatusCode::NO_CONTENT);
s.get("/g").await.assert_status_not_found();
s.get("/g")
.add_header("host", "localhost")
.await
.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_specificity_param_beats_prefix(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let id_p = create_basic_script(
&s,
&app_id,
"by-param",
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
)
.await;
let id_pr = create_basic_script(
&s,
&app_id,
"by-prefix",
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
)
@@ -704,12 +795,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
.assert_status(axum::http::StatusCode::CREATED);
// Single segment under /foo/ — both match; param wins by spec.
let r = s.get("/foo/x").await;
let r = s.get("/foo/x").add_header("host", "localhost").await;
let body: Value = r.json();
assert_eq!(body["tag"], "param");
// Two segments — only prefix matches.
let r2 = s.get("/foo/x/y").await;
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
let body2: Value = r2.json();
assert_eq!(body2["tag"], "prefix");
}
@@ -718,7 +809,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn root_returns_404_when_no_route(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/").await;
let r = s.get("/").add_header("host", "localhost").await;
r.assert_status_not_found();
}
@@ -731,22 +822,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
let v: Value = r.json();
assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1);
assert_eq!(v["schema"], 4);
assert_eq!(v["schema"], 6);
assert_eq!(v["sdk"], "1.1");
}
// ============================================================================
// ============================================================================
// App scoping (Phase 3b)
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn default_app_is_seeded_by_migration(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/api/v1/admin/apps").await;
r.assert_status_ok();
let apps: Vec<Value> = r.json();
let default = apps
.iter()
.find(|a| a["slug"] == "default")
.expect("default app must exist");
assert_eq!(default["name"], "Default");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
let (s, default_id) = server_with_app(pool).await;
// Two apps each create a script with the same name (per-app
// uniqueness — would have collided pre-3b).
let app_b: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
.await
.json();
let b_id = app_b["id"].as_str().unwrap();
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
.json(&json!({ "pattern": "b.localhost" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let id_default: String = s
.post("/api/v1/admin/scripts")
.json(&with_app(
&default_id,
json!({
"name": "echo",
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
}),
))
.await
.json::<Value>()["id"]
.as_str()
.unwrap()
.to_string();
let id_b: String = s
.post("/api/v1/admin/scripts")
.json(&with_app(
b_id,
json!({
"name": "echo",
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
}),
))
.await
.json::<Value>()["id"]
.as_str()
.unwrap()
.to_string();
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Same path, different host — routes land in different apps.
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
assert_eq!(from_default["from"], "default");
let from_b: Value = s
.get("/echo")
.add_header("host", "b.localhost")
.await
.json();
assert_eq!(from_b["from"], "b");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn unknown_host_returns_404(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
r.assert_status_not_found();
let body: Value = r.json();
assert!(body["error"]
.as_str()
.unwrap()
.contains("no app claims host"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
// The /api/v1/execute/{id} bypass is the implicit __internal__
// claim of every app — it MUST keep working for an app with zero
// public domain claims.
let (s, _) = server_with_app(pool).await;
let app: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
.await
.json();
let app_id = app["id"].as_str().unwrap();
let script: Value = s
.post("/api/v1/admin/scripts")
.json(&with_app(
app_id,
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
))
.await
.json();
let id = script["id"].as_str().unwrap();
let r = s
.post(&format!("/api/v1/execute/{id}"))
.json(&json!({}))
.await;
r.assert_status_ok();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn duplicate_slug_creates_a_409(pool: PgPool) {
let s = server(pool).await;
s.post("/api/v1/admin/apps")
.json(&json!({ "slug": "alpha", "name": "First" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "alpha", "name": "Second" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn reserved_slug_rejected(pool: PgPool) {
let s = server(pool).await;
for bad in ["new", "api", "admin", "login"] {
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": bad, "name": "x" }))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
let s = server(pool).await;
let app: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "old-slug", "name": "x" }))
.await
.json();
let id = app["id"].as_str().unwrap();
s.patch(&format!("/api/v1/admin/apps/{id}"))
.json(&json!({ "slug": "new-slug" }))
.await
.assert_status_ok();
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
// The old slug resolves via history and surfaces `redirect_to`.
assert_eq!(resp["redirect_to"], "new-slug");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
let s = server(pool).await;
// Set up a history row.
let first: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "soon-retired", "name": "x" }))
.await
.json();
s.patch(&format!(
"/api/v1/admin/apps/{}",
first["id"].as_str().unwrap()
))
.json(&json!({ "slug": "kept" }))
.await
.assert_status_ok();
// Plain create against the retired slug → 409.
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "soon-retired", "name": "y" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
// With force_takeover → 201.
let r = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn shape_key_collision_rejected(pool: PgPool) {
let s = server(pool).await;
let a: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "a", "name": "A" }))
.await
.json();
let b: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "b", "name": "B" }))
.await
.json();
s.post(&format!(
"/api/v1/admin/apps/{}/domains",
a["id"].as_str().unwrap()
))
.json(&json!({ "pattern": "*.example.com" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Parameterized form should collide with wildcard form.
let r = s
.post(&format!(
"/api/v1/admin/apps/{}/domains",
b["id"].as_str().unwrap()
))
.json(&json!({ "pattern": "{tenant}.example.com" }))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
let s = server(pool).await;
let app: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "with-scripts", "name": "x" }))
.await
.json();
let id = app["id"].as_str().unwrap();
s.post("/api/v1/admin/scripts")
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
r.assert_status(axum::http::StatusCode::CONFLICT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_scripts_filtered_by_app(pool: PgPool) {
let (s, default_id) = server_with_app(pool).await;
let other: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "filter-target", "name": "x" }))
.await
.json();
let other_id = other["id"].as_str().unwrap();
s.post("/api/v1/admin/scripts")
.json(&with_app(
&default_id,
json!({ "name": "in-default", "source": "1" }),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
s.post("/api/v1/admin/scripts")
.json(&with_app(
other_id,
json!({ "name": "in-other", "source": "1" }),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Filter by id.
let filtered: Vec<Value> = s
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
.await
.json();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0]["name"], "in-other");
// Filter by slug.
let filtered_by_slug: Vec<Value> = s
.get("/api/v1/admin/scripts?app=filter-target")
.await
.json();
assert_eq!(filtered_by_slug.len(), 1);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_errors_are_still_logged(pool: PgPool) {
let s = server(pool).await;
let (s, app_id) = server_with_app(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
.json(&with_app(
&app_id,
json!({
"name": "boom",
"source": "1 / 0",
}))
}),
))
.await
.json();
let id = created["id"].as_str().unwrap();

View File

@@ -0,0 +1,647 @@
//! Phase 3.5 authorization end-to-end tests.
//!
//! Covers the 11 scenarios from `lay-foundations-for-snazzy-truffle.md`
//! step 9:
//!
//! 1. Bootstrap admin promotes to owner.
//! 2. Owner access matrix on a sample app.
//! 3. Admin access matrix.
//! 4. Member access matrix.
//! 5. Bearer (pic_) + cookie produce the same Principal.
//! 6. Scope intersection: a script:read-only key cannot write.
//! 7. Bound key cannot escape its app.
//! 8. Member listing isolation (apps + scripts).
//! 9. Deactivation revokes API keys.
//! 10. Mint rejects bound key with `instance:*` scope.
//! 11. `list_active_owners` returns the expected set under the seed
//! that the startup warning is built from (we don't capture the
//! log line itself — the data source is the testable surface).
//!
//! Same harness as `tests/api.rs`: `#[sqlx::test]` against a real
//! Postgres, `TestServer` over the in-process app. We do NOT bake a
//! token into the default headers here — each test wires its own
//! credential per request to exercise the cookie / Bearer split.
#![allow(clippy::needless_pass_by_value)]
use std::sync::Arc;
use axum_test::TestServer;
use picloud_manager_core::{
auth::hash_password, AdminUserRepository, ApiKeyRepository, AppMembersRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppMembersRepository,
};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use serde_json::{json, Value};
use sqlx::PgPool;
// ----------------------------------------------------------------------------
// Harness
// ----------------------------------------------------------------------------
struct Seeded {
server: TestServer,
pool: PgPool,
/// Bootstrap admin — Owner, password "owner-pw".
owner: AdminUserId,
/// Default app id, slug "default" (seeded by 0005 migration).
default_app: AppId,
}
async fn boot(pool: PgPool) -> Seeded {
let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("owner-pw").expect("hash");
let owner = auth
.users
.create("owner", &hash, InstanceRole::Owner)
.await
.expect("seed owner");
let app = picloud::build_app(pool.clone(), auth)
.await
.expect("build_app");
let server = TestServer::new(app).expect("TestServer");
// Default app id (seeded by migration 0005).
let resp = server
.post("/api/v1/admin/auth/login")
.json(&json!({ "username": "owner", "password": "owner-pw" }))
.await;
resp.assert_status_ok();
let token = resp.json::<Value>()["token"]
.as_str()
.expect("login token")
.to_string();
let app_resp = server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await;
app_resp.assert_status_ok();
let app_id: uuid::Uuid = app_resp.json::<Value>()["id"]
.as_str()
.expect("app id")
.parse()
.expect("uuid");
Seeded {
server,
pool,
owner: owner.id,
default_app: app_id.into(),
}
}
/// Mint a session for an existing admin via the login endpoint and
/// return the raw token. Lets tests build a per-role credential
/// without baking it into the default headers.
async fn login_token(server: &TestServer, username: &str, password: &str) -> String {
let r = server
.post("/api/v1/admin/auth/login")
.json(&json!({ "username": username, "password": password }))
.await;
r.assert_status_ok();
r.json::<Value>()["token"]
.as_str()
.expect("token in login response")
.to_string()
}
/// Direct-DB seed (bypassing the API) for users we want to construct
/// at arbitrary roles. The API enforces "owners only create owners"
/// which is correct production behavior but inconvenient for test
/// fixtures.
async fn seed_user(
pool: &PgPool,
username: &str,
password: &str,
role: InstanceRole,
) -> AdminUserId {
let repo = PostgresAdminUserRepository::new(pool.clone());
let hash = hash_password(password).expect("hash");
repo.create(username, &hash, role)
.await
.expect("seed user")
.id
}
async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) {
let repo = PostgresAppMembersRepository::new(pool.clone());
repo.upsert(app, user, role)
.await
.expect("grant membership");
}
async fn create_script_via_api(
server: &TestServer,
token: &str,
app_id: AppId,
name: &str,
) -> Value {
let r = server
.post("/api/v1/admin/scripts")
.add_header("authorization", format!("Bearer {token}"))
.json(&json!({
"app_id": app_id.to_string(),
"name": name,
"source": "fn main() { #{ statusCode: 200 } }",
}))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
r.json()
}
/// Mint an API key for the caller — wraps POST /api-keys.
async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_test::TestResponse {
server
.post("/api/v1/admin/api-keys")
.add_header("authorization", format!("Bearer {cred_token}"))
.json(&body)
.await
}
// ----------------------------------------------------------------------------
// 1. Bootstrap admin → owner
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bootstrap_admin_is_owner(pool: PgPool) {
let s = boot(pool).await;
let token = login_token(&s.server, "owner", "owner-pw").await;
let me = s
.server
.get("/api/v1/admin/auth/me")
.add_header("authorization", format!("Bearer {token}"))
.await;
me.assert_status_ok();
let listing = s
.server
.get("/api/v1/admin/admins")
.add_header("authorization", format!("Bearer {token}"))
.await;
listing.assert_status_ok();
let arr: Value = listing.json();
let row = arr
.as_array()
.and_then(|v| v.iter().find(|u| u["username"] == "owner"))
.expect("owner row");
assert_eq!(row["instance_role"], "owner");
}
// ----------------------------------------------------------------------------
// 2 / 3 / 4. Role access matrices on a sample app
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn owner_access_matrix(pool: PgPool) {
let s = boot(pool.clone()).await;
let token = login_token(&s.server, "owner", "owner-pw").await;
// Read apps / scripts.
s.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Create a script — AppWriteScript.
let script = create_script_via_api(&s.server, &token, s.default_app, "owner-test").await;
let sid = script["id"].as_str().unwrap();
// Read it back — AppRead.
s.server
.get(&format!("/api/v1/admin/scripts/{sid}"))
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Manage users — InstanceManageUsers.
s.server
.get("/api/v1/admin/admins")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
let s = boot(pool.clone()).await;
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let token = login_token(&s.server, "alice", "alice-pw").await;
// Allowed: list admins (InstanceManageUsers).
s.server
.get("/api/v1/admin/admins")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Allowed: read default app (admin is implicit editor everywhere).
s.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Allowed: write scripts (implicit editor).
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
assert!(script["id"].is_string());
// Denied: delete the default app (AppAdmin only).
let denied = s
.server
.delete("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn member_can_only_touch_apps_they_belong_to(pool: PgPool) {
let s = boot(pool.clone()).await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
let token = login_token(&s.server, "bob", "bob-pw").await;
// Allowed: read + write inside the default app.
s.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
let script = create_script_via_api(&s.server, &token, s.default_app, "member-write").await;
let sid = script["id"].as_str().unwrap();
s.server
.get(&format!("/api/v1/admin/scripts/{sid}"))
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Denied: create a *new* app (member cannot InstanceCreateApp).
let denied = s
.server
.post("/api/v1/admin/apps")
.add_header("authorization", format!("Bearer {token}"))
.json(&json!({ "slug": "other", "name": "Other" }))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
// Denied: manage admins.
let denied = s
.server
.get("/api/v1/admin/admins")
.add_header("authorization", format!("Bearer {token}"))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}
// ----------------------------------------------------------------------------
// 5. Bearer pic_ + cookie produce the same Principal
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bearer_and_cookie_produce_same_principal(pool: PgPool) {
let s = boot(pool).await;
let session_token = login_token(&s.server, "owner", "owner-pw").await;
// Mint a no-binding owner key covering script:read.
let mint = mint_key(
&s.server,
&session_token,
json!({
"name": "owner-readonly",
"scopes": ["script:read"],
}),
)
.await;
mint.assert_status(axum::http::StatusCode::CREATED);
let raw_token = mint.json::<Value>()["raw_token"]
.as_str()
.expect("raw token")
.to_string();
assert!(raw_token.starts_with("pic_"));
// /me through the cookie/session path.
let via_session = s
.server
.get("/api/v1/admin/auth/me")
.add_header("authorization", format!("Bearer {session_token}"))
.await;
via_session.assert_status_ok();
// /me through the pic_ path — same user_id.
let via_key = s
.server
.get("/api/v1/admin/auth/me")
.add_header("authorization", format!("Bearer {raw_token}"))
.await;
via_key.assert_status_ok();
assert_eq!(
via_session.json::<Value>()["id"],
via_key.json::<Value>()["id"]
);
assert_eq!(
via_session.json::<Value>()["username"],
via_key.json::<Value>()["username"]
);
}
// ----------------------------------------------------------------------------
// 6. Scope intersection — read-only key cannot write
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn read_only_key_cannot_write_scripts(pool: PgPool) {
let s = boot(pool).await;
let session_token = login_token(&s.server, "owner", "owner-pw").await;
let mint = mint_key(
&s.server,
&session_token,
json!({ "name": "ro", "scopes": ["script:read"] }),
)
.await;
mint.assert_status(axum::http::StatusCode::CREATED);
let raw = mint.json::<Value>()["raw_token"]
.as_str()
.unwrap()
.to_string();
let denied = s
.server
.post("/api/v1/admin/scripts")
.add_header("authorization", format!("Bearer {raw}"))
.json(&json!({
"app_id": s.default_app.to_string(),
"name": "would-write",
"source": "fn main() { #{ statusCode: 200 } }",
}))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}
// ----------------------------------------------------------------------------
// 7. Bound key cannot escape its app
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bound_key_cannot_escape_its_app(pool: PgPool) {
let s = boot(pool.clone()).await;
let session_token = login_token(&s.server, "owner", "owner-pw").await;
// Create a second app via the API (owner can InstanceCreateApp).
let other = s
.server
.post("/api/v1/admin/apps")
.add_header("authorization", format!("Bearer {session_token}"))
.json(&json!({ "slug": "other", "name": "Other" }))
.await;
other.assert_status(axum::http::StatusCode::CREATED);
let other_id = other.json::<Value>()["id"].as_str().unwrap().to_string();
// Mint a key bound to the default app with script:write.
let mint = mint_key(
&s.server,
&session_token,
json!({
"name": "default-only",
"scopes": ["script:write"],
"app_id": s.default_app.to_string(),
}),
)
.await;
mint.assert_status(axum::http::StatusCode::CREATED);
let raw = mint.json::<Value>()["raw_token"]
.as_str()
.unwrap()
.to_string();
// Writing into the bound app: allowed.
let ok = s
.server
.post("/api/v1/admin/scripts")
.add_header("authorization", format!("Bearer {raw}"))
.json(&json!({
"app_id": s.default_app.to_string(),
"name": "bound-ok",
"source": "fn main() { #{ statusCode: 200 } }",
}))
.await;
ok.assert_status(axum::http::StatusCode::CREATED);
// Writing into the *other* app: forbidden.
let denied = s
.server
.post("/api/v1/admin/scripts")
.add_header("authorization", format!("Bearer {raw}"))
.json(&json!({
"app_id": other_id,
"name": "escape-attempt",
"source": "fn main() { #{ statusCode: 200 } }",
}))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
}
// ----------------------------------------------------------------------------
// 8. Member listing isolation
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn member_list_endpoints_filter_at_sql(pool: PgPool) {
let s = boot(pool.clone()).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
// Owner creates a second app + script-in-that-app.
let other = s
.server
.post("/api/v1/admin/apps")
.add_header("authorization", format!("Bearer {owner_token}"))
.json(&json!({ "slug": "secret", "name": "Secret" }))
.await;
other.assert_status(axum::http::StatusCode::CREATED);
let other_id: uuid::Uuid = other.json::<Value>()["id"]
.as_str()
.unwrap()
.parse()
.unwrap();
let other_app: AppId = other_id.into();
create_script_via_api(&s.server, &owner_token, other_app, "secret-script").await;
create_script_via_api(&s.server, &owner_token, s.default_app, "default-script").await;
// Carol is a member of the default app only.
let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
grant_membership(&s.pool, carol, s.default_app, AppRole::Viewer).await;
let carol_token = login_token(&s.server, "carol", "carol-pw").await;
let apps = s
.server
.get("/api/v1/admin/apps")
.add_header("authorization", format!("Bearer {carol_token}"))
.await;
apps.assert_status_ok();
let apps_body: Value = apps.json();
let app_slugs: Vec<String> = apps_body
.as_array()
.unwrap()
.iter()
.map(|a| a["slug"].as_str().unwrap().to_string())
.collect();
assert_eq!(
app_slugs,
vec!["default"],
"member must see only their apps"
);
let scripts = s
.server
.get("/api/v1/admin/scripts")
.add_header("authorization", format!("Bearer {carol_token}"))
.await;
scripts.assert_status_ok();
let scripts_body: Value = scripts.json();
let names: Vec<String> = scripts_body
.as_array()
.unwrap()
.iter()
.map(|s| s["name"].as_str().unwrap().to_string())
.collect();
assert!(
names.iter().any(|n| n == "default-script") && !names.iter().any(|n| n == "secret-script"),
"member listing leaked another app's script: {names:?}"
);
}
// ----------------------------------------------------------------------------
// 9. Deactivation revokes API keys
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn deactivating_user_revokes_their_api_keys(pool: PgPool) {
let s = boot(pool.clone()).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
// A second user — admin so they can mint a key for themselves.
let dave_id = seed_user(&s.pool, "dave", "dave-pw", InstanceRole::Admin).await;
let dave_token = login_token(&s.server, "dave", "dave-pw").await;
let mint = mint_key(
&s.server,
&dave_token,
json!({ "name": "dave-key", "scopes": ["script:read"] }),
)
.await;
mint.assert_status(axum::http::StatusCode::CREATED);
let raw = mint.json::<Value>()["raw_token"]
.as_str()
.unwrap()
.to_string();
// Key works.
let before = s
.server
.get("/api/v1/admin/auth/me")
.add_header("authorization", format!("Bearer {raw}"))
.await;
before.assert_status_ok();
// Owner deactivates Dave.
let patch = s
.server
.patch(&format!("/api/v1/admin/admins/{dave_id}"))
.add_header("authorization", format!("Bearer {owner_token}"))
.json(&json!({ "is_active": false }))
.await;
patch.assert_status_ok();
// Key now rejects with 401.
let after = s
.server
.get("/api/v1/admin/auth/me")
.add_header("authorization", format!("Bearer {raw}"))
.await;
assert_eq!(after.status_code(), axum::http::StatusCode::UNAUTHORIZED);
// Cross-check via the repo: the row's expires_at is set in the past.
let repo = PostgresApiKeyRepository::new(s.pool.clone());
let rows = repo.list_for_user(dave_id).await.expect("list keys");
assert!(
rows.iter().all(|r| r.expires_at.is_some()),
"every key must have an expiry after deactivation"
);
assert!(rows
.iter()
.all(|r| r.expires_at.unwrap() <= chrono::Utc::now()));
}
// ----------------------------------------------------------------------------
// 10. Mint rejects bound key + instance scope
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) {
let s = boot(pool).await;
let token = login_token(&s.server, "owner", "owner-pw").await;
let r = s
.server
.post("/api/v1/admin/api-keys")
.add_header("authorization", format!("Bearer {token}"))
.json(&json!({
"name": "irreconcilable",
"scopes": ["instance:admin"],
"app_id": s.default_app.to_string(),
}))
.await;
assert_eq!(
r.status_code(),
axum::http::StatusCode::UNPROCESSABLE_ENTITY
);
let body: Value = r.json();
assert!(
body["error"].as_str().unwrap().contains("bound"),
"error body should explain the conflict, got {body}"
);
}
// ----------------------------------------------------------------------------
// 11. Multi-owner detection — data-source for the startup warning
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
let s = boot(pool.clone()).await;
// Seed a second owner directly so we exercise the
// multi-owner condition.
seed_user(&s.pool, "owner2", "pw", InstanceRole::Owner).await;
seed_user(&s.pool, "admin1", "pw", InstanceRole::Admin).await;
let users = Arc::new(PostgresAdminUserRepository::new(s.pool.clone()));
let owners = users.list_active_owners().await.expect("list owners");
let names: Vec<&str> = owners.iter().map(|o| o.username.as_str()).collect();
assert!(names.contains(&"owner"));
assert!(names.contains(&"owner2"));
assert!(!names.contains(&"admin1"));
assert_eq!(
owners.len(),
2,
"list_active_owners must filter strictly by instance_role"
);
// count_other_active_owners powers the last-owner guard.
let remaining = users
.count_other_active_owners(s.owner)
.await
.expect("count");
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
}

53
crates/shared/src/app.rs Normal file
View File

@@ -0,0 +1,53 @@
//! App scoping: top-level isolation boundary for scripts, routes,
//! domains, and (forward) data. Every script and route belongs to
//! exactly one app; cross-app references are not allowed.
//!
//! See blueprint §11.5. The orchestrator dispatches via two-phase
//! lookup: `Host → app_id → route trie`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::AppId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct App {
pub id: AppId,
/// URL-safe identifier; appears in dashboard paths. Mutable via the
/// slug-rename flow which preserves the old slug as a permanent 301
/// in `app_slug_history`.
pub slug: String,
pub name: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DomainShape {
/// Exact host: `app.example.com`.
Exact,
/// Wildcard suffix: `*.example.com` matches any subdomain.
Wildcard,
/// Parameterized wildcard: `{tenant}.example.com`. Same shape as
/// `Wildcard` for collision purposes; the binding name surfaces in
/// request context (future).
Parameterized,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppDomain {
pub id: Uuid,
pub app_id: AppId,
/// As the user typed it: `app.example.com`, `*.example.com`, or
/// `{tenant}.example.com`.
pub pattern: String,
pub shape: DomainShape,
/// Normalized collision key. `exact:<host>` for exact; `wildcard:<suffix>`
/// for both wildcard and parameterized (parameter name is a binding,
/// not a discriminator — per blueprint §11.5).
pub shape_key: String,
pub created_at: DateTime<Utc>,
}

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

@@ -11,4 +11,10 @@ pub enum Error {
#[error("invalid script source: {0}")]
InvalidScript(String),
#[error("app not found: {0}")]
AppNotFound(crate::AppId),
#[error("domain claim conflict: {0}")]
DomainConflict(String),
}

View File

@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{RequestId, ScriptId};
use crate::{AppId, RequestId, ScriptId};
/// One row in the `execution_logs` table. Same shape flows through the
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionLog {
pub id: Uuid,
/// Owning app at the time of execution. Materialized at write time
/// so a future "move script to another app" doesn't retag history.
pub app_id: AppId,
pub script_id: ScriptId,
pub request_id: RequestId,

View File

@@ -51,3 +51,5 @@ id_type!(ScriptId);
id_type!(ExecutionId);
id_type!(RequestId);
id_type!(AdminUserId);
id_type!(AppId);
id_type!(ApiKeyId);

View File

@@ -4,6 +4,8 @@
//! that core's crate. Things here must be genuinely shared (IDs, the Script
//! entity, error roots, transport DTOs).
pub mod app;
pub mod auth;
pub mod error;
pub mod execution_log;
pub mod ids;
@@ -14,9 +16,11 @@ pub mod script;
pub mod validator;
pub mod version;
pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{AdminUserId, ExecutionId, RequestId, ScriptId};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;

View File

@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ScriptId;
use crate::{AppId, ScriptId};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@@ -40,6 +40,10 @@ pub enum PathKind {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route {
pub id: Uuid,
/// Owning app. Always equals `scripts.app_id` for the bound script.
/// Carried on the route row so the orchestrator can partition the
/// route table without joining back to scripts on every refresh.
pub app_id: AppId,
pub script_id: ScriptId,
pub host_kind: HostKind,

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{ScriptId, ScriptSandbox};
use crate::{AppId, ScriptId, ScriptSandbox};
/// A user-uploaded Rhai script and its execution configuration.
///
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub id: ScriptId,
/// Owning app. Set on create, immutable thereafter — a "move to
/// another app" is a copy+delete, not an in-place edit (snapshot
/// semantics — see blueprint §11.5).
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub version: i32,

View File

@@ -1,12 +1,12 @@
{
"name": "picloud-dashboard",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picloud-dashboard",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
@@ -1275,7 +1275,6 @@
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1318,7 +1317,6 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -1398,7 +1396,6 @@
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1455,7 +1452,6 @@
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
@@ -1563,7 +1559,6 @@
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -1815,7 +1810,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2217,7 +2211,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3010,7 +3003,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3038,7 +3030,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -3172,7 +3163,6 @@
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3433,7 +3423,6 @@
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3614,7 +3603,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3677,7 +3665,6 @@
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@@ -1,6 +1,6 @@
{
"name": "picloud-dashboard",
"version": "0.5.1",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,328 @@
<!--
Confirmation modal — replaces window.confirm/prompt for destructive
actions so the dashboard can render the full context (counts, lists,
warnings) in its own style.
Usage:
{#if showing}
<ConfirmModal
title="Delete app"
variant="danger"
confirmLabel="Delete app"
confirmPhrase={app.slug}
onConfirm={() => doDelete()}
onCancel={() => (showing = false)}
>
<p>Body content — counts, lists, warnings.</p>
</ConfirmModal>
{/if}
When `confirmPhrase` is set the confirm button stays disabled until
the user types the phrase exactly — same pattern GitHub uses for
irreversible repo deletes. Omit it for low-stakes confirmations.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'danger' | 'neutral';
interface Props {
title: string;
children: Snippet;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
variant?: Variant;
confirmLabel?: string;
cancelLabel?: string;
/** When set, the confirm button is disabled until the user types
* this string exactly (case-sensitive). */
confirmPhrase?: string;
/** Shown above the confirm input. Defaults to a sensible message
* that mentions the phrase. */
confirmPhrasePrompt?: string;
/** While true the buttons are disabled and the confirm label is
* replaced with a "busy" form (e.g. "Deleting…"). */
busy?: boolean;
busyLabel?: string;
}
let {
title,
children,
onConfirm,
onCancel,
variant = 'neutral',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmPhrase,
confirmPhrasePrompt,
busy = false,
busyLabel
}: Props = $props();
let typed = $state('');
let phraseMatches = $derived(
confirmPhrase === undefined || typed === confirmPhrase
);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !busy) {
event.preventDefault();
onCancel();
}
}
function handleBackdrop(event: MouseEvent) {
// Only cancel when clicking the backdrop itself, not bubbled
// clicks from the dialog content.
if (event.target === event.currentTarget && !busy) {
onCancel();
}
}
async function handleConfirm() {
if (!phraseMatches || busy) return;
await onConfirm();
}
// Focus management: when the modal mounts, focus the slug-retype
// input (if present) or the cancel button (so an accidental Enter
// doesn't auto-confirm a destructive action).
let inputRef = $state<HTMLInputElement | null>(null);
let cancelRef = $state<HTMLButtonElement | null>(null);
$effect(() => {
if (inputRef) inputRef.focus();
else if (cancelRef) cancelRef.focus();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="backdrop"
role="presentation"
onclick={handleBackdrop}
>
<div
class="dialog"
class:danger={variant === 'danger'}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
>
<h2 id="confirm-title">{title}</h2>
<div class="body">
{@render children()}
</div>
{#if confirmPhrase}
<label class="phrase">
<span>
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
<code>{confirmPhrase}</code>
</span>
<input
bind:this={inputRef}
bind:value={typed}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
disabled={busy}
/>
</label>
{/if}
<div class="actions">
<button
type="button"
class="secondary"
bind:this={cancelRef}
onclick={onCancel}
disabled={busy}
>
{cancelLabel}
</button>
<button
type="button"
class={variant === 'danger' ? 'danger' : ''}
onclick={handleConfirm}
disabled={!phraseMatches || busy}
>
{busy ? (busyLabel ?? `${confirmLabel}`) : confirmLabel}
</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.dialog {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
.dialog.danger {
border-color: #7f1d1d;
}
h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
}
.dialog.danger h2 {
color: #fca5a5;
}
.body {
font-size: 0.9rem;
line-height: 1.5;
color: #cbd5e1;
}
.body :global(p) {
margin: 0 0 0.75rem;
}
.body :global(p:last-child) {
margin-bottom: 0;
}
.body :global(code) {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.85em;
}
.body :global(strong) {
color: #e2e8f0;
}
.body :global(ul) {
margin: 0.5rem 0 0.75rem;
padding-left: 1.25rem;
}
.body :global(.impact-list) {
list-style: none;
padding: 0;
margin: 0.75rem 0;
background: #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
max-height: 12rem;
overflow-y: auto;
}
.body :global(.impact-list li) {
padding: 0.25rem 0;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.body :global(.impact-list li + li) {
border-top: 1px solid #334155;
}
.body :global(.modal-error) {
margin-top: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
border-radius: 0.375rem;
font-size: 0.85rem;
}
.body :global(.muted) {
color: #64748b;
}
.phrase {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 1rem 0 0;
font-size: 0.85rem;
color: #cbd5e1;
}
.phrase code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
color: #fca5a5;
}
.phrase input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.phrase input:focus {
outline: none;
border-color: #38bdf8;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -21,6 +21,7 @@ export interface ScriptSandbox {
export interface Script {
id: string;
app_id: string;
name: string;
description: string | null;
version: number;
@@ -32,11 +33,64 @@ export interface Script {
updated_at: string;
}
export interface App {
id: string;
slug: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
}
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain {
id: string;
app_id: string;
pattern: string;
shape: DomainShape;
shape_key: string;
created_at: string;
}
export interface AppLookupResponse {
id: string;
slug: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
/// Present only when the requested slug was a retired redirect.
redirect_to?: string;
}
export interface SlugCheckResponse {
ok: boolean;
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
current_app: App | null;
reason: string | null;
}
export interface CreateAppInput {
slug: string;
name: string;
description?: string | null;
force_takeover?: boolean;
}
export interface PatchAppInput {
name?: string;
description?: string | null;
slug?: string;
force_takeover?: boolean;
}
export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param';
export interface Route {
id: string;
app_id: string;
script_id: string;
host_kind: HostKind;
host: string;
@@ -106,6 +160,7 @@ export interface ExecutionLog {
}
export interface CreateScriptInput {
app_id: string;
name: string;
description?: string | null;
source: string;
@@ -257,20 +312,23 @@ export const api = {
}),
remove: (routeId: string) =>
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
check: (input: RouteInput) =>
check: (appId: string, input: RouteInput) =>
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
method: 'POST',
body: JSON.stringify(input)
body: JSON.stringify({ ...input, app_id: appId })
}),
match: (url: string, method = 'GET') =>
match: (appId: string, url: string, method = 'GET') =>
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
method: 'POST',
body: JSON.stringify({ url, method })
body: JSON.stringify({ app_id: appId, url, method })
})
},
scripts: {
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
list: (opts: { app?: string } = {}) => {
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
},
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
create: (input: CreateScriptInput) =>
adminRequest<Script>('/api/v1/admin/scripts', {
@@ -295,6 +353,54 @@ export const api = {
}
},
apps: {
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
get: (idOrSlug: string) =>
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateAppInput) =>
adminRequest<App>('/api/v1/admin/apps', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchAppInput) =>
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
const qs = opts.force ? '?force=true' : '';
return adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
{ method: 'DELETE' }
);
},
slugCheck: (idOrSlug: string, newSlug: string) =>
adminRequest<SlugCheckResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
{
method: 'POST',
body: JSON.stringify({ new_slug: newSlug })
}
)
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
),
create: (idOrSlug: string, pattern: string) =>
adminRequest<AppDomain>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
{ method: 'POST', body: JSON.stringify({ pattern }) }
),
remove: (idOrSlug: string, domainId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
{ method: 'DELETE' }
)
},
execute: async (
id: string,
body: unknown,

View File

@@ -1,4 +1,4 @@
import type { HostKind, PathKind } from './api';
import type { AppDomain, HostKind, PathKind } from './api';
/** Guess a path kind from the literal user input. The dashboard pre-fills
* the kind selector but the user can override (the backend trusts the
@@ -30,3 +30,97 @@ export function pathKindMismatchWarning(raw: string, kind: PathKind): string | n
: 'this looks like a literal path';
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
}
/** Parse the user's free-text host input into the (kind, stored host)
* pair the API expects. Mirrors the dashboard's pre-existing storage
* convention: wildcard route hosts are stored as the bare suffix
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
export interface ParsedHostInput {
kind: HostKind;
/** What gets sent in the API payload as `host`. */
host: string;
/** Canonical display form, for chips/labels. */
display: string;
}
export function parseHostInput(raw: string): ParsedHostInput {
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '*') {
return { kind: 'any', host: '', display: '*' };
}
if (trimmed.startsWith('*.')) {
const suffix = trimmed.slice(2);
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
}
return { kind: 'strict', host: trimmed, display: trimmed };
}
/** Frontend mirror of `validate_route_host_against_app` in
* crates/manager-core/src/route_admin.rs. Lets us surface a live
* warning instead of waiting for a 422. The server runs the same
* check on submit — this is purely an early signal. */
export type HostClaimCheck =
| { ok: true; matched: string }
| { ok: false; reason: string };
export function checkHostAgainstClaims(
parsed: ParsedHostInput,
claims: AppDomain[]
): HostClaimCheck {
if (parsed.kind === 'any') return { ok: true, matched: '*' };
if (claims.length === 0) {
return {
ok: false,
reason: 'this app has no domain claims yet — add one in the apps Domains tab'
};
}
const hostLower = parsed.host.toLowerCase();
for (const claim of claims) {
const claimLower = claim.pattern.toLowerCase();
const claimSuffix = claimLower.split('.').slice(1).join('.');
if (parsed.kind === 'strict') {
if (claim.shape === 'exact' && hostLower === claimLower) {
return { ok: true, matched: claim.pattern };
}
if (
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
claimSuffix &&
hostLower.endsWith(`.${claimSuffix}`)
) {
return { ok: true, matched: claim.pattern };
}
} else {
// wildcard route
if (
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
claimSuffix === hostLower
) {
return { ok: true, matched: claim.pattern };
}
}
}
return {
ok: false,
reason: `${parsed.display} is not covered by any of this apps claims`
};
}
/** Suggestion strings for the host input's <datalist>. We always
* include the wildcard `*` (any host) as the first option, then the
* app's claims rendered in the form the route input expects. */
export function hostSuggestions(claims: AppDomain[]): string[] {
const items: string[] = ['*'];
for (const claim of claims) {
if (claim.shape === 'exact') {
items.push(claim.pattern);
} else {
// Both wildcard and parameterized claims are usable as a
// wildcard route. Render as `*.suffix` — we can't preserve
// the {param} binding name through the current route form.
const suffix = claim.pattern.split('.').slice(1).join('.');
if (suffix) items.push(`*.${suffix}`);
}
}
// Dedupe while preserving order.
return [...new Set(items)];
}

View File

@@ -0,0 +1,30 @@
// Slug normalization for app slugs, mirrored against the backend's
// validate_slug rules in crates/manager-core/src/apps_api.rs:
// - regex: ^[a-z0-9][a-z0-9-]{0,62}$
// - 1..=63 chars, lowercase ascii alphanumerics + `-`
// - must start with [a-z0-9]
// - reserved words are enforced server-side only
//
// Normalization rules are GitLab-style (close to `Babosa::Latin#to_slug`):
// 1. NFKD-decompose Unicode and drop combining marks (é → e, ñ → n,
// ü → u, etc.).
// 2. ß → ss (a single common case the strip-marks pass misses).
// 3. Lowercase.
// 4. Replace any run of non-[a-z0-9] with a single `-`.
// 5. Trim leading/trailing `-`.
// 6. Truncate to 63 chars.
export const SLUG_MAX = 63;
export function slugify(input: string): string {
if (!input) return '';
let s = input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
s = s.toLowerCase().replace(/ß/g, 'ss');
s = s.replace(/[^a-z0-9]+/g, '-');
s = s.replace(/^-+|-+$/g, '');
if (s.length > SLUG_MAX) {
// Truncate, then re-trim in case the cut landed on a `-`.
s = s.slice(0, SLUG_MAX).replace(/-+$/g, '');
}
return s;
}

View File

@@ -45,7 +45,7 @@
<header>
<a href={base + '/'} class="brand">PiCloud</a>
<nav>
<a href={base + '/'}>Scripts</a>
<a href={base + '/apps'}>Apps</a>
<a href={base + '/admins'}>Admins</a>
</nav>
<div class="spacer"></div>

View File

@@ -1,242 +1,20 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type Script } from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
let scripts = $state<Script[] | null>(null);
let listError = $state<string | null>(null);
let loading = $state(true);
let showCreate = $state(false);
let createName = $state('');
let createDescription = $state('');
let createSource = $state(SAMPLE_SOURCE);
let creating = $state(false);
let createError = $state<string | null>(null);
async function load() {
loading = true;
listError = null;
try {
scripts = await api.scripts.list();
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
scripts = null;
} finally {
loading = false;
}
}
async function submitCreate(event: Event) {
event.preventDefault();
creating = true;
createError = null;
try {
await api.scripts.create({
name: createName.trim(),
description: createDescription.trim() || null,
source: createSource
});
showCreate = false;
createName = '';
createDescription = '';
createSource = SAMPLE_SOURCE;
await load();
} catch (e) {
createError = e instanceof Error ? e.message : String(e);
if (e instanceof ApiError && e.status === 422) {
createError = `Syntax error: ${createError}`;
}
} finally {
creating = false;
}
}
$effect(() => {
void load();
// Dashboard entry: always lands on the apps list now (multi-app
// scoping makes "scripts at root" no longer meaningful — every
// script lives inside an app).
onMount(() => {
void goto(`${base}/apps`, { replaceState: true });
});
</script>
<section>
<header class="page-header">
<h1>Scripts</h1>
<button type="button" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : 'New script'}
</button>
</header>
{#if showCreate}
<form class="create-form" onsubmit={submitCreate}>
<div class="row">
<label>
<span>Name</span>
<input bind:value={createName} required minlength="1" placeholder="echo" />
</label>
<label>
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
</div>
<label class="full">
<span>Source (Rhai)</span>
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
</label>
{#if createError}
<div class="error">{createError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create script'}
</button>
</div>
</form>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if listError}
<div class="error">
<strong>Could not load scripts.</strong>
<p>{listError}</p>
<button type="button" onclick={() => void load()}>Retry</button>
</div>
{:else if scripts && scripts.length === 0}
<p class="muted">No scripts yet. Create one above to get started.</p>
{:else if scripts}
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
</div>
<div class="secondary muted">
{script.description ?? '—'}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<p class="muted">Redirecting…</p>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form label.full {
grid-column: 1 / -1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,342 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null);
let loading = $state(true);
let showCreate = $state(false);
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
// Auto-derive slug from name until the user takes manual control of
// the slug field. Clearing the slug input releases the lock so the
// auto-derive resumes — matches the GitLab project-create UX.
let slugTouched = $state(false);
let creating = $state(false);
let createError = $state<string | null>(null);
let createHistoricalConflict = $state<App | null>(null);
function onNameInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
createName = value;
if (!slugTouched) {
createSlug = slugify(value);
}
}
function onSlugInput(event: Event) {
const raw = (event.target as HTMLInputElement).value;
const normalized = slugify(raw);
createSlug = normalized;
// Re-sync the input element so a paste of "Hello World!" shows
// "hello-world" immediately, not the raw value.
if (raw !== normalized) {
(event.target as HTMLInputElement).value = normalized;
}
slugTouched = normalized.length > 0;
}
async function load() {
loading = true;
listError = null;
try {
apps = await api.apps.list();
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
apps = null;
} finally {
loading = false;
}
}
function resetCreate() {
createSlug = '';
createName = '';
createDescription = '';
createError = null;
createHistoricalConflict = null;
slugTouched = false;
}
async function submitCreate(event: Event, forceTakeover = false) {
event.preventDefault();
creating = true;
createError = null;
if (!forceTakeover) createHistoricalConflict = null;
try {
await api.apps.create({
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription.trim() || null,
force_takeover: forceTakeover || undefined
});
showCreate = false;
resetCreate();
await load();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.body) {
const body = e.body as { conflict_kind?: string; current_app?: App };
if (body.conflict_kind === 'historical' && body.current_app) {
createHistoricalConflict = body.current_app;
createError = null;
return;
}
}
createError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
$effect(() => {
void load();
});
</script>
<section>
<header class="page-header">
<h1>Apps</h1>
<button
type="button"
onclick={() => {
showCreate = !showCreate;
if (!showCreate) resetCreate();
}}
>
{showCreate ? 'Cancel' : 'New app'}
</button>
</header>
{#if showCreate}
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row">
<label>
<span>Name</span>
<input
value={createName}
oninput={onNameInput}
required
placeholder="My App"
/>
</label>
<label>
<span>Slug</span>
<input
value={createSlug}
oninput={onSlugInput}
required
pattern="[a-z0-9][a-z0-9-]*"
maxlength={SLUG_MAX}
placeholder="my-app"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
</label>
</div>
<label>
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
{#if createHistoricalConflict}
<div class="warning">
<strong>Slug previously redirected.</strong>
<p>
<code>{createSlug}</code> currently redirects to
<code>{createHistoricalConflict.slug}</code>. Using it here will break any
external links that still target the old slug.
</p>
<div class="actions">
<button type="button" class="secondary" onclick={() => (createHistoricalConflict = null)}>
Cancel
</button>
<button
type="button"
onclick={(e) => submitCreate(e, true)}
disabled={creating}
>
{creating ? 'Claiming…' : 'Claim slug anyway'}
</button>
</div>
</div>
{:else if createError}
<div class="error">{createError}</div>
{/if}
{#if !createHistoricalConflict}
<div class="actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create app'}
</button>
</div>
{/if}
</form>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if listError}
<div class="error">
<strong>Could not load apps.</strong>
<p>{listError}</p>
<button type="button" onclick={() => void load()}>Retry</button>
</div>
{:else if apps && apps.length === 0}
<p class="muted">No apps yet. Create one above to get started.</p>
{:else if apps}
<ul class="list">
{#each apps as app (app.id)}
<li>
<a href="{base}/apps/{app.slug}">
<div class="primary">
<strong>{app.name}</strong>
<span class="muted">/{app.slug}</span>
</div>
<div class="secondary muted">
{app.description ?? '—'}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning {
border: 1px solid #ca8a04;
background: #3f2e07;
color: #fde68a;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,747 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
ApiError,
type App,
type AppDomain,
type Script
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'settings';
let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
let activeTab = $state<Tab>('scripts');
let scripts = $state<Script[]>([]);
let domains = $state<AppDomain[]>([]);
// Script create
let showCreateScript = $state(false);
let createScriptName = $state('');
let createScriptDescription = $state('');
let createScriptSource = $state(SAMPLE_SOURCE);
let creatingScript = $state(false);
let createScriptError = $state<string | null>(null);
// Domain create
let createDomainPattern = $state('');
let creatingDomain = $state(false);
let createDomainError = $state<string | null>(null);
// Settings
let editName = $state('');
let editDescription = $state('');
let editSlug = $state('');
let savingSettings = $state(false);
let settingsError = $state<string | null>(null);
let slugTakeoverNeeded = $state<App | null>(null);
// Delete confirmations
let confirmingDeleteApp = $state(false);
let deletingApp = $state(false);
let deleteAppError = $state<string | null>(null);
let domainToRemove = $state<AppDomain | null>(null);
let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null);
async function loadApp() {
loading = true;
loadError = null;
try {
const fetched = await api.apps.get(slug);
if (fetched.redirect_to && fetched.redirect_to !== slug) {
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
return;
}
app = {
id: fetched.id,
slug: fetched.slug,
name: fetched.name,
description: fetched.description,
created_at: fetched.created_at,
updated_at: fetched.updated_at
};
editName = app.name;
editDescription = app.description ?? '';
editSlug = app.slug;
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadScripts(appId: string) {
try {
scripts = await api.scripts.list({ app: appId });
} catch (e) {
scripts = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function loadDomains(appId: string) {
try {
domains = await api.domains.listForApp(appId);
} catch (e) {
domains = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function submitCreateScript(event: Event) {
event.preventDefault();
if (!app) return;
creatingScript = true;
createScriptError = null;
try {
await api.scripts.create({
app_id: app.id,
name: createScriptName.trim(),
description: createScriptDescription.trim() || null,
source: createScriptSource
});
showCreateScript = false;
createScriptName = '';
createScriptDescription = '';
createScriptSource = SAMPLE_SOURCE;
await loadScripts(app.id);
} catch (e) {
createScriptError = e instanceof Error ? e.message : String(e);
if (e instanceof ApiError && e.status === 422) {
createScriptError = `Validation: ${createScriptError}`;
}
} finally {
creatingScript = false;
}
}
async function submitCreateDomain(event: Event) {
event.preventDefault();
if (!app) return;
creatingDomain = true;
createDomainError = null;
try {
await api.domains.create(app.id, createDomainPattern.trim());
createDomainPattern = '';
await loadDomains(app.id);
} catch (e) {
createDomainError = e instanceof Error ? e.message : String(e);
} finally {
creatingDomain = false;
}
}
function askRemoveDomain(d: AppDomain) {
removeDomainError = null;
domainToRemove = d;
}
async function confirmRemoveDomain() {
if (!app || !domainToRemove) return;
removingDomain = true;
removeDomainError = null;
try {
await api.domains.remove(app.id, domainToRemove.id);
domainToRemove = null;
await loadDomains(app.id);
} catch (e) {
removeDomainError = e instanceof Error ? e.message : String(e);
} finally {
removingDomain = false;
}
}
async function saveSettings(event: Event, forceTakeover = false) {
event.preventDefault();
if (!app) return;
savingSettings = true;
settingsError = null;
if (!forceTakeover) slugTakeoverNeeded = null;
try {
const slugChanged = editSlug.trim() !== app.slug;
const updated = await api.apps.update(app.id, {
name: editName.trim() !== app.name ? editName.trim() : undefined,
description:
editDescription !== (app.description ?? '')
? editDescription || null
: undefined,
slug: slugChanged ? editSlug.trim() : undefined,
force_takeover: forceTakeover || undefined
});
if (slugChanged) {
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
return;
}
app = updated;
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.body) {
const body = e.body as { conflict_kind?: string; current_app?: App };
if (body.conflict_kind === 'historical' && body.current_app) {
slugTakeoverNeeded = body.current_app;
settingsError = null;
return;
}
}
settingsError = e instanceof Error ? e.message : String(e);
} finally {
savingSettings = false;
}
}
function askDeleteApp() {
deleteAppError = null;
confirmingDeleteApp = true;
}
async function confirmDeleteApp() {
if (!app) return;
deletingApp = true;
deleteAppError = null;
try {
// force=true cascades scripts (and thereby their routes +
// execution logs); domains and slug-history rows cascade off
// the app row itself.
await api.apps.remove(app.id, { force: true });
await goto(`${base}/apps`);
} catch (e) {
deleteAppError = e instanceof Error ? e.message : String(e);
} finally {
deletingApp = false;
}
}
$effect(() => {
void loadApp();
});
</script>
{#if loading && !app}
<p class="muted">Loading…</p>
{:else if loadError && !app}
<div class="error">
<strong>Could not load app.</strong>
<p>{loadError}</p>
<a href="{base}/apps">Back to apps</a>
</div>
{:else if app}
<header class="page-header">
<div>
<div class="breadcrumb">
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
</div>
<h1>{app.name}</h1>
{#if app.description}<p class="muted">{app.description}</p>{/if}
</div>
</header>
<nav class="tabs">
<button
type="button"
class:active={activeTab === 'scripts'}
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
>
<button
type="button"
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
</nav>
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
</div>
{#if showCreateScript}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
<span>Name</span>
<input bind:value={createScriptName} required placeholder="echo" />
</label>
<label>
<span>Description</span>
<input bind:value={createScriptDescription} placeholder="optional" />
</label>
</div>
<label class="full">
<span>Source (Rhai)</span>
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
</label>
{#if createScriptError}
<div class="error">{createScriptError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingScript}>
{creatingScript ? 'Creating…' : 'Create script'}
</button>
</div>
</form>
{/if}
{#if scripts.length === 0}
<p class="muted">No scripts in this app yet.</p>
{:else}
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
</div>
<div class="secondary muted">{script.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'domains'}
<section>
<h2>Domain claims</h2>
<p class="muted">
Hosts this app answers on. Routes inside this app can only bind to
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.
</p>
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
required
placeholder="app.example.com"
/>
<button type="submit" disabled={creatingDomain}>
{creatingDomain ? 'Adding…' : 'Add domain'}
</button>
</form>
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
{:else}
<ul class="list">
{#each domains as d (d.id)}
<li class="domain-row">
<div>
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
<button
type="button"
class="secondary danger"
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'settings'}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
<label>
<span>Name</span>
<input bind:value={editName} required />
</label>
<label>
<span>Description</span>
<input bind:value={editDescription} />
</label>
<label>
<span>Slug</span>
<input
bind:value={editSlug}
required
pattern="[a-z0-9][a-z0-9-]*"
/>
<small class="muted">
Renaming records the old slug as a permanent 301 redirect.
</small>
</label>
{#if slugTakeoverNeeded}
<div class="warning">
<strong>Slug previously redirected.</strong>
<p>
<code>{editSlug}</code> currently redirects to
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
links.
</p>
<div class="actions">
<button
type="button"
class="secondary"
onclick={() => (slugTakeoverNeeded = null)}
>
Cancel
</button>
<button
type="button"
onclick={(e) => saveSettings(e, true)}
disabled={savingSettings}
>
{savingSettings ? 'Renaming…' : 'Rename anyway'}
</button>
</div>
</div>
{:else if settingsError}
<div class="error">{settingsError}</div>
{/if}
{#if !slugTakeoverNeeded}
<div class="actions">
<button type="submit" disabled={savingSettings}>
{savingSettings ? 'Saving…' : 'Save changes'}
</button>
</div>
{/if}
</form>
<div class="danger-zone">
<h3>Delete app</h3>
<p class="muted">
Permanently removes the app along with all its scripts, routes,
execution logs, and domain claims.
</p>
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
</div>
</section>
{/if}
{#if confirmingDeleteApp}
<ConfirmModal
title="Delete app “{app.name}”"
variant="danger"
confirmLabel="Delete app"
busyLabel="Deleting…"
confirmPhrase={app.slug}
confirmPhrasePrompt="Type the app slug to confirm:"
busy={deletingApp}
onConfirm={confirmDeleteApp}
onCancel={() => (confirmingDeleteApp = false)}
>
<p>
This will <strong>permanently delete</strong> everything inside
<strong>{app.name}</strong>. There is no undo.
</p>
<ul class="impact-list">
<li>
<span>Scripts</span><strong>{scripts.length}</strong>
</li>
<li>
<span>Domain claims</span><strong>{domains.length}</strong>
</li>
<li>
<span>Routes &amp; execution logs</span><strong>all</strong>
</li>
</ul>
{#if domains.length > 0}
<p>The following hosts will stop pointing at this app:</p>
<ul class="impact-list">
{#each domains as d (d.id)}
<li>
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
</li>
{/each}
</ul>
{/if}
{#if deleteAppError}
<p class="modal-error">{deleteAppError}</p>
{/if}
</ConfirmModal>
{/if}
{#if domainToRemove}
<ConfirmModal
title="Delete domain claim"
variant="danger"
confirmLabel="Delete claim"
busyLabel="Deleting…"
busy={removingDomain}
onConfirm={confirmRemoveDomain}
onCancel={() => (domainToRemove = null)}
>
<p>
<strong>{app.name}</strong> will stop answering on
<code>{domainToRemove.pattern}</code>.
</p>
<p class="muted">
Routes already bound to this host are blocked from deletion by the
API; if so, youll see an error here.
</p>
{#if removeDomainError}
<p class="modal-error">{removeDomainError}</p>
{/if}
</ConfirmModal>
{/if}
{/if}
<style>
.page-header {
margin-bottom: 1rem;
}
.breadcrumb {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.breadcrumb a {
color: #94a3b8;
text-decoration: none;
}
.breadcrumb a:hover {
color: #e2e8f0;
}
.breadcrumb code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
font-size: 1.125rem;
margin: 0 0 1rem;
}
h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid #1e293b;
margin-bottom: 1.25rem;
}
.tabs button {
background: transparent;
color: #94a3b8;
border: none;
padding: 0.6rem 1rem;
font: inherit;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button:hover {
color: #e2e8f0;
}
.tabs button.active {
color: #38bdf8;
border-bottom-color: #38bdf8;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button.secondary.danger {
background: transparent;
color: #fca5a5;
border-color: #7f1d1d;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning {
border: 1px solid #ca8a04;
background: #3f2e07;
color: #fde68a;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form.inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form label.full {
grid-column: 1 / -1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
flex: 1;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.domain-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
}
.domain-row code {
background: #0b1220;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid #7f1d1d;
border-radius: 0.5rem;
background: #1e0a0a;
}
</style>

View File

@@ -5,6 +5,7 @@
import {
api,
ApiError,
type AppDomain,
type ExecutionLog,
type Route,
type RouteInput,
@@ -12,7 +13,13 @@
type VersionInfo
} from '$lib/api';
import { logLevelColor, statusColor } from '$lib/styles';
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
import {
checkHostAgainstClaims,
guessPathKind,
hostSuggestions,
parseHostInput,
pathKindMismatchWarning
} from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte';
import { format as formatRhai } from '$lib/rhai';
@@ -38,6 +45,9 @@
let scriptLoading = $state(true);
let info = $state<VersionInfo | null>(null);
let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]);
async function loadScript() {
scriptLoading = true;
scriptError = null;
@@ -48,6 +58,23 @@
editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb and its
// domain claims for the route form's suggestions + live
// validation. Both are non-fatal — the page works without
// them.
const appId = script.app_id;
void api.apps
.get(appId)
.then((a) => {
appSlug = a.slug;
})
.catch(() => {});
void api.domains
.listForApp(appId)
.then((d) => {
appDomains = d;
})
.catch(() => {});
} catch (e) {
scriptError = e instanceof Error ? e.message : String(e);
script = null;
@@ -165,12 +192,14 @@
let routesLoading = $state(true);
let showAddRoute = $state(false);
let newRoutePath = $state('');
let newRoutePath = $state('/');
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
let newRouteHost = $state('');
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
// Host input is free-form; the kind is derived from what the user
// typed (see `parsedHost` below). Default `*` = Any, matching the
// canonical display form for an unrestricted host.
let newRouteHost = $state('*');
let newRouteMethod = $state('');
let routeKindAutoUpdate = $state(true);
let pathKindAutoUpdate = $state(true);
let creatingRoute = $state(false);
let createRouteError = $state<string | null>(null);
@@ -178,17 +207,17 @@
let previewMethod = $state('GET');
let previewResult = $state<string | null>(null);
// Auto-update kind selectors as the user types.
// Auto-update the path-kind selector as the user types.
$effect(() => {
if (routeKindAutoUpdate) {
if (pathKindAutoUpdate) {
newRoutePathKind = guessPathKind(newRoutePath);
}
});
$effect(() => {
if (routeKindAutoUpdate) {
newRouteHostKind = guessHostKind(newRouteHost);
}
});
let parsedHost = $derived(parseHostInput(newRouteHost));
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
let hostDatalistId = 'route-host-suggestions';
let suggestions = $derived(hostSuggestions(appDomains));
let pathKindWarning = $derived(
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
@@ -212,18 +241,18 @@
createRouteError = null;
try {
const input: RouteInput = {
host_kind: newRouteHostKind,
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
host_kind: parsedHost.kind,
host: parsedHost.host,
path_kind: newRoutePathKind,
path: newRoutePath.trim(),
method: newRouteMethod.trim() || null
};
await api.routes.create(id, input);
showAddRoute = false;
newRoutePath = '';
newRouteHost = '';
newRoutePath = '/';
newRouteHost = '*';
newRouteMethod = '';
routeKindAutoUpdate = true;
pathKindAutoUpdate = true;
await loadRoutes();
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
@@ -251,8 +280,9 @@
async function runPreview() {
previewResult = null;
if (!script) return;
try {
const r = await api.routes.match(previewUrl, previewMethod);
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
if (r.matched) {
const ours = r.matched.script_id === id;
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
@@ -368,6 +398,13 @@
{:else if script}
<header class="page-header">
<div>
{#if appSlug}
<div class="breadcrumb">
<a href="{base}/apps">Apps</a> /
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
<code>{script.name}</code>
</div>
{/if}
<h1>{script.name}</h1>
<p class="muted">
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
@@ -484,7 +521,7 @@
<span>Path</span>
<input
bind:value={newRoutePath}
oninput={() => (routeKindAutoUpdate = true)}
oninput={() => (pathKindAutoUpdate = true)}
placeholder="/greet, /greet/:name, /webhooks/*"
required
autocomplete="off"
@@ -495,7 +532,7 @@
<span>Path kind</span>
<select
bind:value={newRoutePathKind}
onchange={() => (routeKindAutoUpdate = false)}
onchange={() => (pathKindAutoUpdate = false)}
>
<option value="exact">exact</option>
<option value="param">param</option>
@@ -514,31 +551,43 @@
</select>
</label>
</div>
<div class="row">
<label>
<span>Host kind</span>
<select
bind:value={newRouteHostKind}
onchange={() => (routeKindAutoUpdate = false)}
>
<option value="any">ANY</option>
<option value="strict">strict</option>
<option value="wildcard">wildcard</option>
</select>
</label>
<label class:disabled={newRouteHostKind === 'any'}>
<span>Host</span>
<label class="full">
<span class="host-label">
Host
<span class="kind-chip kind-{parsedHost.kind}">
{parsedHost.kind}
</span>
</span>
<input
bind:value={newRouteHost}
oninput={() => (routeKindAutoUpdate = true)}
disabled={newRouteHostKind === 'any'}
placeholder={newRouteHostKind === 'wildcard'
? '*.example.com'
: 'sub.example.com'}
placeholder="* · app.example.com · *.example.com"
list={hostDatalistId}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
<datalist id={hostDatalistId}>
{#each suggestions as s (s)}
<option value={s}></option>
{/each}
</datalist>
<small class="muted">
<code>*</code> = any host claimed by this app ·
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
strict
</small>
</label>
{#if !hostCheck.ok}
<div class="warning inline">
{hostCheck.reason}.
{#if appDomains.length > 0}
Claims:
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
{/if}{/each}
{/if}
</div>
{/if}
{#if pathKindWarning}
<div class="warning inline">{pathKindWarning}</div>
{/if}
@@ -756,6 +805,23 @@
align-items: flex-start;
margin: 1rem 0 1.5rem;
}
.breadcrumb {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.breadcrumb a {
color: #94a3b8;
text-decoration: none;
}
.breadcrumb a:hover {
color: #e2e8f0;
}
.breadcrumb code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
@@ -916,9 +982,6 @@
font-size: 0.85rem;
color: #cbd5e1;
}
label.disabled {
opacity: 0.6;
}
label.full {
width: 100%;
}
@@ -1035,6 +1098,31 @@
background: #581c87;
color: #e9d5ff;
}
.host-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-chip {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
letter-spacing: 0.03em;
}
.kind-chip.kind-any {
background: #1e293b;
color: #cbd5e1;
}
.kind-chip.kind-strict {
background: #14532d;
color: #bbf7d0;
}
.kind-chip.kind-wildcard {
background: #1e3a8a;
color: #bfdbfe;
}
.route-row .method {
color: #fbbf24;
font-weight: 700;

View File

@@ -40,6 +40,12 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
RUST_LOG: ${RUST_LOG:-info}
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
# Bootstrap admin (Phase 3a). Read once on first start to seed the
# admin_users table; ignored on subsequent boots if the table is
# non-empty. No defaults on purpose — leaving these unset in prod
# is a foot-gun. For dev, .env.example documents sensible values.
PICLOUD_ADMIN_USERNAME: ${PICLOUD_ADMIN_USERNAME:?set PICLOUD_ADMIN_USERNAME (see .env.example)}
PICLOUD_ADMIN_PASSWORD: ${PICLOUD_ADMIN_PASSWORD:?set PICLOUD_ADMIN_PASSWORD (see .env.example)}
depends_on:
postgres:
condition: service_healthy

View File

@@ -126,10 +126,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version |
|---|---|
| Product | `0.5.1` |
| Product | `0.6.0` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` |
| Schema | `3` (matches `migrations/0003_routes.sql`) |
| 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 | `6` (matches `migrations/0006_users_authz.sql`) |
| Wire | `1` (reserved; cluster mode not implemented) |
Read live from `GET /version` on any running instance.

View File

@@ -853,7 +853,17 @@ Permission checks land in middleware that initially only enforces "authenticated
---
## 11.5 App Scoping (v1.x)
## 11.5 App Scoping (Phase 3b) — Shipped
**Status**: shipped. Implementation lives in:
- `crates/shared/src/{app,ids,script,route}.rs``App`, `AppDomain`, `AppId`, `app_id` fields on `Script`/`Route`/`ExecutionLog`.
- `crates/manager-core/src/{app_repo,app_domain_repo,apps_api,app_bootstrap}.rs` — repos + admin API + Hello-World seed.
- `crates/orchestrator-core/src/routing/{app_domains,pattern,table}.rs``AppDomainTable`, `parse_app_domain`, per-app `RouteTable`.
- Migration `0005_apps.sql`.
**Deviations from the design below**: none of substance. Two operational notes:
- The Hello-World seed lives in `crates/manager-core/seeds/hello.rhai` and is inserted by a Rust bootstrap step (`seed_hello_world_if_fresh`) rather than from the migration — keeps it testable and gives the dashboard editor real source to render. The migration always inserts the `default` app + `localhost` claim; the seed only fires when that app is otherwise empty.
- Per-app admin roles/permissions are deferred — every authenticated admin can act on every app. The middleware seam (`auth_middleware::require_admin`) is the place where role checks slot in later.
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
@@ -1012,6 +1022,152 @@ 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 `editor` on every app. Cannot manage instance-wide settings 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 |
| `editor` | CRUD on scripts, routes, sandbox config |
| `viewer` | read scripts + execution logs |
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
### 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).
### 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 / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
---
## 12. Development Roadmap
### Phase 1: MVP ✓ (Shipped)
@@ -1038,13 +1194,15 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
### 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.
**3b. Multi-app scoping**see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
**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.
---