21 Commits

Author SHA1 Message Date
MechaCat02
6eb32a78bf feat(dashboard): adopt ActionMenu for user row actions
Replaces the inline row-action buttons on the Users page with the new
shared ActionMenu kebab. Drops the redundant `is_active` toggle from the
edit form (Activate/Deactivate already lives in the kebab).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

25
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -6,7 +6,7 @@
//! that writes the history row in the same transaction. //! that writes the history row in the same transaction.
use async_trait::async_trait; use async_trait::async_trait;
use picloud_shared::{App, AppId}; use picloud_shared::{AdminUserId, App, AppId};
use sqlx::PgPool; use sqlx::PgPool;
use crate::repo::ScriptRepositoryError; use crate::repo::ScriptRepositoryError;
@@ -22,7 +22,12 @@ pub struct AppLookup {
#[async_trait] #[async_trait]
pub trait AppRepository: Send + Sync { pub trait AppRepository: Send + Sync {
/// Every app on the instance. For owner/admin callers — `member`
/// users go through `list_for_user`.
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>; async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
/// Only apps the user has an `app_members` row for. Drives the
/// membership-filtered `GET /admin/apps` for `member` callers.
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>; async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>; async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
async fn get_by_slug_or_history( async fn get_by_slug_or_history(
@@ -92,6 +97,20 @@ impl AppRepository for PostgresAppRepository {
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, AppRow>(
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
FROM apps a \
JOIN app_members m ON m.app_id = a.id \
WHERE m.user_id = $1 \
ORDER BY a.name",
)
.bind(user_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> { async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, AppRow>( let row = sqlx::query_as::<_, AppRow>(
"SELECT id, slug, name, description, created_at, updated_at \ "SELECT id, slug, name, description, created_at, updated_at \

View File

@@ -15,15 +15,16 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
use axum::Router; use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain}; use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId}; use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain}; use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository; use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError; use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository; use crate::route_repo::RouteRepository;
@@ -41,6 +42,8 @@ pub struct AppsState {
/// Cached host → app_id lookup; replaced after every domain CRUD /// Cached host → app_id lookup; replaced after every domain CRUD
/// operation so the orchestrator sees changes immediately. /// operation so the orchestrator sees changes immediately.
pub domain_table: Arc<AppDomainTable>, pub domain_table: Arc<AppDomainTable>,
/// Capability gate — Phase 3.5.
pub authz: Arc<dyn AuthzRepo>,
} }
pub fn apps_router(state: AppsState) -> Router { pub fn apps_router(state: AppsState) -> Router {
@@ -144,14 +147,27 @@ pub struct AppLookupResponse {
// Handlers // Handlers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> { async fn list_apps(
Ok(Json(s.apps.list().await?)) State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<App>>, AppsApiError> {
// Member callers see only apps they're a member of; owner/admin
// see everything. Filter at the SQL layer (not just in the
// dashboard) — that's the strict-isolation guarantee from §11.6.
let apps = if principal.instance_role == InstanceRole::Member {
s.apps.list_for_user(principal.user_id).await?
} else {
s.apps.list().await?
};
Ok(Json(apps))
} }
async fn create_app( async fn create_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAppRequest>, Json(input): Json<CreateAppRequest>,
) -> Result<(StatusCode, Json<App>), AppsApiError> { ) -> Result<(StatusCode, Json<App>), AppsApiError> {
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
validate_slug(&input.slug)?; validate_slug(&input.slug)?;
// Historical-slug check before insert: if the slug is in history // Historical-slug check before insert: if the slug is in history
@@ -178,9 +194,16 @@ async fn create_app(
async fn get_app( async fn get_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
) -> Result<Json<AppLookupResponse>, AppsApiError> { ) -> Result<Json<AppLookupResponse>, AppsApiError> {
let lookup = resolve_app(&*s.apps, &id_or_slug).await?; let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppRead(lookup.app.id),
)
.await?;
let redirect_to = if lookup.redirected { let redirect_to = if lookup.redirected {
Some(lookup.app.slug.clone()) Some(lookup.app.slug.clone())
} else { } else {
@@ -194,10 +217,17 @@ async fn get_app(
async fn patch_app( async fn patch_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Json(input): Json<PatchAppRequest>, Json(input): Json<PatchAppRequest>,
) -> Result<Json<App>, AppsApiError> { ) -> Result<Json<App>, AppsApiError> {
let current = resolve_app(&*s.apps, &id_or_slug).await?.app; let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(
s.authz.as_ref(),
&principal,
Capability::AppAdmin(current.id),
)
.await?;
// Edits to name/description go first (separate from rename so we // Edits to name/description go first (separate from rename so we
// don't conflate the two errors). // don't conflate the two errors).
@@ -240,10 +270,12 @@ async fn patch_app(
async fn delete_app( async fn delete_app(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Query(q): Query<DeleteAppQuery>, Query(q): Query<DeleteAppQuery>,
) -> Result<StatusCode, AppsApiError> { ) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
if q.force { if q.force {
s.apps.delete_cascade(app.id).await?; s.apps.delete_cascade(app.id).await?;
@@ -262,9 +294,12 @@ async fn delete_app(
async fn slug_check( async fn slug_check(
State(s): State<AppsState>, State(s): State<AppsState>,
Path(_id_or_slug): Path<String>, Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SlugCheckRequest>, Json(input): Json<SlugCheckRequest>,
) -> Result<Json<SlugCheckResponse>, AppsApiError> { ) -> Result<Json<SlugCheckResponse>, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
match validate_slug(&input.new_slug) { match validate_slug(&input.new_slug) {
Err(AppsApiError::InvalidSlug(reason)) => { Err(AppsApiError::InvalidSlug(reason)) => {
return Ok(Json(SlugCheckResponse { return Ok(Json(SlugCheckResponse {
@@ -303,18 +338,27 @@ async fn slug_check(
async fn list_domains( async fn list_domains(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppDomain>>, AppsApiError> { ) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
Ok(Json(s.domains.list_for_app(app.id).await?)) Ok(Json(s.domains.list_for_app(app.id).await?))
} }
async fn create_domain( async fn create_domain(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateDomainRequest>, Json(input): Json<CreateDomainRequest>,
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> { ) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageDomains(app.id),
)
.await?;
let parsed = pattern::parse_app_domain(&input.pattern)?; let parsed = pattern::parse_app_domain(&input.pattern)?;
let created = s let created = s
.domains .domains
@@ -331,9 +375,16 @@ async fn create_domain(
async fn delete_domain( async fn delete_domain(
State(s): State<AppsState>, State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, domain_id)): Path<(String, Uuid)>, Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppsApiError> { ) -> Result<StatusCode, AppsApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?.app; let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageDomains(app.id),
)
.await?;
let Some(domain) = s.domains.get(domain_id).await? else { let Some(domain) = s.domains.get(domain_id).await? else {
return Err(AppsApiError::DomainNotFound(domain_id)); return Err(AppsApiError::DomainNotFound(domain_id));
}; };
@@ -476,10 +527,25 @@ pub enum AppsApiError {
#[error("conflict: {0}")] #[error("conflict: {0}")]
Conflict(String), Conflict(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")] #[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError), Repo(#[from] ScriptRepositoryError),
} }
impl From<AuthzDenied> for AppsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for AppsApiError { impl IntoResponse for AppsApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {
@@ -511,6 +577,14 @@ impl IntoResponse for AppsApiError {
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => { Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() })) (StatusCode::CONFLICT, json!({ "error": self.to_string() }))
} }
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "apps authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Repo(ScriptRepositoryError::Db(e)) => { Self::Repo(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "apps api db error"); tracing::error!(error = %e, "apps api db error");
( (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,14 @@ use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router}; use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits}; use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations, admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository, AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
RouteRepository, SandboxCeiling, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
}; };
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{ use picloud_orchestrator_core::{
@@ -37,6 +38,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
pub struct AuthDeps { pub struct AuthDeps {
pub users: Arc<dyn AdminUserRepository>, pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>, pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration, pub ttl: Duration,
} }
@@ -46,7 +48,8 @@ impl AuthDeps {
pub fn from_pool(pool: PgPool) -> Self { pub fn from_pool(pool: PgPool) -> Self {
Self { Self {
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())), users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)), sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
ttl: read_session_ttl(), ttl: read_session_ttl(),
} }
} }
@@ -85,7 +88,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone())); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone())); let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> = let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool)); Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// 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. // Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new()); let route_table = Arc::new(RouteTable::new());
@@ -120,6 +126,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())), repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
logs: log_repo, logs: log_repo,
apps: apps_repo.clone(), apps: apps_repo.clone(),
authz: authz.clone(),
validator: engine as Arc<dyn ScriptValidator>, validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(), sandbox_ceiling: SandboxCeiling::from_env(),
}; };
@@ -128,6 +135,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)), scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
domains: domains_repo.clone(), domains: domains_repo.clone(),
table: route_table.clone(), table: route_table.clone(),
authz: authz.clone(),
}; };
let data_plane = DataPlaneState { let data_plane = DataPlaneState {
executor, executor,
@@ -141,28 +149,39 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
domains: domains_repo, domains: domains_repo,
routes: route_repo, routes: route_repo,
domain_table: app_domain_table, domain_table: app_domain_table,
authz: authz.clone(),
}; };
let auth_state = AuthState { let auth_state = AuthState {
users: auth.users.clone(), users: auth.users.clone(),
sessions: auth.sessions.clone(), sessions: auth.sessions.clone(),
keys: auth.keys.clone(),
ttl: auth.ttl, ttl: auth.ttl,
}; };
let admins_state = AdminsState { let admins_state = AdminsState {
users: auth.users, users: auth.users,
sessions: auth.sessions, 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 // /admin/auth/login + /logout are unguarded by design (login is how
// you get in). /admin/auth/me applies the middleware internally so // you get in). /admin/auth/me applies the middleware internally so
// the same Router::with_state machinery composes cleanly. Everything // the same Router::with_state machinery composes cleanly. Everything
// else under /admin gets the require_admin layer. // else under /admin gets the require_authenticated layer; capability
// checks live in each handler (after the resource is loaded so the
// capability binds to the resource's actual app_id).
let guarded_admin = Router::new() let guarded_admin = Router::new()
.merge(admin_router(admin)) .merge(admin_router(admin))
.merge(route_admin_router(route_admin)) .merge(route_admin_router(route_admin))
.merge(admins_router(admins_state)) .merge(admins_router(admins_state))
.merge(apps_router(apps_state)) .merge(apps_router(apps_state))
.layer(from_fn_with_state(auth_state.clone(), require_admin)); .merge(api_keys_router(api_keys_state))
.layer(from_fn_with_state(
auth_state.clone(),
require_authenticated,
));
// Silence "unused import" lint on `apps_api` — we re-export via the // Silence "unused import" lint on `apps_api` — we re-export via the
// facade above; the bare module path is retained so it's discoverable. // facade above; the bare module path is retained so it's discoverable.
@@ -244,6 +263,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> { ) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_app(app_id).await self.0.list_for_app(app_id).await
} }
async fn list_for_user(
&self,
user_id: picloud_shared::AdminUserId,
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
self.0.list_for_user(user_id).await
}
async fn create( async fn create(
&self, &self,
input: picloud_manager_core::NewScript, input: picloud_manager_core::NewScript,

View File

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

View File

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

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, None)
.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, None)
.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)");
}

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

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{ {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.1", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picloud-dashboard", "name": "picloud-dashboard",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.2", "@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import type { InstanceRole } from '$lib/auth';
interface Props {
role: InstanceRole;
size?: 'sm' | 'md';
}
let { role, size = 'md' }: Props = $props();
</script>
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</span>
<style>
.chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid transparent;
}
.chip.sm {
font-size: 0.625rem;
padding: 0.1rem 0.45rem;
}
.chip-owner {
background: #78350f;
color: #fbbf24;
border-color: #b45309;
}
.chip-admin {
background: #164e63;
color: #67e8f9;
border-color: #0e7490;
}
.chip-member {
background: #1e293b;
color: #cbd5e1;
border-color: #334155;
}
</style>

View File

@@ -8,7 +8,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { base } from '$app/paths'; import { base } from '$app/paths';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { clearSession, getToken, setSession, type AdminUser } from './auth'; import { clearSession, getToken, setSession, type InstanceRole } from './auth';
export type { InstanceRole };
export interface ScriptSandbox { export interface ScriptSandbox {
max_operations?: number; max_operations?: number;
@@ -232,10 +234,42 @@ function safeJson(text: string): unknown {
} }
} }
export interface AdminUserRecord { export type Scope =
| 'script:read'
| 'script:write'
| 'route:write'
| 'domain:manage'
| 'log:read'
| 'app:admin'
| 'instance:admin';
export const ALL_SCOPES: readonly Scope[] = [
'script:read',
'script:write',
'route:write',
'domain:manage',
'log:read',
'app:admin',
'instance:admin'
] as const;
export function isInstanceScope(s: Scope): boolean {
return s.startsWith('instance:');
}
export interface MeDto {
id: string;
username: string;
instance_role: InstanceRole;
email: string | null;
}
export interface AdminDto {
id: string; id: string;
username: string; username: string;
is_active: boolean; is_active: boolean;
instance_role: InstanceRole;
email: string | null;
created_at: string; created_at: string;
last_login_at: string | null; last_login_at: string | null;
} }
@@ -243,16 +277,42 @@ export interface AdminUserRecord {
export interface CreateAdminInput { export interface CreateAdminInput {
username: string; username: string;
password: string; password: string;
instance_role?: InstanceRole;
email?: string | null;
} }
export interface PatchAdminInput { export interface PatchAdminInput {
username?: string; username?: string;
password?: string; password?: string;
is_active?: boolean; is_active?: boolean;
instance_role?: InstanceRole;
email?: string | null;
}
export interface ApiKeyDto {
id: string;
prefix: string;
name: string;
scopes: Scope[];
app_id: string | null;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
}
export interface MintApiKeyInput {
name: string;
scopes: Scope[];
app_id?: string | null;
expires_at?: string | null;
}
export interface MintApiKeyResponse extends ApiKeyDto {
raw_token: string;
} }
interface LoginResponse { interface LoginResponse {
user: AdminUser; user: MeDto;
token: string; token: string;
expires_at: string; expires_at: string;
} }
@@ -263,7 +323,7 @@ export const api = {
version: () => adminRequest<VersionInfo>('/version'), version: () => adminRequest<VersionInfo>('/version'),
auth: { auth: {
login: async (username: string, password: string): Promise<AdminUser> => { login: async (username: string, password: string): Promise<MeDto> => {
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', { const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
@@ -282,19 +342,19 @@ export const api = {
clearSession(); clearSession();
} }
}, },
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me') me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
}, },
admins: { admins: {
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'), list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`), get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
create: (input: CreateAdminInput) => create: (input: CreateAdminInput) =>
adminRequest<AdminUserRecord>('/api/v1/admin/admins', { adminRequest<AdminDto>('/api/v1/admin/admins', {
method: 'POST', method: 'POST',
body: JSON.stringify(input) body: JSON.stringify(input)
}), }),
update: (id: string, input: PatchAdminInput) => update: (id: string, input: PatchAdminInput) =>
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, { adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(input) body: JSON.stringify(input)
}), }),
@@ -302,6 +362,17 @@ export const api = {
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' }) adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
}, },
apiKeys: {
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
mint: (input: MintApiKeyInput) =>
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
method: 'POST',
body: JSON.stringify(input)
}),
revoke: (id: string) =>
adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
},
routes: { routes: {
listForScript: (scriptId: string) => listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`), adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),

View File

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

View File

@@ -0,0 +1,25 @@
// Cryptographically random password generator for the user-create
// and reset-password flows. PiCloud has no email yet, so the admin
// invites a user by generating a password locally, posting it to the
// backend, and copying the cleartext out of the one-time reveal panel
// to share through whatever channel they trust.
//
// Charset is alphanumeric plus a small printable symbol set — enough
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
// avoidant of characters that ship awkwardly through chat clients
// (no quotes, slashes, or backticks).
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
export function generatePassword(length = 16): string {
if (length < 8) {
throw new Error('password length must be at least 8');
}
const buf = new Uint32Array(length);
crypto.getRandomValues(buf);
let out = '';
for (let i = 0; i < length; i++) {
out += CHARSET[buf[i] % CHARSET.length];
}
return out;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1022,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 ## 12. Development Roadmap
### Phase 1: MVP ✓ (Shipped) ### Phase 1: MVP ✓ (Shipped)
@@ -1048,13 +1194,15 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
### Phase 3: v1.0.x — Foundations (Current focus) ### Phase 3: v1.0.x — Foundations (Current focus)
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive. Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC. **3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred. **3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data. **3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
--- ---