Compare commits
15 Commits
6891496589
...
feat/users
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aab92af31 | ||
|
|
063595be31 | ||
|
|
30a1584667 | ||
|
|
d229120df6 | ||
|
|
8659a58eb2 | ||
|
|
5f7ddd23ab | ||
|
|
44db8d107a | ||
|
|
abaabb68d8 | ||
|
|
fd6f2b1f13 | ||
|
|
d435322f9c | ||
|
|
5546323cdc | ||
|
|
a393f11344 | ||
|
|
ad5492a4bd | ||
|
|
ee0dbc428f | ||
|
|
4c41374db4 |
@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
|
|||||||
# Public base URL the dashboard uses to render full URLs for user routes.
|
# Public base URL the dashboard uses to render full URLs for user routes.
|
||||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||||
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ---------- Bootstrap admin ----------
|
||||||
|
# Required. Used once on first startup to seed the admin_users table.
|
||||||
|
# Ignored on subsequent boots if the table is non-empty. For prod,
|
||||||
|
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
|
||||||
|
# raw password never lands in env or compose files; see blueprint §11.5.
|
||||||
|
PICLOUD_ADMIN_USERNAME=admin
|
||||||
|
PICLOUD_ADMIN_PASSWORD=admin
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||||
|
|
||||||
**Current focus (Phase 3, pre-v1.1):** admin auth gate, then multi-app scoping. The latter introduces `apps` as the top-level isolation boundary for scripts, routes, domains, and (later) data. See blueprint §11.5 for the design. Every v1.1+ feature must assume `app_id` exists as a scoping dimension.
|
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||||
|
|
||||||
## Three-Service Architecture
|
## Three-Service Architecture
|
||||||
|
|
||||||
|
|||||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
117
crates/manager-core/migrations/0005_apps.sql
Normal file
117
crates/manager-core/migrations/0005_apps.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Phase 3b multi-app scoping — see blueprint §11.5.
|
||||||
|
--
|
||||||
|
-- Apps are the top-level isolation boundary for scripts, routes, domain
|
||||||
|
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
|
||||||
|
-- route trie; cross-app resource access is not possible.
|
||||||
|
--
|
||||||
|
-- This migration is unconditional:
|
||||||
|
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
|
||||||
|
-- 2. Always inserts a "default" app claiming `localhost` so existing
|
||||||
|
-- installs get a usable home for their pre-existing scripts/routes.
|
||||||
|
-- 3. Backfills app_id on scripts, routes, execution_logs from the
|
||||||
|
-- default app row, then promotes the columns to NOT NULL + FK.
|
||||||
|
--
|
||||||
|
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
|
||||||
|
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
|
||||||
|
-- World script into it. Doing the seed in Rust keeps it testable and
|
||||||
|
-- lets the script source live in a real .rhai file.
|
||||||
|
|
||||||
|
CREATE TABLE apps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- URL-safe identifier; mutable via the rename flow which records
|
||||||
|
-- the prior slug in app_slug_history for permanent 301 redirects.
|
||||||
|
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
-- check) lives in Rust handlers, not SQL.
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Domain claims. Most-specific wins at request time; same-shape
|
||||||
|
-- collisions are rejected at claim time via the UNIQUE(shape_key).
|
||||||
|
-- shape_key encoding:
|
||||||
|
-- exact:<lowercased-host> for shape='exact'
|
||||||
|
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
|
||||||
|
-- (parameterized is the same shape as wildcard for collision — the
|
||||||
|
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
|
||||||
|
CREATE TABLE app_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
|
||||||
|
shape_key TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
|
||||||
|
|
||||||
|
-- Permanent 301 redirects after a slug rename. A row dies only when
|
||||||
|
-- another app explicitly claims the retired slug (with confirmation in
|
||||||
|
-- the UI). On_delete cascade: if the owning app is deleted, its history
|
||||||
|
-- row goes too (otherwise the redirect would point at a dead app).
|
||||||
|
CREATE TABLE app_slug_history (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the default app + a localhost claim. Used by both upgrade and
|
||||||
|
-- fresh-install paths; the Rust bootstrap layers Hello World on top
|
||||||
|
-- only when the install was fresh.
|
||||||
|
WITH default_app AS (
|
||||||
|
INSERT INTO apps (slug, name, description)
|
||||||
|
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
|
||||||
|
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
|
||||||
|
|
||||||
|
-- Add app_id to scripts. The default app already exists (above), so
|
||||||
|
-- there is exactly one row to look up.
|
||||||
|
ALTER TABLE scripts ADD COLUMN app_id UUID;
|
||||||
|
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
|
||||||
|
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- Per-app name uniqueness. Two apps can each have a script called
|
||||||
|
-- "echo"; previously they could not.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
|
||||||
|
|
||||||
|
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to routes, mirroring the script's app.
|
||||||
|
ALTER TABLE routes ADD COLUMN app_id UUID;
|
||||||
|
UPDATE routes
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE routes.script_id = scripts.id;
|
||||||
|
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Replace the route uniqueness index so two apps can claim identical
|
||||||
|
-- (host_kind, host, path_kind, path, method) tuples — they live in
|
||||||
|
-- separate route trees and never see each other.
|
||||||
|
DROP INDEX routes_unique_binding_idx;
|
||||||
|
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||||
|
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||||
|
|
||||||
|
CREATE INDEX routes_app_id_idx ON routes (app_id);
|
||||||
|
|
||||||
|
-- Add app_id to execution_logs. Materialized at write time so future
|
||||||
|
-- script-moves (or eventual export/import) don't silently retag history.
|
||||||
|
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
|
||||||
|
UPDATE execution_logs
|
||||||
|
SET app_id = scripts.app_id
|
||||||
|
FROM scripts
|
||||||
|
WHERE execution_logs.script_id = scripts.id;
|
||||||
|
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX execution_logs_app_id_created_at_idx
|
||||||
|
ON execution_logs (app_id, created_at DESC);
|
||||||
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
112
crates/manager-core/migrations/0006_users_authz.sql
Normal 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()
|
||||||
|
-- );
|
||||||
15
crates/manager-core/seeds/hello.rhai
Normal file
15
crates/manager-core/seeds/hello.rhai
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Hello World — the reference example seeded into the default app on
|
||||||
|
// fresh installs. Bound to GET /hello.
|
||||||
|
|
||||||
|
let who = ctx.request.body;
|
||||||
|
let name = if who != () && type_of(who) == "map" && who.contains("name") {
|
||||||
|
who.name
|
||||||
|
} else {
|
||||||
|
"world"
|
||||||
|
};
|
||||||
|
|
||||||
|
return #{
|
||||||
|
statusCode: 200,
|
||||||
|
headers: #{ "Content-Type": "application/json" },
|
||||||
|
body: #{ message: `Hello, ${name}!` }
|
||||||
|
};
|
||||||
@@ -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,14 @@ 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.
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
async fn update_username(
|
async fn update_username(
|
||||||
&self,
|
&self,
|
||||||
@@ -73,6 +86,14 @@ pub trait AdminUserRepository: Send + Sync {
|
|||||||
id: AdminUserId,
|
id: AdminUserId,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
) -> 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 +111,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 +137,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 +152,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,42 +167,46 @@ 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,
|
||||||
) -> 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) \
|
||||||
VALUES ($1, $2) \
|
VALUES ($1, $2, $3) \
|
||||||
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())
|
||||||
.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() => Err(
|
||||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
),
|
),
|
||||||
@@ -186,7 +222,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 +231,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 +248,34 @@ 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_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 +286,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 +335,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 +369,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 +400,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),
|
||||||
|
)?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_create_role() -> InstanceRole {
|
||||||
|
InstanceRole::Admin
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
@@ -84,6 +106,7 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -92,15 +115,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 +149,49 @@ 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 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)
|
||||||
|
.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 +216,26 @@ async fn patch_admin(
|
|||||||
// for the initial cut.)
|
// for the initial cut.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(new_role) = input.instance_role {
|
||||||
|
// Self-elevation guard: only an owner can promote anyone TO
|
||||||
|
// owner. An admin cannot turn themselves (or anyone else)
|
||||||
|
// into one.
|
||||||
|
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
|
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||||
|
// leave the install with zero owners. The check is on the
|
||||||
|
// source role (current.instance_role) so demoting an
|
||||||
|
// already-non-owner is always fine.
|
||||||
|
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
|
||||||
|
let remaining = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.update_instance_role(id, new_role).await?);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(new_active) = input.is_active {
|
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 +243,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 +293,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 +312,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +376,18 @@ pub enum AdminApiError {
|
|||||||
#[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 +395,39 @@ 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::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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,20 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
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::{
|
||||||
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::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
|||||||
pub struct AdminState<R, L> {
|
pub struct AdminState<R, L> {
|
||||||
pub repo: Arc<R>,
|
pub repo: Arc<R>,
|
||||||
pub logs: Arc<L>,
|
pub logs: Arc<L>,
|
||||||
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||||
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
/// Phase 3.5 capability checks — every script handler resolves
|
||||||
|
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||||
|
/// loading the resource.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
Self {
|
Self {
|
||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.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,
|
||||||
}
|
}
|
||||||
@@ -70,6 +82,9 @@ where
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateScriptRequest {
|
pub struct CreateScriptRequest {
|
||||||
|
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||||
|
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
|
|||||||
pub sandbox: ScriptSandbox,
|
pub sandbox: ScriptSandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListScriptsQuery {
|
||||||
|
/// Optional filter: list scripts belonging to a single app, by id
|
||||||
|
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||||
|
#[serde(default)]
|
||||||
|
pub app: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateScriptRequest {
|
pub struct UpdateScriptRequest {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
@@ -113,31 +136,83 @@ 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>,
|
||||||
) -> 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 {
|
||||||
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
|
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||||
|
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||||
|
}
|
||||||
|
if principal.instance_role == InstanceRole::Member {
|
||||||
|
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||||
|
}
|
||||||
Ok(Json(state.repo.list().await?))
|
Ok(Json(state.repo.list().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||||
|
/// for redirects, but here we just need the live current id; if a
|
||||||
|
/// retired slug is given, we follow it to the current app silently.
|
||||||
|
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
let id = AppId::from(uuid);
|
||||||
|
apps.get_by_id(id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let lookup = apps
|
||||||
|
.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||||
|
Ok(lookup.app.id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
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
|
||||||
|
// raw FK violation surfacing as 500.
|
||||||
|
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||||
|
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||||
|
}
|
||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
|
app_id: input.app_id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -155,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)?;
|
||||||
}
|
}
|
||||||
@@ -183,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)
|
||||||
}
|
}
|
||||||
@@ -203,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);
|
||||||
@@ -223,6 +322,9 @@ pub enum ApiError {
|
|||||||
#[error("script not found: {0}")]
|
#[error("script not found: {0}")]
|
||||||
NotFound(ScriptId),
|
NotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
@@ -232,18 +334,42 @@ 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 {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
|||||||
292
crates/manager-core/src/api_key_repo.rs
Normal file
292
crates/manager-core/src/api_key_repo.rs
Normal 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()
|
||||||
|
}
|
||||||
251
crates/manager-core/src/api_keys_api.rs
Normal file
251
crates/manager-core/src/api_keys_api.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
92
crates/manager-core/src/app_bootstrap.rs
Normal file
92
crates/manager-core/src/app_bootstrap.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Hello-World seed for fresh installs.
|
||||||
|
//!
|
||||||
|
//! Idempotent. Runs after migrations and after admin bootstrap. Only
|
||||||
|
//! seeds when the default app is empty (no scripts, no routes); on
|
||||||
|
//! upgrades it does nothing so existing content isn't polluted.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||||
|
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||||
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
|
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HelloWorldOutcome {
|
||||||
|
/// Default app already has scripts (or doesn't exist) — left alone.
|
||||||
|
SkippedExisting,
|
||||||
|
/// Inserted the hello.rhai script and the `/hello` route.
|
||||||
|
Seeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum SeedError {
|
||||||
|
#[error("default app not found — did the migration run?")]
|
||||||
|
MissingDefaultApp,
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seed_hello_world_if_fresh(
|
||||||
|
apps: Arc<dyn AppRepository>,
|
||||||
|
scripts: Arc<dyn ScriptRepository>,
|
||||||
|
routes: Arc<dyn RouteRepository>,
|
||||||
|
) -> Result<HelloWorldOutcome, SeedError> {
|
||||||
|
let default = apps
|
||||||
|
.get_by_slug("default")
|
||||||
|
.await?
|
||||||
|
.ok_or(SeedError::MissingDefaultApp)?;
|
||||||
|
|
||||||
|
// Idempotence: only seed when both scripts AND routes are empty.
|
||||||
|
// (Either alone is suspicious enough to skip — the operator may have
|
||||||
|
// already started shaping the default app.)
|
||||||
|
let existing_scripts = scripts.list_for_app(default.id).await?;
|
||||||
|
let existing_routes = routes.list_for_app(default.id).await?;
|
||||||
|
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
|
||||||
|
return Ok(HelloWorldOutcome::SkippedExisting);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_into(&*scripts, &*routes, &default).await?;
|
||||||
|
Ok(HelloWorldOutcome::Seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_into(
|
||||||
|
scripts: &dyn ScriptRepository,
|
||||||
|
routes: &dyn RouteRepository,
|
||||||
|
default: &App,
|
||||||
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let script = scripts
|
||||||
|
.create(NewScript {
|
||||||
|
app_id: default.id,
|
||||||
|
name: "hello".to_string(),
|
||||||
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
|
timeout_seconds: Some(5),
|
||||||
|
memory_limit_mb: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
routes
|
||||||
|
.create(NewRoute {
|
||||||
|
app_id: default.id,
|
||||||
|
script_id: script.id,
|
||||||
|
host_kind: HostKind::Any,
|
||||||
|
host: String::new(),
|
||||||
|
host_param_name: None,
|
||||||
|
path_kind: PathKind::Exact,
|
||||||
|
path: "/hello".to_string(),
|
||||||
|
// Accept any method so both `curl /hello` and
|
||||||
|
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||||
|
method: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled
|
||||||
152
crates/manager-core/src/app_domain_repo.rs
Normal file
152
crates/manager-core/src/app_domain_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! CRUD over the `app_domains` table.
|
||||||
|
//!
|
||||||
|
//! Parsing + shape_key derivation live in `orchestrator-core`'s
|
||||||
|
//! `routing::pattern::parse_app_domain` — this repo just stores what
|
||||||
|
//! the API handler hands it. Same-shape collisions surface as a unique
|
||||||
|
//! constraint violation on `shape_key`, mapped here to a clean error.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppDomain, AppId, DomainShape};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppDomainRepository: Send + Sync {
|
||||||
|
/// All domain claims across all apps — used by the orchestrator's
|
||||||
|
/// `AppDomainTable` to build its lookup cache at startup and after
|
||||||
|
/// every write.
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppDomainRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppDomainRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppDomainRepository for PostgresAppDomainRepository {
|
||||||
|
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||||
|
FROM app_domains WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(domain_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, DomainRow>(
|
||||||
|
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
|
||||||
|
VALUES ($1, $2, $3, $4) \
|
||||||
|
RETURNING id, app_id, pattern, shape, shape_key, created_at",
|
||||||
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
|
.bind(&input.pattern)
|
||||||
|
.bind(shape_str(input.shape))
|
||||||
|
.bind(&input.shape_key)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {:?} (or another claim of the same shape) is already claimed",
|
||||||
|
input.pattern
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
|
||||||
|
.bind(domain_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"domain {domain_id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn shape_str(s: DomainShape) -> &'static str {
|
||||||
|
match s {
|
||||||
|
DomainShape::Exact => "exact",
|
||||||
|
DomainShape::Wildcard => "wildcard",
|
||||||
|
DomainShape::Parameterized => "parameterized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DomainRow {
|
||||||
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
|
pattern: String,
|
||||||
|
shape: String,
|
||||||
|
shape_key: String,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DomainRow> for AppDomain {
|
||||||
|
fn from(r: DomainRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
|
pattern: r.pattern,
|
||||||
|
shape: match r.shape.as_str() {
|
||||||
|
"wildcard" => DomainShape::Wildcard,
|
||||||
|
"parameterized" => DomainShape::Parameterized,
|
||||||
|
_ => DomainShape::Exact,
|
||||||
|
},
|
||||||
|
shape_key: r.shape_key,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
crates/manager-core/src/app_members_repo.rs
Normal file
212
crates/manager-core/src/app_members_repo.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
423
crates/manager-core/src/app_repo.rs
Normal file
423
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||||
|
//!
|
||||||
|
//! Slug validation (regex, reserved-word check) lives in the API
|
||||||
|
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||||
|
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||||
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AdminUserId, App, AppId};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
/// Result of looking up an app by slug or via the redirect history.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppLookup {
|
||||||
|
pub app: App,
|
||||||
|
/// `true` when the slug was found in `app_slug_history` rather than
|
||||||
|
/// directly on `apps`. Dashboards should issue a redirect.
|
||||||
|
pub redirected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AppRepository: Send + Sync {
|
||||||
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
/// users go through `list_for_user`.
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Create that also consumes a matching `app_slug_history` row, if
|
||||||
|
/// any. Used after the operator has confirmed they want to break old
|
||||||
|
/// redirects.
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
/// Rename and record the old slug in `app_slug_history` (so
|
||||||
|
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||||
|
/// any existing history row for `new_slug` is consumed.
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError>;
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
/// Delete the app along with all its scripts (which in turn cascades
|
||||||
|
/// routes and execution logs via their `script_id` FK). Domains and
|
||||||
|
/// app-slug-history rows cascade off the app row itself. Runs in a
|
||||||
|
/// single transaction so a partial delete cannot be observed.
|
||||||
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PostgresAppRepository {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresAppRepository {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AppRepository for PostgresAppRepository {
|
||||||
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps ORDER BY name",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM apps a \
|
||||||
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_slug_or_history(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Some(app) = self.get_by_slug(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = self.slug_in_history(slug).await? {
|
||||||
|
return Ok(Some(AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM app_slug_history h \
|
||||||
|
JOIN apps a ON a.id = h.current_app_id \
|
||||||
|
WHERE h.slug = $1",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_with_takeover(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"INSERT INTO apps (slug, name, description) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {slug:?} is already in use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<Option<&str>>,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET \
|
||||||
|
name = COALESCE($2, name), \
|
||||||
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.bind(description.is_some())
|
||||||
|
.bind(description.and_then(|d| d))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(Into::into)
|
||||||
|
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_slug(
|
||||||
|
&self,
|
||||||
|
id: AppId,
|
||||||
|
new_slug: &str,
|
||||||
|
take_over_history: bool,
|
||||||
|
) -> Result<App, ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
|
||||||
|
// 1. Read the current slug (so we can record it in history).
|
||||||
|
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let Some((current_slug,)) = current else {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if current_slug == new_slug {
|
||||||
|
// No-op rename; just return the row.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
FROM apps WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
return Ok(row.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If renaming back to this app's own retired slug, just
|
||||||
|
// consume the history row silently (no warning, no takeover
|
||||||
|
// flag required).
|
||||||
|
let owns_history: Option<(uuid::Uuid,)> =
|
||||||
|
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match owns_history {
|
||||||
|
Some((owner,)) if owner == id.into_inner() => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) if take_over_history => {
|
||||||
|
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||||
|
.bind(new_slug)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record the current slug in history (replacing any older
|
||||||
|
// entry — the same slug can pass through history multiple
|
||||||
|
// times across many renames).
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||||
|
VALUES ($1, $2) \
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||||
|
retired_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(¤t_slug)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Apply the rename. Unique violation = another live app
|
||||||
|
// already holds this slug.
|
||||||
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
|
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, slug, name, description, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(new_slug)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
let row = match row {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"slug {new_slug:?} is already in use by another app"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(row.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
))),
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||||
|
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||||
|
// "has dependents" error rather than a raw SQL message.
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"app still contains scripts; delete or move them first".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"app {id} not found"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppRow> for App {
|
||||||
|
fn from(r: AppRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.into(),
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
598
crates/manager-core/src/apps_api.rs
Normal file
598
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||||
|
//!
|
||||||
|
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||||
|
//! are deferred (every authenticated admin can act on every app); the
|
||||||
|
//! middleware seam exists for when that lands.
|
||||||
|
//!
|
||||||
|
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||||
|
//! list rejected. Slug renames record the old slug in
|
||||||
|
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||||
|
//! historical slug requires `"force_takeover": true` in the request.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{delete, get, post};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
|
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
|
const SLUG_MIN: usize = 1;
|
||||||
|
const SLUG_MAX: usize = 63;
|
||||||
|
const RESERVED_SLUGS: &[&str] = &[
|
||||||
|
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppsState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
|
pub routes: Arc<dyn RouteRepository>,
|
||||||
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
|
/// operation so the orchestrator sees changes immediately.
|
||||||
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/apps", get(list_apps).post(create_app))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}",
|
||||||
|
get(get_app).patch(patch_app).delete(delete_app),
|
||||||
|
)
|
||||||
|
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains",
|
||||||
|
get(list_domains).post(create_domain),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||||
|
delete(delete_domain),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppDto {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateAppRequest {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||||
|
/// the requested slug (breaking old redirects).
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchAppRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
pub description: Option<Option<String>>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_takeover: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<String>::deserialize(d).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SlugCheckRequest {
|
||||||
|
pub new_slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlugCheckResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
pub conflict_kind: Option<&'static str>,
|
||||||
|
pub current_app: Option<App>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateDomainRequest {
|
||||||
|
pub pattern: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
|
||||||
|
/// a cascading delete that also removes every script in the app (and
|
||||||
|
/// thereby their routes and execution logs). Without it the request is
|
||||||
|
/// rejected when the app still contains scripts.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct DeleteAppQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppLookupResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub app: App,
|
||||||
|
/// When the operator hits the API with a retired slug, this points
|
||||||
|
/// at the live slug so dashboards can redirect.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub redirect_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_apps(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
// Member callers see only apps they're a member of; owner/admin
|
||||||
|
// see everything. Filter at the SQL layer (not just in the
|
||||||
|
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||||
|
let apps = if principal.instance_role == InstanceRole::Member {
|
||||||
|
s.apps.list_for_user(principal.user_id).await?
|
||||||
|
} else {
|
||||||
|
s.apps.list().await?
|
||||||
|
};
|
||||||
|
Ok(Json(apps))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Json(input): Json<CreateAppRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
|
// Historical-slug check before insert: if the slug is in history
|
||||||
|
// and the caller hasn't asked to force takeover, surface a clean
|
||||||
|
// 409 so the dashboard can present a "this will break old links"
|
||||||
|
// confirmation.
|
||||||
|
if !input.force_takeover {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = if input.force_takeover {
|
||||||
|
s.apps
|
||||||
|
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
s.apps
|
||||||
|
.create(&input.slug, &input.name, input.description.as_deref())
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(lookup.app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let redirect_to = if lookup.redirected {
|
||||||
|
Some(lookup.app.slug.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(Json(AppLookupResponse {
|
||||||
|
app: lookup.app,
|
||||||
|
redirect_to,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<PatchAppRequest>,
|
||||||
|
) -> Result<Json<App>, AppsApiError> {
|
||||||
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppAdmin(current.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Edits to name/description go first (separate from rename so we
|
||||||
|
// don't conflate the two errors).
|
||||||
|
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||||
|
s.apps
|
||||||
|
.update(
|
||||||
|
current.id,
|
||||||
|
input.name.as_deref(),
|
||||||
|
input.description.as_ref().map(|d| d.as_deref()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slug rename is a separate operation; the rename method does its
|
||||||
|
// own history bookkeeping in a transaction.
|
||||||
|
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||||
|
validate_slug(new_slug)?;
|
||||||
|
match s
|
||||||
|
.apps
|
||||||
|
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(app) => app,
|
||||||
|
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||||
|
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||||
|
return Err(AppsApiError::SlugInHistory(current));
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::Conflict(msg));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
after_meta
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(after_rename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_app(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Query(q): Query<DeleteAppQuery>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
if q.force {
|
||||||
|
s.apps.delete_cascade(app.id).await?;
|
||||||
|
} else {
|
||||||
|
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||||
|
// (ON DELETE RESTRICT on scripts.app_id).
|
||||||
|
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||||
|
if n_scripts > 0 {
|
||||||
|
return Err(AppsApiError::HasScripts(n_scripts));
|
||||||
|
}
|
||||||
|
s.apps.delete(app.id).await?;
|
||||||
|
}
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn slug_check(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<SlugCheckRequest>,
|
||||||
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
match validate_slug(&input.new_slug) {
|
||||||
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("invalid"),
|
||||||
|
current_app: None,
|
||||||
|
reason: Some(reason),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(other) => return Err(other),
|
||||||
|
Ok(()) => {}
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("current"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("another app currently uses this slug".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||||
|
return Ok(Json(SlugCheckResponse {
|
||||||
|
ok: false,
|
||||||
|
conflict_kind: Some("historical"),
|
||||||
|
current_app: Some(app),
|
||||||
|
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(SlugCheckResponse {
|
||||||
|
ok: true,
|
||||||
|
conflict_kind: None,
|
||||||
|
current_app: None,
|
||||||
|
reason: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_domains(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||||
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<CreateDomainRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||||
|
let created = s
|
||||||
|
.domains
|
||||||
|
.create(NewAppDomain {
|
||||||
|
app_id: app.id,
|
||||||
|
pattern: input.pattern,
|
||||||
|
shape: parsed.shape,
|
||||||
|
shape_key: parsed.shape_key,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_domain(
|
||||||
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
};
|
||||||
|
if domain.app_id != app.id {
|
||||||
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: routes inside this app may reference this exact host
|
||||||
|
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||||
|
// (Any routes don't pin a specific host). We block deletion in
|
||||||
|
// either case and let the operator clean up first.
|
||||||
|
let strict = s
|
||||||
|
.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||||
|
.await?;
|
||||||
|
let wild_suffix = domain
|
||||||
|
.pattern
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let wild = if wild_suffix.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
s.routes
|
||||||
|
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if strict + wild > 0 {
|
||||||
|
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||||
|
}
|
||||||
|
|
||||||
|
s.domains.delete(domain_id).await?;
|
||||||
|
refresh_domain_cache(&s).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
||||||
|
return Ok(crate::app_repo::AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||||
|
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !slug
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug must start with [a-z0-9]".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for c in slug.chars() {
|
||||||
|
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||||
|
return Err(AppsApiError::InvalidSlug(
|
||||||
|
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if RESERVED_SLUGS.contains(&slug) {
|
||||||
|
return Err(AppsApiError::InvalidSlug(format!(
|
||||||
|
"slug {slug:?} is reserved for system use"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||||
|
/// Called after every domain CRUD operation.
|
||||||
|
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||||
|
let all = state.domains.list_all().await?;
|
||||||
|
let compiled = all
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
// Parse the stored pattern; skip on parse error rather than
|
||||||
|
// poisoning the entire cache. The handlers reject bad input,
|
||||||
|
// so this is purely defensive against a future migration
|
||||||
|
// that loosens the constraints.
|
||||||
|
pattern::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
state.domain_table.replace(compiled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppsApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("domain not found: {0}")]
|
||||||
|
DomainNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("invalid slug: {0}")]
|
||||||
|
InvalidSlug(String),
|
||||||
|
|
||||||
|
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||||
|
SlugInHistory(App),
|
||||||
|
|
||||||
|
#[error("app still contains {0} script(s); delete or move them first")]
|
||||||
|
HasScripts(i64),
|
||||||
|
|
||||||
|
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||||
|
DomainHasRoutes(i64),
|
||||||
|
|
||||||
|
#[error("invalid pattern: {0}")]
|
||||||
|
Pattern(#[from] pattern::ParseError),
|
||||||
|
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Repo(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppsApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::DomainNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::SlugInHistory(current) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"conflict_kind": "historical",
|
||||||
|
"current_app": current,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Self::HasScripts(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "script_count": n }),
|
||||||
|
),
|
||||||
|
Self::DomainHasRoutes(n) => (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
json!({ "error": self.to_string(), "route_count": n }),
|
||||||
|
),
|
||||||
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "apps authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps api db error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use picloud_shared::AdminUserId;
|
|||||||
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))
|
||||||
@@ -158,11 +160,25 @@ 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,
|
||||||
})
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||||
|
internal_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -116,7 +116,15 @@ 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,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
info!(username = %username, "bootstrapped initial admin user");
|
info!(username = %username, "bootstrapped initial admin user");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -130,7 +138,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 +175,14 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
_password_hash: &str,
|
_password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
) -> 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: None,
|
||||||
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 +204,13 @@ mod tests {
|
|||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
unimplemented!()
|
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 +233,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 +272,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)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let env = BootstrapEnv {
|
let env = BootstrapEnv {
|
||||||
username: Some("alice".into()),
|
username: Some("alice".into()),
|
||||||
password: Some("supersecret".into()),
|
password: Some("supersecret".into()),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
599
crates/manager-core/src/authz.rs
Normal file
599
crates/manager-core/src/authz.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +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_domain_repo;
|
||||||
|
pub mod app_members_repo;
|
||||||
|
pub mod app_repo;
|
||||||
|
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;
|
||||||
@@ -30,11 +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_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
|
||||||
|
};
|
||||||
|
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
||||||
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_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,
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO execution_logs ( \
|
"INSERT INTO execution_logs ( \
|
||||||
id, script_id, request_id, \
|
id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
) VALUES ( \
|
) VALUES ( \
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(log.id)
|
.bind(log.id)
|
||||||
|
.bind(log.app_id.into_inner())
|
||||||
.bind(log.script_id.into_inner())
|
.bind(log.script_id.into_inner())
|
||||||
.bind(log.request_id.into_inner())
|
.bind(log.request_id.into_inner())
|
||||||
.bind(&log.request_path)
|
.bind(&log.request_path)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ 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::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
use picloud_shared::{
|
||||||
|
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -21,7 +23,18 @@ pub enum ScriptRepositoryError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ScriptRepository: Send + Sync {
|
pub trait ScriptRepository: Send + Sync {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script across all apps. Mostly for tests and admin
|
||||||
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script in any app the user is a member of. Drives
|
||||||
|
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||||
|
/// API never returns scripts they shouldn't see — even before the
|
||||||
|
/// per-handler capability check fires.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -35,6 +48,7 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
/// constraints; the repo enforces them in the DB regardless.
|
/// constraints; the repo enforces them in the DB regardless.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewScript {
|
pub struct NewScript {
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -78,7 +92,7 @@ impl PostgresScriptRepository {
|
|||||||
impl ScriptRepository for PostgresScriptRepository {
|
impl ScriptRepository for PostgresScriptRepository {
|
||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts WHERE id = $1",
|
FROM scripts WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -90,7 +104,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
|
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts ORDER BY name",
|
FROM scripts ORDER BY name",
|
||||||
)
|
)
|
||||||
@@ -99,17 +113,48 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT id, app_id, name, description, version, source, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
|
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||||
|
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||||
|
FROM scripts s \
|
||||||
|
JOIN app_members m ON m.app_id = s.app_id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY s.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
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!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"INSERT INTO scripts ( \
|
"INSERT INTO scripts ( \
|
||||||
name, description, source, \
|
app_id, name, description, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox \
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
@@ -123,7 +168,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(row) => Ok(row.into()),
|
Ok(row) => Ok(row.into()),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
Err(ScriptRepositoryError::Conflict(format!(
|
Err(ScriptRepositoryError::Conflict(format!(
|
||||||
"a script named {:?} already exists",
|
"a script named {:?} already exists in this app",
|
||||||
input.name
|
input.name
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -141,12 +186,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||||
// Sandbox is replaced wholesale when present; per-field merging
|
// Sandbox is replaced wholesale when present; per-field merging
|
||||||
// happens in the API layer (clearer semantics for a "PUT a new
|
// happens in the API layer (clearer semantics for a "PUT a new
|
||||||
// sandbox config" call).
|
// sandbox config" call). app_id is immutable — moving a script
|
||||||
|
// to another app is a copy-and-delete, not an in-place edit.
|
||||||
let sandbox_json = patch
|
let sandbox_json = patch
|
||||||
.sandbox
|
.sandbox
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"UPDATE scripts SET \
|
"UPDATE scripts SET \
|
||||||
name = COALESCE($2, name), \
|
name = COALESCE($2, name), \
|
||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
@@ -157,7 +203,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
version = version + 1, \
|
version = version + 1, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, app_id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -169,10 +215,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.bind(patch.memory_limit_mb)
|
.bind(patch.memory_limit_mb)
|
||||||
.bind(sandbox_json)
|
.bind(sandbox_json)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
row.map(Into::into)
|
match res {
|
||||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
Ok(Some(row)) => Ok(row.into()),
|
||||||
|
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
Err(ScriptRepositoryError::Conflict(
|
||||||
|
"a script with that name already exists in this app".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||||
@@ -191,6 +245,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ScriptRow {
|
struct ScriptRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
version: i32,
|
version: i32,
|
||||||
@@ -211,6 +266,7 @@ impl From<ScriptRow> for Script {
|
|||||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
|
app_id: r.app_id.into(),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
@@ -284,7 +340,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||||
"SELECT id, script_id, request_id, \
|
"SELECT id, app_id, script_id, request_id, \
|
||||||
request_path, request_headers, request_body, \
|
request_path, request_headers, request_body, \
|
||||||
response_code, response_body, \
|
response_code, response_body, \
|
||||||
logs, duration_ms, status, created_at \
|
logs, duration_ms, status, created_at \
|
||||||
@@ -306,6 +362,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ExecutionLogRow {
|
struct ExecutionLogRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
|
app_id: uuid::Uuid,
|
||||||
script_id: uuid::Uuid,
|
script_id: uuid::Uuid,
|
||||||
request_id: uuid::Uuid,
|
request_id: uuid::Uuid,
|
||||||
request_path: Option<String>,
|
request_path: Option<String>,
|
||||||
@@ -331,6 +388,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
|||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
request_id: RequestId::from(r.request_id),
|
request_id: RequestId::from(r.request_id),
|
||||||
request_path: r.request_path.unwrap_or_default(),
|
request_path: r.request_path.unwrap_or_default(),
|
||||||
|
|||||||
@@ -10,42 +10,56 @@ 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::{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::repo::ScriptRepositoryError;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||||
use crate::route_repo::{NewRoute, RouteRepository};
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
pub struct RouteAdminState<RR> {
|
pub struct RouteAdminState<RR, SR> {
|
||||||
pub routes: Arc<RR>,
|
pub routes: Arc<RR>,
|
||||||
|
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||||
|
/// route inherits the script's app) and to scope conflict checks.
|
||||||
|
pub scripts: Arc<SR>,
|
||||||
|
/// Used to validate the route's host against the parent app's
|
||||||
|
/// declared domain claims.
|
||||||
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub table: Arc<RouteTable>,
|
pub table: Arc<RouteTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<RR> Clone for RouteAdminState<RR> {
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
|
scripts: self.scripts.clone(),
|
||||||
|
domains: self.domains.clone(),
|
||||||
table: self.table.clone(),
|
table: self.table.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||||
where
|
where
|
||||||
RR: RouteRepository + 'static,
|
RR: RouteRepository + 'static,
|
||||||
|
SR: ScriptRepository + 'static,
|
||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/scripts/{id}/routes",
|
"/scripts/{id}/routes",
|
||||||
get(list_routes::<RR>).post(create_route::<RR>),
|
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||||
)
|
)
|
||||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||||
.route("/routes:check", post(check_route::<RR>))
|
.route("/routes:check", post(check_route::<RR, SR>))
|
||||||
.route("/routes:match", post(match_route::<RR>))
|
.route("/routes:match", post(match_route::<RR, SR>))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +81,10 @@ pub struct CreateRouteRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CheckRouteRequest {
|
pub struct CheckRouteRequest {
|
||||||
|
/// Required: which app's route table this hypothetical route would
|
||||||
|
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||||
|
/// errors would leak tenant info — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -84,6 +102,9 @@ pub struct CheckRouteResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MatchRouteRequest {
|
pub struct MatchRouteRequest {
|
||||||
|
/// Which app's route table to dispatch against. The dashboard's
|
||||||
|
/// route-preview tester always knows the current app context.
|
||||||
|
pub app_id: AppId,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default = "default_method")]
|
#[serde(default = "default_method")]
|
||||||
pub method: String,
|
pub method: String,
|
||||||
@@ -111,15 +132,28 @@ pub struct MatchedRoute {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_routes<RR: RouteRepository>(
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
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>(
|
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
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> {
|
||||||
@@ -130,8 +164,28 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
input.host_param_name.as_deref(),
|
input.host_param_name.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Within-kind conflict check against existing routes.
|
// Look up the script's owning app — every route inherits it.
|
||||||
let existing = state.routes.list_all().await?;
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
let app_id = script.app_id;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Validate the route's host is consistent with one of the app's
|
||||||
|
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||||
|
// host the app already owns). Specific hosts must match a claim.
|
||||||
|
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Within-app conflict check (cross-app is impossible by construction).
|
||||||
|
let existing = state.routes.list_for_app(app_id).await?;
|
||||||
if let Some((conflicting, reason)) = first_conflict(
|
if let Some((conflicting, reason)) = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -149,6 +203,7 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.routes
|
.routes
|
||||||
.create(NewRoute {
|
.create(NewRoute {
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
host_kind: input.host_kind,
|
host_kind: input.host_kind,
|
||||||
host: input.host,
|
host: input.host,
|
||||||
@@ -162,23 +217,47 @@ async fn create_route<RR: RouteRepository>(
|
|||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_route<RR: RouteRepository>(
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_route<RR: RouteRepository>(
|
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
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)?;
|
||||||
|
|
||||||
let existing = state.routes.list_all().await?;
|
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||||
let conflict = first_conflict(
|
let conflict = first_conflict(
|
||||||
&existing,
|
&existing,
|
||||||
input.host_kind,
|
input.host_kind,
|
||||||
@@ -201,16 +280,25 @@ async fn check_route<RR: RouteRepository>(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn match_route<RR: RouteRepository>(
|
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR>>,
|
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();
|
||||||
let path = parsed.path().to_string();
|
let path = parsed.path().to_string();
|
||||||
|
|
||||||
let result = state.table.match_request(&host, &input.method, &path);
|
let result = state
|
||||||
|
.table
|
||||||
|
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||||
Ok(Json(MatchRouteResponse {
|
Ok(Json(MatchRouteResponse {
|
||||||
matched: result.map(|r| MatchedRoute {
|
matched: result.map(|r| MatchedRoute {
|
||||||
route_id: r.matched.route_id,
|
route_id: r.matched.route_id,
|
||||||
@@ -263,12 +351,12 @@ fn first_conflict(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_table<RR: RouteRepository>(
|
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
state: &RouteAdminState<RR>,
|
state: &RouteAdminState<RR, SR>,
|
||||||
) -> Result<(), RouteApiError> {
|
) -> Result<(), RouteApiError> {
|
||||||
let rows = state.routes.list_all().await?;
|
let rows = state.routes.list_all().await?;
|
||||||
let compiled = compile_routes(&rows)?;
|
let compiled = compile_routes(&rows)?;
|
||||||
state.table.replace(compiled);
|
state.table.replace_all(compiled);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +365,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
Ok(CompiledRoute {
|
Ok(CompiledRoute {
|
||||||
route_id: r.id,
|
route_id: r.id,
|
||||||
|
app_id: r.app_id,
|
||||||
script_id: r.script_id,
|
script_id: r.script_id,
|
||||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||||
@@ -286,6 +375,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||||
|
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||||
|
/// always permitted — it catches every host the app already owns.
|
||||||
|
async fn validate_route_host_against_app(
|
||||||
|
domains: &dyn AppDomainRepository,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<(), RouteApiError> {
|
||||||
|
if matches!(host_kind, HostKind::Any) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let claims = domains.list_for_app(app_id).await?;
|
||||||
|
if claims.is_empty() {
|
||||||
|
return Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let host_lower = host.to_ascii_lowercase();
|
||||||
|
for claim in &claims {
|
||||||
|
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||||
|
match (host_kind, claim.shape) {
|
||||||
|
// Strict route under exact claim: must match exactly.
|
||||||
|
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||||
|
if host_lower == claim_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strict route under wildcard/parameterized: must end with
|
||||||
|
// ".<suffix>" where the claim's suffix is the part after
|
||||||
|
// `*.` or `{...}.`.
|
||||||
|
(
|
||||||
|
HostKind::Strict,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let needle = format!(".{suffix}");
|
||||||
|
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route: must match a wildcard or parameterized
|
||||||
|
// claim with identical suffix.
|
||||||
|
(
|
||||||
|
HostKind::Wildcard,
|
||||||
|
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||||
|
) => {
|
||||||
|
let claim_suffix = claim_lower
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, s)| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if claim_suffix == host_lower {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wildcard route under exact claim: not allowed (would
|
||||||
|
// shadow other apps' subdomains the operator didn't claim).
|
||||||
|
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||||
|
(HostKind::Any, _) => unreachable!("handled above"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(RouteApiError::HostNotClaimed {
|
||||||
|
host: host.to_string(),
|
||||||
|
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -304,10 +466,37 @@ pub enum RouteApiError {
|
|||||||
#[error("bad request: {0}")]
|
#[error("bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("script not found: {0}")]
|
||||||
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("route not found: {0}")]
|
||||||
|
RouteNotFound(Uuid),
|
||||||
|
|
||||||
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
|
HostNotClaimed {
|
||||||
|
host: String,
|
||||||
|
available_claims: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[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 {
|
||||||
@@ -326,10 +515,34 @@ 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::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 {
|
||||||
|
host,
|
||||||
|
available_claims,
|
||||||
|
} => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
serde_json::json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"host": host,
|
||||||
|
"available_claims": available_claims,
|
||||||
|
}),
|
||||||
|
),
|
||||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! CRUD over the `routes` table.
|
//! CRUD over the `routes` table.
|
||||||
//!
|
//!
|
||||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||||
//! every write — see the route_admin module for the binding.
|
//! after every write — see the route_admin module for the binding.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewRoute {
|
pub struct NewRoute {
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -24,12 +25,25 @@ 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_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||||
|
/// Count routes whose host_kind/host pair matches a pattern in
|
||||||
|
/// `app_id`. Used by the domain-claim delete guard.
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresRouteRepository {
|
pub struct PostgresRouteRepository {
|
||||||
@@ -47,7 +61,7 @@ impl PostgresRouteRepository {
|
|||||||
impl RouteRepository for PostgresRouteRepository {
|
impl RouteRepository for PostgresRouteRepository {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes ORDER BY created_at",
|
FROM routes ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -56,12 +70,36 @@ 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> {
|
||||||
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at \
|
path_kind, path, method, created_at \
|
||||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||||
)
|
)
|
||||||
@@ -74,12 +112,13 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, RouteRow>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"INSERT INTO routes ( \
|
||||||
script_id, host_kind, host, host_param_name, \
|
app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method \
|
path_kind, path, method \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, created_at",
|
path_kind, path, method, created_at",
|
||||||
)
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
.bind(input.script_id.into_inner())
|
.bind(input.script_id.into_inner())
|
||||||
.bind(host_kind_str(input.host_kind))
|
.bind(host_kind_str(input.host_kind))
|
||||||
.bind(&input.host)
|
.bind(&input.host)
|
||||||
@@ -112,6 +151,24 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_for_app_host(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host_kind: HostKind,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<i64, ScriptRepositoryError> {
|
||||||
|
let count: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM routes \
|
||||||
|
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(host_kind_str(host_kind))
|
||||||
|
.bind(host)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||||
@@ -133,6 +190,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
app_id: Uuid,
|
||||||
script_id: Uuid,
|
script_id: Uuid,
|
||||||
host_kind: String,
|
host_kind: String,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -147,6 +205,7 @@ impl From<RouteRow> for Route {
|
|||||||
fn from(r: RouteRow) -> Self {
|
fn from(r: RouteRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
app_id: r.app_id.into(),
|
||||||
script_id: r.script_id.into(),
|
script_id: r.script_id.into(),
|
||||||
host_kind: match r.host_kind.as_str() {
|
host_kind: match r.host_kind.as_str() {
|
||||||
"strict" => HostKind::Strict,
|
"strict" => HostKind::Strict,
|
||||||
|
|||||||
@@ -3,6 +3,64 @@
|
|||||||
|
|
||||||
## tables
|
## tables
|
||||||
|
|
||||||
|
table: admin_sessions
|
||||||
|
token_hash: text NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
expires_at: timestamp with time zone NOT NULL
|
||||||
|
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: admin_users
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
username: text NOT NULL
|
||||||
|
password_hash: text NOT NULL
|
||||||
|
is_active: boolean NOT NULL default=true
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
last_login_at: timestamp with time zone NULL
|
||||||
|
instance_role: text NOT NULL default='owner'::text
|
||||||
|
email: text NULL
|
||||||
|
mfa_secret: text NULL
|
||||||
|
|
||||||
|
table: api_keys
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
hash: text NOT NULL
|
||||||
|
prefix: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
scopes: ARRAY NOT NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
expires_at: timestamp with time zone NULL
|
||||||
|
last_used_at: timestamp with time zone NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_domains
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
pattern: text NOT NULL
|
||||||
|
shape: text NOT NULL
|
||||||
|
shape_key: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_members
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
role: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_slug_history
|
||||||
|
slug: text NOT NULL
|
||||||
|
current_app_id: uuid NOT NULL
|
||||||
|
retired_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: apps
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
slug: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
description: text NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: execution_logs
|
table: execution_logs
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
script_id: uuid NOT NULL
|
script_id: uuid NOT NULL
|
||||||
@@ -16,6 +74,7 @@ table: execution_logs
|
|||||||
duration_ms: integer NOT NULL default=0
|
duration_ms: integer NOT NULL default=0
|
||||||
status: text NOT NULL
|
status: text NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: routes
|
table: routes
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -27,6 +86,7 @@ table: routes
|
|||||||
path: text NOT NULL
|
path: text NOT NULL
|
||||||
method: text NULL
|
method: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
table: scripts
|
table: scripts
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -39,42 +99,119 @@ table: scripts
|
|||||||
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()
|
||||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
|
||||||
## indexes
|
## indexes
|
||||||
|
|
||||||
|
indexes on admin_sessions:
|
||||||
|
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||||
|
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||||
|
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on admin_users:
|
||||||
|
admin_users_email_key: public.admin_users USING btree (email)
|
||||||
|
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
|
||||||
|
admin_users_pkey: public.admin_users USING btree (id)
|
||||||
|
admin_users_username_key: public.admin_users USING btree (username)
|
||||||
|
|
||||||
|
indexes on api_keys:
|
||||||
|
api_keys_pkey: public.api_keys USING btree (id)
|
||||||
|
api_keys_prefix_idx: public.api_keys USING btree (prefix)
|
||||||
|
api_keys_user_id_idx: public.api_keys USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on app_domains:
|
||||||
|
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||||
|
app_domains_pkey: public.app_domains USING btree (id)
|
||||||
|
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||||
|
|
||||||
|
indexes on app_members:
|
||||||
|
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||||
|
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on app_slug_history:
|
||||||
|
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||||
|
|
||||||
|
indexes on apps:
|
||||||
|
apps_pkey: public.apps USING btree (id)
|
||||||
|
apps_slug_key: public.apps USING btree (slug)
|
||||||
|
|
||||||
indexes on execution_logs:
|
indexes on execution_logs:
|
||||||
|
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||||
|
|
||||||
indexes on routes:
|
indexes on routes:
|
||||||
|
routes_app_id_idx: public.routes USING btree (app_id)
|
||||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||||
routes_pkey: public.routes USING btree (id)
|
routes_pkey: public.routes USING btree (id)
|
||||||
routes_script_id_idx: public.routes USING btree (script_id)
|
routes_script_id_idx: public.routes USING btree (script_id)
|
||||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||||
|
|
||||||
indexes on scripts:
|
indexes on scripts:
|
||||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||||
|
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||||
scripts_pkey: public.scripts USING btree (id)
|
scripts_pkey: public.scripts USING btree (id)
|
||||||
|
|
||||||
## constraints
|
## constraints
|
||||||
|
|
||||||
|
constraints on admin_sessions:
|
||||||
|
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||||
|
|
||||||
|
constraints on admin_users:
|
||||||
|
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
|
||||||
|
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] admin_users_email_key: UNIQUE (email)
|
||||||
|
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||||
|
|
||||||
|
constraints on api_keys:
|
||||||
|
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
|
constraints on app_domains:
|
||||||
|
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||||
|
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||||
|
|
||||||
|
constraints on app_members:
|
||||||
|
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||||
|
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||||
|
|
||||||
|
constraints on app_slug_history:
|
||||||
|
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||||
|
|
||||||
|
constraints on apps:
|
||||||
|
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
constraints on execution_logs:
|
constraints on execution_logs:
|
||||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||||
|
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on routes:
|
constraints on routes:
|
||||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||||
|
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on scripts:
|
constraints on scripts:
|
||||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||||
|
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
## applied migrations
|
## applied migrations
|
||||||
0001: init
|
0001: init
|
||||||
0002: sandbox
|
0002: sandbox
|
||||||
0003: routes
|
0003: routes
|
||||||
|
0004: admin auth
|
||||||
|
0005: apps
|
||||||
|
0006: users authz
|
||||||
|
|||||||
@@ -17,22 +17,26 @@ use axum::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||||
};
|
};
|
||||||
use serde_json::Value as Json_;
|
use serde_json::Value as Json_;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::client::ExecutorClient;
|
use crate::client::ExecutorClient;
|
||||||
use crate::resolver::{ResolverError, ScriptResolver};
|
use crate::resolver::{ResolverError, ScriptResolver};
|
||||||
use crate::routing::RouteTable;
|
use crate::routing::{AppDomainTable, RouteTable};
|
||||||
|
|
||||||
/// State shared by data-plane handlers.
|
/// State shared by data-plane handlers.
|
||||||
pub struct DataPlaneState<E, R> {
|
pub struct DataPlaneState<E, R> {
|
||||||
pub executor: Arc<E>,
|
pub executor: Arc<E>,
|
||||||
pub resolver: Arc<R>,
|
pub resolver: Arc<R>,
|
||||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||||
/// Routing table for user-defined paths. Shared with the manager
|
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||||
/// (admin router writes; this side reads).
|
/// owning app's slice. Shared with the manager (writes invalidate
|
||||||
|
/// the cache by replacing the table).
|
||||||
|
pub app_domains: Arc<AppDomainTable>,
|
||||||
|
/// Routing table for user-defined paths, partitioned per app.
|
||||||
|
/// Shared with the manager (admin router writes; this side reads).
|
||||||
pub routes: Arc<RouteTable>,
|
pub routes: Arc<RouteTable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
|||||||
executor: self.executor.clone(),
|
executor: self.executor.clone(),
|
||||||
resolver: self.resolver.clone(),
|
resolver: self.resolver.clone(),
|
||||||
log_sink: self.log_sink.clone(),
|
log_sink: self.log_sink.clone(),
|
||||||
|
app_domains: self.app_domains.clone(),
|
||||||
routes: self.routes.clone(),
|
routes: self.routes.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,7 @@ where
|
|||||||
// audit-visible platform — but a sink failure must not mask the
|
// audit-visible platform — but a sink failure must not mask the
|
||||||
// user-facing result, so we only log a warning if it fails.
|
// user-facing result, so we only log a warning if it fails.
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
id,
|
id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -145,7 +151,23 @@ where
|
|||||||
.to_string();
|
.to_string();
|
||||||
let headers = request.headers().clone();
|
let headers = request.headers().clone();
|
||||||
|
|
||||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||||
|
// then run the existing matcher on that app's slice. No app claims
|
||||||
|
// this host → flat 404; the path doesn't get the chance to fire.
|
||||||
|
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": format!("no app claims host {host:?}")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(matched) = state
|
||||||
|
.routes
|
||||||
|
.match_request_for_app(app_id, &host, &method, &path)
|
||||||
|
else {
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -191,6 +213,7 @@ where
|
|||||||
let finished = Utc::now();
|
let finished = Utc::now();
|
||||||
|
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
|
script.app_id,
|
||||||
matched.matched.script_id,
|
matched.matched.script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_execution_log(
|
fn build_execution_log(
|
||||||
|
app_id: AppId,
|
||||||
script_id: ScriptId,
|
script_id: ScriptId,
|
||||||
request_id: RequestId,
|
request_id: RequestId,
|
||||||
request_path: String,
|
request_path: String,
|
||||||
@@ -336,6 +360,7 @@ fn build_execution_log(
|
|||||||
|
|
||||||
ExecutionLog {
|
ExecutionLog {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
|
app_id,
|
||||||
script_id,
|
script_id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
|
|||||||
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Host → app_id resolver. The first phase of the orchestrator's
|
||||||
|
//! two-phase dispatch (the second phase is the per-app route matcher
|
||||||
|
//! in `routing::table::RouteTable`).
|
||||||
|
//!
|
||||||
|
//! Cached in memory; the manager rebuilds the table after each
|
||||||
|
//! domain-claim CRUD operation (same pattern as `RouteTable`).
|
||||||
|
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
|
use super::pattern::{HostPattern, HostSpecificity};
|
||||||
|
|
||||||
|
/// A parsed domain claim ready for runtime matching.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompiledAppDomain {
|
||||||
|
pub app_id: AppId,
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppDomainTable {
|
||||||
|
inner: RwLock<Vec<CompiledAppDomain>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppDomainTable {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic full replacement; called at startup and after every
|
||||||
|
/// domain CRUD operation.
|
||||||
|
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
|
||||||
|
let mut guard = self.inner.write().expect("app domain table poisoned");
|
||||||
|
*guard = domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a request's `Host` header to an `AppId`. Most-specific
|
||||||
|
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||||
|
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
|
||||||
|
let host = strip_port(host).to_ascii_lowercase();
|
||||||
|
let guard = self.inner.read().expect("app domain table poisoned");
|
||||||
|
let mut best: Option<(HostSpecificity, AppId)> = None;
|
||||||
|
for claim in guard.iter() {
|
||||||
|
if let Some(()) = host_matches(&claim.pattern, &host) {
|
||||||
|
let s = claim.pattern.specificity();
|
||||||
|
if best.is_none_or(|(prev, _)| s > prev) {
|
||||||
|
best = Some((s, claim.app_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, app_id)| app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("app domain table poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_port(host: &str) -> &str {
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
|
||||||
|
match pattern {
|
||||||
|
HostPattern::Any => Some(()),
|
||||||
|
HostPattern::Strict(s) => {
|
||||||
|
if s.eq_ignore_ascii_case(host) {
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostPattern::Wildcard { suffix, .. } => {
|
||||||
|
let dotted = format!(".{}", suffix.to_ascii_lowercase());
|
||||||
|
host.strip_suffix(&dotted)
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::routing::pattern::parse_app_domain;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn id() -> AppId {
|
||||||
|
AppId::from(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
|
||||||
|
let d = parse_app_domain(raw).unwrap();
|
||||||
|
CompiledAppDomain {
|
||||||
|
app_id,
|
||||||
|
pattern: d.pattern,
|
||||||
|
shape_key: d.shape_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_exact_over_wildcard() {
|
||||||
|
let app_a = id();
|
||||||
|
let app_b = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(app_a, "foo.example.com"),
|
||||||
|
compile(app_b, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
|
||||||
|
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longer_wildcard_beats_shorter() {
|
||||||
|
let inner = id();
|
||||||
|
let outer = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![
|
||||||
|
compile(inner, "*.api.example.com"),
|
||||||
|
compile(outer, "*.example.com"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
table.resolve_app("v1.api.example.com"),
|
||||||
|
Some(inner),
|
||||||
|
"more-specific wildcard should win"
|
||||||
|
);
|
||||||
|
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameterized_resolves_like_wildcard() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "{tenant}.example.com")]);
|
||||||
|
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
|
||||||
|
assert!(table.resolve_app("example.com").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_when_no_claim() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "foo.example.com")]);
|
||||||
|
assert!(table.resolve_app("nope.com").is_none());
|
||||||
|
assert!(table.resolve_app("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_port() {
|
||||||
|
let app = id();
|
||||||
|
let table = AppDomainTable::new();
|
||||||
|
table.replace(vec![compile(app, "localhost")]);
|
||||||
|
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ pub struct Matched {
|
|||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single route ready for matching.
|
/// A single route ready for matching. `app_id` is carried so the
|
||||||
|
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||||
|
/// table; the matcher itself doesn't read it.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CompiledRoute {
|
pub struct CompiledRoute {
|
||||||
pub route_id: uuid::Uuid,
|
pub route_id: uuid::Uuid,
|
||||||
|
pub app_id: picloud_shared::AppId,
|
||||||
pub script_id: picloud_shared::ScriptId,
|
pub script_id: picloud_shared::ScriptId,
|
||||||
pub host: HostPattern,
|
pub host: HostPattern,
|
||||||
pub path: PathPattern,
|
pub path: PathPattern,
|
||||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::pattern::parse_path;
|
use super::super::pattern::parse_path;
|
||||||
use super::*;
|
use super::*;
|
||||||
use picloud_shared::{PathKind, ScriptId};
|
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||||
CompiledRoute {
|
CompiledRoute {
|
||||||
route_id: Uuid::new_v4(),
|
route_id: Uuid::new_v4(),
|
||||||
|
app_id: AppId::new(),
|
||||||
script_id: ScriptId::new(),
|
script_id: ScriptId::new(),
|
||||||
host,
|
host,
|
||||||
path: parse_path(path_kind, raw).unwrap(),
|
path: parse_path(path_kind, raw).unwrap(),
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||||
//! wildcard suffix breaks ties between wildcards.
|
//! wildcard suffix breaks ties between wildcards.
|
||||||
|
|
||||||
|
pub mod app_domains;
|
||||||
pub mod conflict;
|
pub mod conflict;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod pattern;
|
pub mod pattern;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
|
|
||||||
|
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||||
pub use conflict::{conflicts, ConflictReason};
|
pub use conflict::{conflicts, ConflictReason};
|
||||||
pub use matcher::{MatchResult, Matched};
|
pub use matcher::{MatchResult, Matched};
|
||||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
pub use pattern::{
|
||||||
|
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||||
|
};
|
||||||
pub use table::RouteTable;
|
pub use table::RouteTable;
|
||||||
|
|||||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// App-domain patterns
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use picloud_shared::DomainShape;
|
||||||
|
|
||||||
|
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||||
|
/// host pattern (used at request time), the shape (used at write time
|
||||||
|
/// for collision checks), and the normalized shape_key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParsedAppDomain {
|
||||||
|
pub pattern: HostPattern,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||||
|
/// for both wildcard AND parameterized — they share a shape per
|
||||||
|
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||||
|
/// check").
|
||||||
|
pub shape_key: String,
|
||||||
|
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||||
|
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||||
|
/// is surfaced into request context in a future iteration.
|
||||||
|
pub binding: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a user-supplied app domain claim. Accepts:
|
||||||
|
/// * `app.example.com` — exact host
|
||||||
|
/// * `*.example.com` — wildcard suffix
|
||||||
|
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||||
|
///
|
||||||
|
/// Distinct from `parse_host` (which is for route host fields): the
|
||||||
|
/// route parser still rejects `{...}` syntax — see
|
||||||
|
/// `ParseError::ReservedHostBraceSyntax`.
|
||||||
|
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ParseError::EmptyHost);
|
||||||
|
}
|
||||||
|
let lowered = trimmed.to_ascii_lowercase();
|
||||||
|
|
||||||
|
// Wildcard: starts with "*."
|
||||||
|
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||||
|
if suffix.is_empty() {
|
||||||
|
return Err(ParseError::EmptyWildcardSuffix);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: None,
|
||||||
|
},
|
||||||
|
shape: DomainShape::Wildcard,
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||||
|
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||||
|
let (binding, rest) = stripped
|
||||||
|
.split_once('}')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if binding.is_empty()
|
||||||
|
|| !binding
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||||
|
{
|
||||||
|
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||||
|
}
|
||||||
|
let suffix = rest
|
||||||
|
.strip_prefix('.')
|
||||||
|
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||||
|
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
return Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Wildcard {
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
capture: Some(binding.to_string()),
|
||||||
|
},
|
||||||
|
shape: DomainShape::Parameterized,
|
||||||
|
// Same shape_key as the equivalent wildcard — parameter
|
||||||
|
// name is a binding, not a discriminator.
|
||||||
|
shape_key: format!("wildcard:{suffix}"),
|
||||||
|
binding: Some(binding.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else: exact host. Reject braces anywhere in the body
|
||||||
|
// (they'd be a malformed parameterized form).
|
||||||
|
if lowered.contains('{') || lowered.contains('}') {
|
||||||
|
return Err(ParseError::ReservedHostBraceSyntax);
|
||||||
|
}
|
||||||
|
Ok(ParsedAppDomain {
|
||||||
|
pattern: HostPattern::Strict(lowered.clone()),
|
||||||
|
shape: DomainShape::Exact,
|
||||||
|
shape_key: format!("exact:{lowered}"),
|
||||||
|
binding: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -393,6 +493,49 @@ mod tests {
|
|||||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_exact() {
|
||||||
|
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||||
|
assert_eq!(d.shape, DomainShape::Exact);
|
||||||
|
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||||
|
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||||
|
assert!(d.binding.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||||
|
let w = parse_app_domain("*.example.com").unwrap();
|
||||||
|
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||||
|
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||||
|
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||||
|
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||||
|
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||||
|
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_app_domain_rejects_garbage() {
|
||||||
|
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("*."),
|
||||||
|
Err(ParseError::EmptyWildcardSuffix)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("{1tenant}.example.com"),
|
||||||
|
Err(ParseError::InvalidParamName(_))
|
||||||
|
));
|
||||||
|
// Mid-host braces — disallowed.
|
||||||
|
assert!(matches!(
|
||||||
|
parse_app_domain("foo.{tenant}.example.com"),
|
||||||
|
Err(ParseError::ReservedHostBraceSyntax)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn leading_literal_count_works() {
|
fn leading_literal_count_works() {
|
||||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||||
//! and orchestrator (reads).
|
|
||||||
//!
|
//!
|
||||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||||
//! read without contending against the writer; in MVP-single-process
|
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||||
//! we just use `RwLock` and accept the cheap contention.
|
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||||
|
|
||||||
|
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||||
|
/// via `replace_all`); contention against readers is minimal so a plain
|
||||||
|
/// `RwLock` is fine.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RouteTable {
|
pub struct RouteTable {
|
||||||
inner: RwLock<Vec<CompiledRoute>>,
|
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RouteTable {
|
impl RouteTable {
|
||||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the whole table atomically. The manager calls this after
|
/// Replace every per-app slice atomically. The manager calls this
|
||||||
/// each successful route CRUD operation (by re-reading from DB).
|
/// after each successful route CRUD operation; in cluster mode the
|
||||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
/// orchestrator's HTTP-fed receiver will too.
|
||||||
|
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||||
|
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||||
|
for r in routes {
|
||||||
|
by_app.entry(r.app_id).or_default().push(r);
|
||||||
|
}
|
||||||
let mut guard = self.inner.write().expect("route table poisoned");
|
let mut guard = self.inner.write().expect("route table poisoned");
|
||||||
*guard = routes;
|
*guard = by_app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a request to a matching route, or `None`.
|
/// Dispatch a request to a matching route within `app_id`, or
|
||||||
|
/// `None`. Returns `None` when the app has no routes at all.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
pub fn match_request_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
host: &str,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
) -> Option<MatchResult> {
|
||||||
let guard = self.inner.read().expect("route table poisoned");
|
let guard = self.inner.read().expect("route table poisoned");
|
||||||
r#match(guard.iter(), host, method, path)
|
let slice = guard.get(&app_id)?;
|
||||||
|
r#match(slice.iter(), host, method, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a clone of the currently compiled routes; intended for
|
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||||
/// the dashboard's "list routes" admin endpoint.
|
/// intended for admin endpoints like "list this app's routes".
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||||
self.inner.read().expect("route table poisoned").clone()
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.get(&app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All compiled routes across all apps. Used by tests and the
|
||||||
|
/// global admin "every route on this install" view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||||
|
self.inner
|
||||||
|
.read()
|
||||||
|
.expect("route table poisoned")
|
||||||
|
.values()
|
||||||
|
.flat_map(|v| v.iter().cloned())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ 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, auth_router, compile_routes, migrations, require_admin,
|
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
||||||
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
||||||
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||||
|
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
|
||||||
|
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||||
|
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::RouteTable;
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||||
};
|
};
|
||||||
@@ -35,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,14 +85,37 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
|
// Authz: app_members repo doubles as the AuthzRepo impl for the
|
||||||
|
// per-handler capability checks introduced in Phase 3.5.
|
||||||
|
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// 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());
|
||||||
let initial = route_repo.list_all().await?;
|
let initial = route_repo.list_all().await?;
|
||||||
let compiled = compile_routes(&initial)
|
let compiled = compile_routes(&initial)
|
||||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||||
route_table.replace(compiled);
|
route_table.replace_all(compiled);
|
||||||
|
|
||||||
|
// Same shape for app domains (Host → app_id cache).
|
||||||
|
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||||
|
let initial_domains = domains_repo.list_all().await?;
|
||||||
|
let compiled_domains: Vec<_> = initial_domains
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||||
|
app_id: d.app_id,
|
||||||
|
pattern: p.pattern,
|
||||||
|
shape_key: p.shape_key,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
app_domain_table.replace(compiled_domains);
|
||||||
|
|
||||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||||
script_repo.clone(),
|
script_repo.clone(),
|
||||||
@@ -95,41 +123,69 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
||||||
|
|
||||||
let admin = AdminState {
|
let admin = AdminState {
|
||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
|
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(),
|
||||||
};
|
};
|
||||||
let route_admin = RouteAdminState {
|
let route_admin = RouteAdminState {
|
||||||
routes: route_repo,
|
routes: route_repo.clone(),
|
||||||
|
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
|
domains: domains_repo.clone(),
|
||||||
table: route_table.clone(),
|
table: route_table.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
resolver,
|
resolver,
|
||||||
log_sink,
|
log_sink,
|
||||||
|
app_domains: app_domain_table.clone(),
|
||||||
routes: route_table,
|
routes: route_table,
|
||||||
};
|
};
|
||||||
|
let apps_state = AppsState {
|
||||||
|
apps: apps_repo,
|
||||||
|
domains: domains_repo,
|
||||||
|
routes: route_repo,
|
||||||
|
domain_table: app_domain_table,
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
let auth_state = AuthState {
|
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))
|
||||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
.merge(apps_router(apps_state))
|
||||||
|
.merge(api_keys_router(api_keys_state))
|
||||||
|
.layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
require_authenticated,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||||
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
|
let _ = apps_api::AppsState::clone;
|
||||||
|
|
||||||
let api_v1 = Router::new()
|
let api_v1 = Router::new()
|
||||||
.nest("/admin", auth_router(auth_state))
|
.nest("/admin", auth_router(auth_state))
|
||||||
@@ -201,6 +257,18 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.list().await
|
self.0.list().await
|
||||||
}
|
}
|
||||||
|
async fn list_for_app(
|
||||||
|
&self,
|
||||||
|
app_id: picloud_shared::AppId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_app(app_id).await
|
||||||
|
}
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: picloud_shared::AdminUserId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_user(user_id).await
|
||||||
|
}
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
input: picloud_manager_core::NewScript,
|
input: picloud_manager_core::NewScript,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use std::time::Duration;
|
|||||||
use picloud::{build_app, init_db, AuthDeps};
|
use picloud::{build_app, init_db, AuthDeps};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
auth::{hash_password, validate_password_hash},
|
auth::{hash_password, validate_password_hash},
|
||||||
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
|
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
|
||||||
|
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
|
||||||
|
PostgresScriptRepository,
|
||||||
};
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -43,6 +45,24 @@ 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
|
||||||
|
// install (no scripts and no routes). Idempotent on upgrades.
|
||||||
|
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
|
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||||
|
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
|
match seed_hello_world_if_fresh(apps, scripts, routes).await {
|
||||||
|
Ok(HelloWorldOutcome::Seeded) => {
|
||||||
|
tracing::info!("hello-world seed inserted into the default app");
|
||||||
|
}
|
||||||
|
Ok(HelloWorldOutcome::SkippedExisting) => {
|
||||||
|
tracing::debug!("hello-world seed skipped (default app already populated)");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Background session-prune sweep. Cheap; keeps the table from
|
// Background session-prune sweep. Cheap; keeps the table from
|
||||||
// growing unbounded. Expired rows are also rejected at lookup time,
|
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||||
@@ -60,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));
|
||||||
|
|||||||
@@ -23,12 +23,20 @@ use sqlx::PgPool;
|
|||||||
/// the bearer token into the TestServer as a default header so every
|
/// the bearer token into the TestServer as a default header so every
|
||||||
/// request in the test passes the `require_admin` middleware.
|
/// request in the test passes the `require_admin` middleware.
|
||||||
async fn server(pool: PgPool) -> TestServer {
|
async fn server(pool: PgPool) -> TestServer {
|
||||||
|
let (server, _app_id) = server_with_app(pool).await;
|
||||||
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `server`, but also returns the default app's id — needed by
|
||||||
|
/// any test that creates scripts (every script now requires `app_id`).
|
||||||
|
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||||
use picloud_manager_core::auth::hash_password;
|
use picloud_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)
|
||||||
.await
|
.await
|
||||||
.expect("seed admin");
|
.expect("seed admin");
|
||||||
|
|
||||||
@@ -45,7 +53,32 @@ async fn server(pool: PgPool) -> TestServer {
|
|||||||
.expect("login should return token")
|
.expect("login should return token")
|
||||||
.to_string();
|
.to_string();
|
||||||
server.add_header("authorization", format!("Bearer {token}"));
|
server.add_header("authorization", format!("Bearer {token}"));
|
||||||
server
|
// Note: user-route dispatch needs an explicit `host: <claim>` header
|
||||||
|
// on each request (the axum_test client doesn't default to a real
|
||||||
|
// host). The default app claims `localhost`; user-route tests below
|
||||||
|
// add the header per request via `.add_header("host", "localhost")`
|
||||||
|
// so per-test overrides for other apps cleanly replace it.
|
||||||
|
|
||||||
|
// The 0005 migration unconditionally inserts a `default` app; fetch
|
||||||
|
// its id so tests can attach scripts to it without re-running the
|
||||||
|
// Rust-side hello-world seed (which only fires from main.rs).
|
||||||
|
// The get-app handler returns `{ ...App, redirect_to?: ... }` —
|
||||||
|
// the app fields are flattened at the response root.
|
||||||
|
let app: Value = server.get("/api/v1/admin/apps/default").await.json();
|
||||||
|
let app_id = app["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("default app id missing from response: {app}"))
|
||||||
|
.to_string();
|
||||||
|
(server, app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge `{ "app_id": <default> }` into a create-script body. Saves
|
||||||
|
/// repeating the same field in 25+ tests.
|
||||||
|
fn with_app(app_id: &str, mut body: Value) -> Value {
|
||||||
|
body.as_object_mut()
|
||||||
|
.expect("script body must be a JSON object")
|
||||||
|
.insert("app_id".into(), Value::String(app_id.to_string()));
|
||||||
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -67,30 +100,37 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"description": "test",
|
"description": "test",
|
||||||
"source": "#{ statusCode: 200, body: 42 }",
|
"source": "#{ statusCode: 200, body: 42 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CREATED);
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "echo");
|
assert_eq!(body["name"], "echo");
|
||||||
assert_eq!(body["version"], 1);
|
assert_eq!(body["version"], 1);
|
||||||
assert_eq!(body["timeout_seconds"], 30);
|
assert_eq!(body["timeout_seconds"], 30);
|
||||||
|
assert_eq!(body["app_id"], app_id);
|
||||||
assert!(body["id"].as_str().is_some());
|
assert!(body["id"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||||
let r = server(pool)
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
.await
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -100,14 +140,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn duplicate_name_returns_409(pool: PgPool) {
|
async fn duplicate_name_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "42" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "dup", "source": "43" }))
|
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
@@ -115,10 +155,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn list_returns_all_scripts(pool: PgPool) {
|
async fn list_returns_all_scripts(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
for name in ["alpha", "bravo", "charlie"] {
|
for name in ["alpha", "bravo", "charlie"] {
|
||||||
s.post("/api/v1/admin/scripts")
|
s.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
}
|
}
|
||||||
@@ -133,10 +173,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -155,10 +195,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "u", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -173,10 +213,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn delete_then_get_returns_404(pool: PgPool) {
|
async fn delete_then_get_returns_404(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "d", "source": "1" }))
|
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -207,13 +247,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_echoes_body_back(pool: PgPool) {
|
async fn execute_echoes_body_back(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -230,13 +273,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "header-test",
|
"name": "header-test",
|
||||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -263,13 +309,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_logs_capture_invocations(pool: PgPool) {
|
async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "logger",
|
"name": "logger",
|
||||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -320,10 +369,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({ "name": "no-sandbox", "source": "1" }),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(created["sandbox"], json!({}));
|
assert_eq!(created["sandbox"], json!({}));
|
||||||
@@ -332,14 +384,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight",
|
"name": "tight",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -359,14 +414,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||||
// Default conservative ceiling caps max_operations at 10_000_000.
|
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "too-loose",
|
"name": "too-loose",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 100_000_000 }
|
"sandbox": { "max_operations": 100_000_000 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -376,14 +434,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "typo",
|
"name": "typo",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operashuns": 500 }
|
"sandbox": { "max_operashuns": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
// serde's deny_unknown_fields causes axum to reject with 422 or
|
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||||
// 400 depending on extractor; the routing is irrelevant here, just
|
// 400 depending on extractor; the routing is irrelevant here, just
|
||||||
@@ -397,15 +458,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
// Tight max_operations on a loop the default would happily run.
|
// Tight max_operations on a loop the default would happily run.
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "tight-exec",
|
"name": "tight-exec",
|
||||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||||
"sandbox": { "max_operations": 500 }
|
"sandbox": { "max_operations": 500 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -422,14 +486,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "patch-target",
|
"name": "patch-target",
|
||||||
"source": "1",
|
"source": "1",
|
||||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
@@ -455,10 +522,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
|||||||
// Custom routing
|
// Custom routing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
|
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||||
let v: Value = s
|
let v: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({ "name": name, "source": source }))
|
.json(&with_app(app_id, json!({ "name": name, "source": source })))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
v["id"].as_str().unwrap().to_string()
|
v["id"].as_str().unwrap().to_string()
|
||||||
@@ -467,9 +534,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet",
|
"greet",
|
||||||
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
||||||
)
|
)
|
||||||
@@ -483,7 +551,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet").await;
|
let r = s.get("/greet").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["msg"], "hi");
|
assert_eq!(body["msg"], "hi");
|
||||||
@@ -493,9 +561,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_param_captures_path_vars(pool: PgPool) {
|
async fn route_param_captures_path_vars(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"greet-name",
|
"greet-name",
|
||||||
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
||||||
)
|
)
|
||||||
@@ -509,7 +578,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/greet/alice").await;
|
let r = s.get("/greet/alice").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["name"], "alice");
|
assert_eq!(body["name"], "alice");
|
||||||
@@ -518,9 +587,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_prefix_captures_rest(pool: PgPool) {
|
async fn route_prefix_captures_rest(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(
|
let id = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"echo-prefix",
|
"echo-prefix",
|
||||||
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
||||||
)
|
)
|
||||||
@@ -534,19 +604,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/echo/foo/bar").await;
|
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["rest"], "foo/bar");
|
assert_eq!(body["rest"], "foo/bar");
|
||||||
|
|
||||||
s.get("/echo").await.assert_status_not_found();
|
s.get("/echo")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
|
let id = create_basic_script(
|
||||||
|
&s,
|
||||||
|
&app_id,
|
||||||
|
"qs",
|
||||||
|
"#{ statusCode: 200, body: ctx.request.query }",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -556,7 +635,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
let r = s.get("/qs?a=1&b=two").await;
|
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
||||||
@@ -565,8 +644,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -581,8 +660,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_conflict_returns_409(pool: PgPool) {
|
async fn route_conflict_returns_409(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -608,8 +687,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_reserved_path_returns_422(pool: PgPool) {
|
async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "x", "1").await;
|
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||||
let r = s
|
let r = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -624,8 +703,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_match_preview_endpoint(pool: PgPool) {
|
async fn route_match_preview_endpoint(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "1").await;
|
let id = create_basic_script(&s, &app_id, "g", "1").await;
|
||||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"host_kind": "any",
|
"host_kind": "any",
|
||||||
@@ -637,7 +716,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
|
|
||||||
let r = s
|
let r = s
|
||||||
.post("/api/v1/admin/routes:match")
|
.post("/api/v1/admin/routes:match")
|
||||||
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
|
.json(&json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"url": "http://localhost:8000/greet/alice",
|
||||||
|
"method": "GET"
|
||||||
|
}))
|
||||||
.await;
|
.await;
|
||||||
r.assert_status_ok();
|
r.assert_status_ok();
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
@@ -648,8 +731,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
|||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_delete_removes_dispatch(pool: PgPool) {
|
async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
|
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
@@ -661,27 +744,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
|
|||||||
.json();
|
.json();
|
||||||
let route_id = created["id"].as_str().unwrap();
|
let route_id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
s.get("/g").await.assert_status_ok();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
||||||
.await
|
.await
|
||||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
s.get("/g").await.assert_status_not_found();
|
s.get("/g")
|
||||||
|
.add_header("host", "localhost")
|
||||||
|
.await
|
||||||
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let id_p = create_basic_script(
|
let id_p = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-param",
|
"by-param",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let id_pr = create_basic_script(
|
let id_pr = create_basic_script(
|
||||||
&s,
|
&s,
|
||||||
|
&app_id,
|
||||||
"by-prefix",
|
"by-prefix",
|
||||||
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
||||||
)
|
)
|
||||||
@@ -704,12 +795,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
.assert_status(axum::http::StatusCode::CREATED);
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
// Single segment under /foo/ — both match; param wins by spec.
|
// Single segment under /foo/ — both match; param wins by spec.
|
||||||
let r = s.get("/foo/x").await;
|
let r = s.get("/foo/x").add_header("host", "localhost").await;
|
||||||
let body: Value = r.json();
|
let body: Value = r.json();
|
||||||
assert_eq!(body["tag"], "param");
|
assert_eq!(body["tag"], "param");
|
||||||
|
|
||||||
// Two segments — only prefix matches.
|
// Two segments — only prefix matches.
|
||||||
let r2 = s.get("/foo/x/y").await;
|
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
|
||||||
let body2: Value = r2.json();
|
let body2: Value = r2.json();
|
||||||
assert_eq!(body2["tag"], "prefix");
|
assert_eq!(body2["tag"], "prefix");
|
||||||
}
|
}
|
||||||
@@ -718,7 +809,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
|||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn root_returns_404_when_no_route(pool: PgPool) {
|
async fn root_returns_404_when_no_route(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let s = server(pool).await;
|
||||||
let r = s.get("/").await;
|
let r = s.get("/").add_header("host", "localhost").await;
|
||||||
r.assert_status_not_found();
|
r.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,22 +822,325 @@ 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"], 4);
|
assert_eq!(v["schema"], 6);
|
||||||
assert_eq!(v["sdk"], "1.1");
|
assert_eq!(v["sdk"], "1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// App scoping (Phase 3b)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn default_app_is_seeded_by_migration(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/apps").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let apps: Vec<Value> = r.json();
|
||||||
|
let default = apps
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["slug"] == "default")
|
||||||
|
.expect("default app must exist");
|
||||||
|
assert_eq!(default["name"], "Default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
|
||||||
|
// Two apps each create a script with the same name (per-app
|
||||||
|
// uniqueness — would have collided pre-3b).
|
||||||
|
let app_b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b_id = app_b["id"].as_str().unwrap();
|
||||||
|
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
|
||||||
|
.json(&json!({ "pattern": "b.localhost" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
let id_default: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let id_b: String = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
b_id,
|
||||||
|
json!({
|
||||||
|
"name": "echo",
|
||||||
|
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
|
||||||
|
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Same path, different host — routes land in different apps.
|
||||||
|
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
|
||||||
|
assert_eq!(from_default["from"], "default");
|
||||||
|
|
||||||
|
let from_b: Value = s
|
||||||
|
.get("/echo")
|
||||||
|
.add_header("host", "b.localhost")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(from_b["from"], "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn unknown_host_returns_404(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
|
||||||
|
r.assert_status_not_found();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("no app claims host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
|
||||||
|
// The /api/v1/execute/{id} bypass is the implicit __internal__
|
||||||
|
// claim of every app — it MUST keep working for an app with zero
|
||||||
|
// public domain claims.
|
||||||
|
let (s, _) = server_with_app(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let app_id = app["id"].as_str().unwrap();
|
||||||
|
let script: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
app_id,
|
||||||
|
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = script["id"].as_str().unwrap();
|
||||||
|
let r = s
|
||||||
|
.post(&format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&json!({}))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn duplicate_slug_creates_a_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
s.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "First" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "alpha", "name": "Second" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn reserved_slug_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
for bad in ["new", "api", "admin", "login"] {
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": bad, "name": "x" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "old-slug", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.patch(&format!("/api/v1/admin/apps/{id}"))
|
||||||
|
.json(&json!({ "slug": "new-slug" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
|
||||||
|
// The old slug resolves via history and surfaces `redirect_to`.
|
||||||
|
assert_eq!(resp["redirect_to"], "new-slug");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Set up a history row.
|
||||||
|
let first: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.patch(&format!(
|
||||||
|
"/api/v1/admin/apps/{}",
|
||||||
|
first["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "slug": "kept" }))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Plain create against the retired slug → 409.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// With force_takeover → 201.
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn shape_key_collision_rejected(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let a: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "a", "name": "A" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let b: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "b", "name": "B" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
s.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
a["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "*.example.com" }))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
// Parameterized form should collide with wildcard form.
|
||||||
|
let r = s
|
||||||
|
.post(&format!(
|
||||||
|
"/api/v1/admin/apps/{}/domains",
|
||||||
|
b["id"].as_str().unwrap()
|
||||||
|
))
|
||||||
|
.json(&json!({ "pattern": "{tenant}.example.com" }))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let app: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "with-scripts", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = app["id"].as_str().unwrap();
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_scripts_filtered_by_app(pool: PgPool) {
|
||||||
|
let (s, default_id) = server_with_app(pool).await;
|
||||||
|
let other: Value = s
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.json(&json!({ "slug": "filter-target", "name": "x" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let other_id = other["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
&default_id,
|
||||||
|
json!({ "name": "in-default", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
s.post("/api/v1/admin/scripts")
|
||||||
|
.json(&with_app(
|
||||||
|
other_id,
|
||||||
|
json!({ "name": "in-other", "source": "1" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Filter by id.
|
||||||
|
let filtered: Vec<Value> = s
|
||||||
|
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered.len(), 1);
|
||||||
|
assert_eq!(filtered[0]["name"], "in-other");
|
||||||
|
|
||||||
|
// Filter by slug.
|
||||||
|
let filtered_by_slug: Vec<Value> = s
|
||||||
|
.get("/api/v1/admin/scripts?app=filter-target")
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(filtered_by_slug.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||||
let s = server(pool).await;
|
let (s, app_id) = server_with_app(pool).await;
|
||||||
let created: Value = s
|
let created: Value = s
|
||||||
.post("/api/v1/admin/scripts")
|
.post("/api/v1/admin/scripts")
|
||||||
.json(&json!({
|
.json(&with_app(
|
||||||
|
&app_id,
|
||||||
|
json!({
|
||||||
"name": "boom",
|
"name": "boom",
|
||||||
"source": "1 / 0",
|
"source": "1 / 0",
|
||||||
}))
|
}),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.json();
|
.json();
|
||||||
let id = created["id"].as_str().unwrap();
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|||||||
647
crates/picloud/tests/authz.rs
Normal file
647
crates/picloud/tests/authz.rs
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
//! Phase 3.5 authorization end-to-end tests.
|
||||||
|
//!
|
||||||
|
//! Covers the 11 scenarios from `lay-foundations-for-snazzy-truffle.md`
|
||||||
|
//! step 9:
|
||||||
|
//!
|
||||||
|
//! 1. Bootstrap admin promotes to owner.
|
||||||
|
//! 2. Owner access matrix on a sample app.
|
||||||
|
//! 3. Admin access matrix.
|
||||||
|
//! 4. Member access matrix.
|
||||||
|
//! 5. Bearer (pic_) + cookie produce the same Principal.
|
||||||
|
//! 6. Scope intersection: a script:read-only key cannot write.
|
||||||
|
//! 7. Bound key cannot escape its app.
|
||||||
|
//! 8. Member listing isolation (apps + scripts).
|
||||||
|
//! 9. Deactivation revokes API keys.
|
||||||
|
//! 10. Mint rejects bound key with `instance:*` scope.
|
||||||
|
//! 11. `list_active_owners` returns the expected set under the seed
|
||||||
|
//! that the startup warning is built from (we don't capture the
|
||||||
|
//! log line itself — the data source is the testable surface).
|
||||||
|
//!
|
||||||
|
//! Same harness as `tests/api.rs`: `#[sqlx::test]` against a real
|
||||||
|
//! Postgres, `TestServer` over the in-process app. We do NOT bake a
|
||||||
|
//! token into the default headers here — each test wires its own
|
||||||
|
//! credential per request to exercise the cookie / Bearer split.
|
||||||
|
|
||||||
|
#![allow(clippy::needless_pass_by_value)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum_test::TestServer;
|
||||||
|
use picloud_manager_core::{
|
||||||
|
auth::hash_password, AdminUserRepository, ApiKeyRepository, AppMembersRepository,
|
||||||
|
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppMembersRepository,
|
||||||
|
};
|
||||||
|
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Harness
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Seeded {
|
||||||
|
server: TestServer,
|
||||||
|
pool: PgPool,
|
||||||
|
/// Bootstrap admin — Owner, password "owner-pw".
|
||||||
|
owner: AdminUserId,
|
||||||
|
/// Default app id, slug "default" (seeded by 0005 migration).
|
||||||
|
default_app: AppId,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn boot(pool: PgPool) -> Seeded {
|
||||||
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
|
let hash = hash_password("owner-pw").expect("hash");
|
||||||
|
let owner = auth
|
||||||
|
.users
|
||||||
|
.create("owner", &hash, InstanceRole::Owner)
|
||||||
|
.await
|
||||||
|
.expect("seed owner");
|
||||||
|
|
||||||
|
let app = picloud::build_app(pool.clone(), auth)
|
||||||
|
.await
|
||||||
|
.expect("build_app");
|
||||||
|
let server = TestServer::new(app).expect("TestServer");
|
||||||
|
|
||||||
|
// Default app id (seeded by migration 0005).
|
||||||
|
let resp = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": "owner", "password": "owner-pw" }))
|
||||||
|
.await;
|
||||||
|
resp.assert_status_ok();
|
||||||
|
let token = resp.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login token")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let app_resp = server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
app_resp.assert_status_ok();
|
||||||
|
let app_id: uuid::Uuid = app_resp.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("app id")
|
||||||
|
.parse()
|
||||||
|
.expect("uuid");
|
||||||
|
|
||||||
|
Seeded {
|
||||||
|
server,
|
||||||
|
pool,
|
||||||
|
owner: owner.id,
|
||||||
|
default_app: app_id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a session for an existing admin via the login endpoint and
|
||||||
|
/// return the raw token. Lets tests build a per-role credential
|
||||||
|
/// without baking it into the default headers.
|
||||||
|
async fn login_token(server: &TestServer, username: &str, password: &str) -> String {
|
||||||
|
let r = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": username, "password": password }))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
r.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("token in login response")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct-DB seed (bypassing the API) for users we want to construct
|
||||||
|
/// at arbitrary roles. The API enforces "owners only create owners"
|
||||||
|
/// which is correct production behavior but inconvenient for test
|
||||||
|
/// fixtures.
|
||||||
|
async fn seed_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
role: InstanceRole,
|
||||||
|
) -> AdminUserId {
|
||||||
|
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let hash = hash_password(password).expect("hash");
|
||||||
|
repo.create(username, &hash, role)
|
||||||
|
.await
|
||||||
|
.expect("seed user")
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) {
|
||||||
|
let repo = PostgresAppMembersRepository::new(pool.clone());
|
||||||
|
repo.upsert(app, user, role)
|
||||||
|
.await
|
||||||
|
.expect("grant membership");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_script_via_api(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Value {
|
||||||
|
let r = server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": app_id.to_string(),
|
||||||
|
"name": name,
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint an API key for the caller — wraps POST /api-keys.
|
||||||
|
async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.post("/api/v1/admin/api-keys")
|
||||||
|
.add_header("authorization", format!("Bearer {cred_token}"))
|
||||||
|
.json(&body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 1. Bootstrap admin → owner
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bootstrap_admin_is_owner(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let me = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
me.assert_status_ok();
|
||||||
|
let listing = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
listing.assert_status_ok();
|
||||||
|
let arr: Value = listing.json();
|
||||||
|
let row = arr
|
||||||
|
.as_array()
|
||||||
|
.and_then(|v| v.iter().find(|u| u["username"] == "owner"))
|
||||||
|
.expect("owner row");
|
||||||
|
assert_eq!(row["instance_role"], "owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 2 / 3 / 4. Role access matrices on a sample app
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn owner_access_matrix(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Read apps / scripts.
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Create a script — AppWriteScript.
|
||||||
|
let script = create_script_via_api(&s.server, &token, s.default_app, "owner-test").await;
|
||||||
|
let sid = script["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Read it back — AppRead.
|
||||||
|
s.server
|
||||||
|
.get(&format!("/api/v1/admin/scripts/{sid}"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Manage users — InstanceManageUsers.
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
|
let token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
|
|
||||||
|
// Allowed: list admins (InstanceManageUsers).
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: read default app (admin is implicit editor everywhere).
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: write scripts (implicit editor).
|
||||||
|
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
||||||
|
assert!(script["id"].is_string());
|
||||||
|
|
||||||
|
// Denied: delete the default app (AppAdmin only).
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.delete("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn member_can_only_touch_apps_they_belong_to(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
|
||||||
|
let token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
// Allowed: read + write inside the default app.
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
let script = create_script_via_api(&s.server, &token, s.default_app, "member-write").await;
|
||||||
|
let sid = script["id"].as_str().unwrap();
|
||||||
|
s.server
|
||||||
|
.get(&format!("/api/v1/admin/scripts/{sid}"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Denied: create a *new* app (member cannot InstanceCreateApp).
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({ "slug": "other", "name": "Other" }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// Denied: manage admins.
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 5. Bearer pic_ + cookie produce the same Principal
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bearer_and_cookie_produce_same_principal(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let session_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Mint a no-binding owner key covering script:read.
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&session_token,
|
||||||
|
json!({
|
||||||
|
"name": "owner-readonly",
|
||||||
|
"scopes": ["script:read"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw_token = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("raw token")
|
||||||
|
.to_string();
|
||||||
|
assert!(raw_token.starts_with("pic_"));
|
||||||
|
|
||||||
|
// /me through the cookie/session path.
|
||||||
|
let via_session = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {session_token}"))
|
||||||
|
.await;
|
||||||
|
via_session.assert_status_ok();
|
||||||
|
|
||||||
|
// /me through the pic_ path — same user_id.
|
||||||
|
let via_key = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {raw_token}"))
|
||||||
|
.await;
|
||||||
|
via_key.assert_status_ok();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
via_session.json::<Value>()["id"],
|
||||||
|
via_key.json::<Value>()["id"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
via_session.json::<Value>()["username"],
|
||||||
|
via_key.json::<Value>()["username"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 6. Scope intersection — read-only key cannot write
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn read_only_key_cannot_write_scripts(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let session_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&session_token,
|
||||||
|
json!({ "name": "ro", "scopes": ["script:read"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
"name": "would-write",
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 7. Bound key cannot escape its app
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bound_key_cannot_escape_its_app(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let session_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Create a second app via the API (owner can InstanceCreateApp).
|
||||||
|
let other = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {session_token}"))
|
||||||
|
.json(&json!({ "slug": "other", "name": "Other" }))
|
||||||
|
.await;
|
||||||
|
other.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let other_id = other.json::<Value>()["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Mint a key bound to the default app with script:write.
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&session_token,
|
||||||
|
json!({
|
||||||
|
"name": "default-only",
|
||||||
|
"scopes": ["script:write"],
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Writing into the bound app: allowed.
|
||||||
|
let ok = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
"name": "bound-ok",
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
ok.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Writing into the *other* app: forbidden.
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": other_id,
|
||||||
|
"name": "escape-attempt",
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 8. Member listing isolation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn member_list_endpoints_filter_at_sql(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Owner creates a second app + script-in-that-app.
|
||||||
|
let other = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.json(&json!({ "slug": "secret", "name": "Secret" }))
|
||||||
|
.await;
|
||||||
|
other.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let other_id: uuid::Uuid = other.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let other_app: AppId = other_id.into();
|
||||||
|
create_script_via_api(&s.server, &owner_token, other_app, "secret-script").await;
|
||||||
|
create_script_via_api(&s.server, &owner_token, s.default_app, "default-script").await;
|
||||||
|
|
||||||
|
// Carol is a member of the default app only.
|
||||||
|
let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, carol, s.default_app, AppRole::Viewer).await;
|
||||||
|
let carol_token = login_token(&s.server, "carol", "carol-pw").await;
|
||||||
|
|
||||||
|
let apps = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {carol_token}"))
|
||||||
|
.await;
|
||||||
|
apps.assert_status_ok();
|
||||||
|
let apps_body: Value = apps.json();
|
||||||
|
let app_slugs: Vec<String> = apps_body
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a["slug"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
app_slugs,
|
||||||
|
vec!["default"],
|
||||||
|
"member must see only their apps"
|
||||||
|
);
|
||||||
|
|
||||||
|
let scripts = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {carol_token}"))
|
||||||
|
.await;
|
||||||
|
scripts.assert_status_ok();
|
||||||
|
let scripts_body: Value = scripts.json();
|
||||||
|
let names: Vec<String> = scripts_body
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|s| s["name"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "default-script") && !names.iter().any(|n| n == "secret-script"),
|
||||||
|
"member listing leaked another app's script: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 9. Deactivation revokes API keys
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn deactivating_user_revokes_their_api_keys(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// A second user — admin so they can mint a key for themselves.
|
||||||
|
let dave_id = seed_user(&s.pool, "dave", "dave-pw", InstanceRole::Admin).await;
|
||||||
|
let dave_token = login_token(&s.server, "dave", "dave-pw").await;
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&dave_token,
|
||||||
|
json!({ "name": "dave-key", "scopes": ["script:read"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Key works.
|
||||||
|
let before = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.await;
|
||||||
|
before.assert_status_ok();
|
||||||
|
|
||||||
|
// Owner deactivates Dave.
|
||||||
|
let patch = s
|
||||||
|
.server
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{dave_id}"))
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.json(&json!({ "is_active": false }))
|
||||||
|
.await;
|
||||||
|
patch.assert_status_ok();
|
||||||
|
|
||||||
|
// Key now rejects with 401.
|
||||||
|
let after = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(after.status_code(), axum::http::StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
// Cross-check via the repo: the row's expires_at is set in the past.
|
||||||
|
let repo = PostgresApiKeyRepository::new(s.pool.clone());
|
||||||
|
let rows = repo.list_for_user(dave_id).await.expect("list keys");
|
||||||
|
assert!(
|
||||||
|
rows.iter().all(|r| r.expires_at.is_some()),
|
||||||
|
"every key must have an expiry after deactivation"
|
||||||
|
);
|
||||||
|
assert!(rows
|
||||||
|
.iter()
|
||||||
|
.all(|r| r.expires_at.unwrap() <= chrono::Utc::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 10. Mint rejects bound key + instance scope
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/api-keys")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({
|
||||||
|
"name": "irreconcilable",
|
||||||
|
"scopes": ["instance:admin"],
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
r.status_code(),
|
||||||
|
axum::http::StatusCode::UNPROCESSABLE_ENTITY
|
||||||
|
);
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(
|
||||||
|
body["error"].as_str().unwrap().contains("bound"),
|
||||||
|
"error body should explain the conflict, got {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 11. Multi-owner detection — data-source for the startup warning
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
|
||||||
|
// Seed a second owner directly so we exercise the
|
||||||
|
// multi-owner condition.
|
||||||
|
seed_user(&s.pool, "owner2", "pw", InstanceRole::Owner).await;
|
||||||
|
seed_user(&s.pool, "admin1", "pw", InstanceRole::Admin).await;
|
||||||
|
|
||||||
|
let users = Arc::new(PostgresAdminUserRepository::new(s.pool.clone()));
|
||||||
|
let owners = users.list_active_owners().await.expect("list owners");
|
||||||
|
let names: Vec<&str> = owners.iter().map(|o| o.username.as_str()).collect();
|
||||||
|
assert!(names.contains(&"owner"));
|
||||||
|
assert!(names.contains(&"owner2"));
|
||||||
|
assert!(!names.contains(&"admin1"));
|
||||||
|
assert_eq!(
|
||||||
|
owners.len(),
|
||||||
|
2,
|
||||||
|
"list_active_owners must filter strictly by instance_role"
|
||||||
|
);
|
||||||
|
|
||||||
|
// count_other_active_owners powers the last-owner guard.
|
||||||
|
let remaining = users
|
||||||
|
.count_other_active_owners(s.owner)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
|
||||||
|
}
|
||||||
53
crates/shared/src/app.rs
Normal file
53
crates/shared/src/app.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! App scoping: top-level isolation boundary for scripts, routes,
|
||||||
|
//! domains, and (forward) data. Every script and route belongs to
|
||||||
|
//! exactly one app; cross-app references are not allowed.
|
||||||
|
//!
|
||||||
|
//! See blueprint §11.5. The orchestrator dispatches via two-phase
|
||||||
|
//! lookup: `Host → app_id → route trie`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct App {
|
||||||
|
pub id: AppId,
|
||||||
|
/// URL-safe identifier; appears in dashboard paths. Mutable via the
|
||||||
|
/// slug-rename flow which preserves the old slug as a permanent 301
|
||||||
|
/// in `app_slug_history`.
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DomainShape {
|
||||||
|
/// Exact host: `app.example.com`.
|
||||||
|
Exact,
|
||||||
|
/// Wildcard suffix: `*.example.com` matches any subdomain.
|
||||||
|
Wildcard,
|
||||||
|
/// Parameterized wildcard: `{tenant}.example.com`. Same shape as
|
||||||
|
/// `Wildcard` for collision purposes; the binding name surfaces in
|
||||||
|
/// request context (future).
|
||||||
|
Parameterized,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppDomain {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub app_id: AppId,
|
||||||
|
/// As the user typed it: `app.example.com`, `*.example.com`, or
|
||||||
|
/// `{tenant}.example.com`.
|
||||||
|
pub pattern: String,
|
||||||
|
pub shape: DomainShape,
|
||||||
|
/// Normalized collision key. `exact:<host>` for exact; `wildcard:<suffix>`
|
||||||
|
/// for both wildcard and parameterized (parameter name is a binding,
|
||||||
|
/// not a discriminator — per blueprint §11.5).
|
||||||
|
pub shape_key: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
242
crates/shared/src/auth.rs
Normal file
242
crates/shared/src/auth.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,4 +11,10 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("invalid script source: {0}")]
|
#[error("invalid script source: {0}")]
|
||||||
InvalidScript(String),
|
InvalidScript(String),
|
||||||
|
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(crate::AppId),
|
||||||
|
|
||||||
|
#[error("domain claim conflict: {0}")]
|
||||||
|
DomainConflict(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{RequestId, ScriptId};
|
use crate::{AppId, RequestId, ScriptId};
|
||||||
|
|
||||||
/// One row in the `execution_logs` table. Same shape flows through the
|
/// One row in the `execution_logs` table. Same shape flows through the
|
||||||
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ExecutionLog {
|
pub struct ExecutionLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app at the time of execution. Materialized at write time
|
||||||
|
/// so a future "move script to another app" doesn't retag history.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
pub request_id: RequestId,
|
pub request_id: RequestId,
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,5 @@ id_type!(ScriptId);
|
|||||||
id_type!(ExecutionId);
|
id_type!(ExecutionId);
|
||||||
id_type!(RequestId);
|
id_type!(RequestId);
|
||||||
id_type!(AdminUserId);
|
id_type!(AdminUserId);
|
||||||
|
id_type!(AppId);
|
||||||
|
id_type!(ApiKeyId);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
//! that core's crate. Things here must be genuinely shared (IDs, the Script
|
||||||
//! entity, error roots, transport DTOs).
|
//! entity, error roots, transport DTOs).
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -14,9 +16,11 @@ pub mod script;
|
|||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
|
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, 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;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::ScriptId;
|
use crate::{AppId, ScriptId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -40,6 +40,10 @@ pub enum PathKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Route {
|
pub struct Route {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
||||||
|
/// Carried on the route row so the orchestrator can partition the
|
||||||
|
/// route table without joining back to scripts on every refresh.
|
||||||
|
pub app_id: AppId,
|
||||||
pub script_id: ScriptId,
|
pub script_id: ScriptId,
|
||||||
|
|
||||||
pub host_kind: HostKind,
|
pub host_kind: HostKind,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{ScriptId, ScriptSandbox};
|
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
/// A user-uploaded Rhai script and its execution configuration.
|
/// A user-uploaded Rhai script and its execution configuration.
|
||||||
///
|
///
|
||||||
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
pub id: ScriptId,
|
pub id: ScriptId,
|
||||||
|
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||||
|
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||||
|
/// semantics — see blueprint §11.5).
|
||||||
|
pub app_id: AppId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
|||||||
17
dashboard/package-lock.json
generated
17
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.0",
|
"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",
|
||||||
@@ -1275,7 +1275,6 @@
|
|||||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1318,7 +1317,6 @@
|
|||||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
@@ -1398,7 +1396,6 @@
|
|||||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1455,7 +1452,6 @@
|
|||||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.4",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.4",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
@@ -1563,7 +1559,6 @@
|
|||||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -1815,7 +1810,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2217,7 +2211,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3010,7 +3003,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3038,7 +3030,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3172,7 +3163,6 @@
|
|||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3433,7 +3423,6 @@
|
|||||||
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3614,7 +3603,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3677,7 +3665,6 @@
|
|||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
328
dashboard/src/lib/ConfirmModal.svelte
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<!--
|
||||||
|
Confirmation modal — replaces window.confirm/prompt for destructive
|
||||||
|
actions so the dashboard can render the full context (counts, lists,
|
||||||
|
warnings) in its own style.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{#if showing}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete app"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete app"
|
||||||
|
confirmPhrase={app.slug}
|
||||||
|
onConfirm={() => doDelete()}
|
||||||
|
onCancel={() => (showing = false)}
|
||||||
|
>
|
||||||
|
<p>Body content — counts, lists, warnings.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
When `confirmPhrase` is set the confirm button stays disabled until
|
||||||
|
the user types the phrase exactly — same pattern GitHub uses for
|
||||||
|
irreversible repo deletes. Omit it for low-stakes confirmations.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Variant = 'danger' | 'neutral';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
children: Snippet;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
variant?: Variant;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** When set, the confirm button is disabled until the user types
|
||||||
|
* this string exactly (case-sensitive). */
|
||||||
|
confirmPhrase?: string;
|
||||||
|
/** Shown above the confirm input. Defaults to a sensible message
|
||||||
|
* that mentions the phrase. */
|
||||||
|
confirmPhrasePrompt?: string;
|
||||||
|
/** While true the buttons are disabled and the confirm label is
|
||||||
|
* replaced with a "busy" form (e.g. "Deleting…"). */
|
||||||
|
busy?: boolean;
|
||||||
|
busyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = 'neutral',
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
confirmPhrase,
|
||||||
|
confirmPhrasePrompt,
|
||||||
|
busy = false,
|
||||||
|
busyLabel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let typed = $state('');
|
||||||
|
let phraseMatches = $derived(
|
||||||
|
confirmPhrase === undefined || typed === confirmPhrase
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdrop(event: MouseEvent) {
|
||||||
|
// Only cancel when clicking the backdrop itself, not bubbled
|
||||||
|
// clicks from the dialog content.
|
||||||
|
if (event.target === event.currentTarget && !busy) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!phraseMatches || busy) return;
|
||||||
|
await onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus management: when the modal mounts, focus the slug-retype
|
||||||
|
// input (if present) or the cancel button (so an accidental Enter
|
||||||
|
// doesn't auto-confirm a destructive action).
|
||||||
|
let inputRef = $state<HTMLInputElement | null>(null);
|
||||||
|
let cancelRef = $state<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (inputRef) inputRef.focus();
|
||||||
|
else if (cancelRef) cancelRef.focus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={handleBackdrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
class:danger={variant === 'danger'}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title">{title}</h2>
|
||||||
|
<div class="body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmPhrase}
|
||||||
|
<label class="phrase">
|
||||||
|
<span>
|
||||||
|
{confirmPhrasePrompt ?? `Type the slug to confirm:`}
|
||||||
|
<code>{confirmPhrase}</code>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
bind:this={inputRef}
|
||||||
|
bind:value={typed}
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
bind:this={cancelRef}
|
||||||
|
onclick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={variant === 'danger' ? 'danger' : ''}
|
||||||
|
onclick={handleConfirm}
|
||||||
|
disabled={!phraseMatches || busy}
|
||||||
|
>
|
||||||
|
{busy ? (busyLabel ?? `${confirmLabel}…`) : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.danger {
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.danger h2 {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(p) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(code) {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(strong) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(ul) {
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list) {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list li) {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.impact-list li + li) {
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.modal-error) {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body :global(.muted) {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,6 +21,7 @@ export interface ScriptSandbox {
|
|||||||
|
|
||||||
export interface Script {
|
export interface Script {
|
||||||
id: string;
|
id: string;
|
||||||
|
app_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -32,11 +33,64 @@ export interface Script {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface App {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||||
|
|
||||||
|
export interface AppDomain {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
pattern: string;
|
||||||
|
shape: DomainShape;
|
||||||
|
shape_key: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppLookupResponse {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
/// Present only when the requested slug was a retired redirect.
|
||||||
|
redirect_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlugCheckResponse {
|
||||||
|
ok: boolean;
|
||||||
|
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
|
||||||
|
current_app: App | null;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAppInput {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
force_takeover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchAppInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
force_takeover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type HostKind = 'any' | 'strict' | 'wildcard';
|
export type HostKind = 'any' | 'strict' | 'wildcard';
|
||||||
export type PathKind = 'exact' | 'prefix' | 'param';
|
export type PathKind = 'exact' | 'prefix' | 'param';
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
id: string;
|
id: string;
|
||||||
|
app_id: string;
|
||||||
script_id: string;
|
script_id: string;
|
||||||
host_kind: HostKind;
|
host_kind: HostKind;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -106,6 +160,7 @@ export interface ExecutionLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateScriptInput {
|
export interface CreateScriptInput {
|
||||||
|
app_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -257,20 +312,23 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
remove: (routeId: string) =>
|
remove: (routeId: string) =>
|
||||||
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
||||||
check: (input: RouteInput) =>
|
check: (appId: string, input: RouteInput) =>
|
||||||
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify({ ...input, app_id: appId })
|
||||||
}),
|
}),
|
||||||
match: (url: string, method = 'GET') =>
|
match: (appId: string, url: string, method = 'GET') =>
|
||||||
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url, method })
|
body: JSON.stringify({ app_id: appId, url, method })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
scripts: {
|
scripts: {
|
||||||
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
|
list: (opts: { app?: string } = {}) => {
|
||||||
|
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
|
||||||
|
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
|
||||||
|
},
|
||||||
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
||||||
create: (input: CreateScriptInput) =>
|
create: (input: CreateScriptInput) =>
|
||||||
adminRequest<Script>('/api/v1/admin/scripts', {
|
adminRequest<Script>('/api/v1/admin/scripts', {
|
||||||
@@ -295,6 +353,54 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
apps: {
|
||||||
|
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
|
||||||
|
get: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
|
||||||
|
create: (input: CreateAppInput) =>
|
||||||
|
adminRequest<App>('/api/v1/admin/apps', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
update: (idOrSlug: string, input: PatchAppInput) =>
|
||||||
|
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
|
||||||
|
const qs = opts.force ? '?force=true' : '';
|
||||||
|
return adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
slugCheck: (idOrSlug: string, newSlug: string) =>
|
||||||
|
adminRequest<SlugCheckResponse>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_slug: newSlug })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
domains: {
|
||||||
|
listForApp: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppDomain[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
|
||||||
|
),
|
||||||
|
create: (idOrSlug: string, pattern: string) =>
|
||||||
|
adminRequest<AppDomain>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ pattern }) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, domainId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HostKind, PathKind } from './api';
|
import type { AppDomain, HostKind, PathKind } from './api';
|
||||||
|
|
||||||
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
||||||
* the kind selector but the user can override (the backend trusts the
|
* the kind selector but the user can override (the backend trusts the
|
||||||
@@ -30,3 +30,97 @@ export function pathKindMismatchWarning(raw: string, kind: PathKind): string | n
|
|||||||
: 'this looks like a literal path';
|
: 'this looks like a literal path';
|
||||||
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the user's free-text host input into the (kind, stored host)
|
||||||
|
* pair the API expects. Mirrors the dashboard's pre-existing storage
|
||||||
|
* convention: wildcard route hosts are stored as the bare suffix
|
||||||
|
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
|
||||||
|
export interface ParsedHostInput {
|
||||||
|
kind: HostKind;
|
||||||
|
/** What gets sent in the API payload as `host`. */
|
||||||
|
host: string;
|
||||||
|
/** Canonical display form, for chips/labels. */
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHostInput(raw: string): ParsedHostInput {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '' || trimmed === '*') {
|
||||||
|
return { kind: 'any', host: '', display: '*' };
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('*.')) {
|
||||||
|
const suffix = trimmed.slice(2);
|
||||||
|
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
|
||||||
|
}
|
||||||
|
return { kind: 'strict', host: trimmed, display: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frontend mirror of `validate_route_host_against_app` in
|
||||||
|
* crates/manager-core/src/route_admin.rs. Lets us surface a live
|
||||||
|
* warning instead of waiting for a 422. The server runs the same
|
||||||
|
* check on submit — this is purely an early signal. */
|
||||||
|
export type HostClaimCheck =
|
||||||
|
| { ok: true; matched: string }
|
||||||
|
| { ok: false; reason: string };
|
||||||
|
|
||||||
|
export function checkHostAgainstClaims(
|
||||||
|
parsed: ParsedHostInput,
|
||||||
|
claims: AppDomain[]
|
||||||
|
): HostClaimCheck {
|
||||||
|
if (parsed.kind === 'any') return { ok: true, matched: '*' };
|
||||||
|
if (claims.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'this app has no domain claims yet — add one in the app’s Domains tab'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hostLower = parsed.host.toLowerCase();
|
||||||
|
for (const claim of claims) {
|
||||||
|
const claimLower = claim.pattern.toLowerCase();
|
||||||
|
const claimSuffix = claimLower.split('.').slice(1).join('.');
|
||||||
|
if (parsed.kind === 'strict') {
|
||||||
|
if (claim.shape === 'exact' && hostLower === claimLower) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
|
||||||
|
claimSuffix &&
|
||||||
|
hostLower.endsWith(`.${claimSuffix}`)
|
||||||
|
) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// wildcard route
|
||||||
|
if (
|
||||||
|
(claim.shape === 'wildcard' || claim.shape === 'parameterized') &&
|
||||||
|
claimSuffix === hostLower
|
||||||
|
) {
|
||||||
|
return { ok: true, matched: claim.pattern };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `${parsed.display} is not covered by any of this app’s claims`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggestion strings for the host input's <datalist>. We always
|
||||||
|
* include the wildcard `*` (any host) as the first option, then the
|
||||||
|
* app's claims rendered in the form the route input expects. */
|
||||||
|
export function hostSuggestions(claims: AppDomain[]): string[] {
|
||||||
|
const items: string[] = ['*'];
|
||||||
|
for (const claim of claims) {
|
||||||
|
if (claim.shape === 'exact') {
|
||||||
|
items.push(claim.pattern);
|
||||||
|
} else {
|
||||||
|
// Both wildcard and parameterized claims are usable as a
|
||||||
|
// wildcard route. Render as `*.suffix` — we can't preserve
|
||||||
|
// the {param} binding name through the current route form.
|
||||||
|
const suffix = claim.pattern.split('.').slice(1).join('.');
|
||||||
|
if (suffix) items.push(`*.${suffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dedupe while preserving order.
|
||||||
|
return [...new Set(items)];
|
||||||
|
}
|
||||||
|
|||||||
30
dashboard/src/lib/slugify.ts
Normal file
30
dashboard/src/lib/slugify.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Slug normalization for app slugs, mirrored against the backend's
|
||||||
|
// validate_slug rules in crates/manager-core/src/apps_api.rs:
|
||||||
|
// - regex: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||||
|
// - 1..=63 chars, lowercase ascii alphanumerics + `-`
|
||||||
|
// - must start with [a-z0-9]
|
||||||
|
// - reserved words are enforced server-side only
|
||||||
|
//
|
||||||
|
// Normalization rules are GitLab-style (close to `Babosa::Latin#to_slug`):
|
||||||
|
// 1. NFKD-decompose Unicode and drop combining marks (é → e, ñ → n,
|
||||||
|
// ü → u, etc.).
|
||||||
|
// 2. ß → ss (a single common case the strip-marks pass misses).
|
||||||
|
// 3. Lowercase.
|
||||||
|
// 4. Replace any run of non-[a-z0-9] with a single `-`.
|
||||||
|
// 5. Trim leading/trailing `-`.
|
||||||
|
// 6. Truncate to 63 chars.
|
||||||
|
|
||||||
|
export const SLUG_MAX = 63;
|
||||||
|
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
if (!input) return '';
|
||||||
|
let s = input.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
s = s.toLowerCase().replace(/ß/g, 'ss');
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
s = s.replace(/^-+|-+$/g, '');
|
||||||
|
if (s.length > SLUG_MAX) {
|
||||||
|
// Truncate, then re-trim in case the cut landed on a `-`.
|
||||||
|
s = s.slice(0, SLUG_MAX).replace(/-+$/g, '');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/'}>Scripts</a>
|
<a href={base + '/apps'}>Apps</a>
|
||||||
<a href={base + '/admins'}>Admins</a>
|
<a href={base + '/admins'}>Admins</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
|||||||
@@ -1,242 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type Script } from '$lib/api';
|
import { goto } from '$app/navigation';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
// Dashboard entry: always lands on the apps list now (multi-app
|
||||||
|
// scoping makes "scripts at root" no longer meaningful — every
|
||||||
let scripts = $state<Script[] | null>(null);
|
// script lives inside an app).
|
||||||
let listError = $state<string | null>(null);
|
onMount(() => {
|
||||||
let loading = $state(true);
|
void goto(`${base}/apps`, { replaceState: true });
|
||||||
|
|
||||||
let showCreate = $state(false);
|
|
||||||
let createName = $state('');
|
|
||||||
let createDescription = $state('');
|
|
||||||
let createSource = $state(SAMPLE_SOURCE);
|
|
||||||
let creating = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
listError = null;
|
|
||||||
try {
|
|
||||||
scripts = await api.scripts.list();
|
|
||||||
} catch (e) {
|
|
||||||
listError = e instanceof Error ? e.message : String(e);
|
|
||||||
scripts = null;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate(event: Event) {
|
|
||||||
event.preventDefault();
|
|
||||||
creating = true;
|
|
||||||
createError = null;
|
|
||||||
try {
|
|
||||||
await api.scripts.create({
|
|
||||||
name: createName.trim(),
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
source: createSource
|
|
||||||
});
|
|
||||||
showCreate = false;
|
|
||||||
createName = '';
|
|
||||||
createDescription = '';
|
|
||||||
createSource = SAMPLE_SOURCE;
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof Error ? e.message : String(e);
|
|
||||||
if (e instanceof ApiError && e.status === 422) {
|
|
||||||
createError = `Syntax error: ${createError}`;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
creating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void load();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<p class="muted">Redirecting…</p>
|
||||||
<header class="page-header">
|
|
||||||
<h1>Scripts</h1>
|
|
||||||
<button type="button" onclick={() => (showCreate = !showCreate)}>
|
|
||||||
{showCreate ? 'Cancel' : 'New script'}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if showCreate}
|
|
||||||
<form class="create-form" onsubmit={submitCreate}>
|
|
||||||
<div class="row">
|
|
||||||
<label>
|
|
||||||
<span>Name</span>
|
|
||||||
<input bind:value={createName} required minlength="1" placeholder="echo" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Description</span>
|
|
||||||
<input bind:value={createDescription} placeholder="optional" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="full">
|
|
||||||
<span>Source (Rhai)</span>
|
|
||||||
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
|
|
||||||
</label>
|
|
||||||
{#if createError}
|
|
||||||
<div class="error">{createError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" disabled={creating}>
|
|
||||||
{creating ? 'Creating…' : 'Create script'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="muted">Loading…</p>
|
|
||||||
{:else if listError}
|
|
||||||
<div class="error">
|
|
||||||
<strong>Could not load scripts.</strong>
|
|
||||||
<p>{listError}</p>
|
|
||||||
<button type="button" onclick={() => void load()}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else if scripts && scripts.length === 0}
|
|
||||||
<p class="muted">No scripts yet. Create one above to get started.</p>
|
|
||||||
{:else if scripts}
|
|
||||||
<ul class="list">
|
|
||||||
{#each scripts as script (script.id)}
|
|
||||||
<li>
|
|
||||||
<a href="{base}/scripts/{script.id}">
|
|
||||||
<div class="primary">
|
|
||||||
<strong>{script.name}</strong>
|
|
||||||
<span class="muted">v{script.version}</span>
|
|
||||||
</div>
|
|
||||||
<div class="secondary muted">
|
|
||||||
{script.description ?? '—'}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: #38bdf8;
|
|
||||||
color: #0b1220;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form {
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form .row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form label.full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form input {
|
|
||||||
background: #0b1220;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list a {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
background: #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list a:hover {
|
|
||||||
background: #283549;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
342
dashboard/src/routes/apps/+page.svelte
Normal file
342
dashboard/src/routes/apps/+page.svelte
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
|
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
|
|
||||||
|
let apps = $state<App[] | null>(null);
|
||||||
|
let listError = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let createSlug = $state('');
|
||||||
|
let createName = $state('');
|
||||||
|
let createDescription = $state('');
|
||||||
|
// Auto-derive slug from name until the user takes manual control of
|
||||||
|
// the slug field. Clearing the slug input releases the lock so the
|
||||||
|
// auto-derive resumes — matches the GitLab project-create UX.
|
||||||
|
let slugTouched = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state<string | null>(null);
|
||||||
|
let createHistoricalConflict = $state<App | null>(null);
|
||||||
|
|
||||||
|
function onNameInput(event: Event) {
|
||||||
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
createName = value;
|
||||||
|
if (!slugTouched) {
|
||||||
|
createSlug = slugify(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlugInput(event: Event) {
|
||||||
|
const raw = (event.target as HTMLInputElement).value;
|
||||||
|
const normalized = slugify(raw);
|
||||||
|
createSlug = normalized;
|
||||||
|
// Re-sync the input element so a paste of "Hello World!" shows
|
||||||
|
// "hello-world" immediately, not the raw value.
|
||||||
|
if (raw !== normalized) {
|
||||||
|
(event.target as HTMLInputElement).value = normalized;
|
||||||
|
}
|
||||||
|
slugTouched = normalized.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
listError = null;
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch (e) {
|
||||||
|
listError = e instanceof Error ? e.message : String(e);
|
||||||
|
apps = null;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreate() {
|
||||||
|
createSlug = '';
|
||||||
|
createName = '';
|
||||||
|
createDescription = '';
|
||||||
|
createError = null;
|
||||||
|
createHistoricalConflict = null;
|
||||||
|
slugTouched = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: Event, forceTakeover = false) {
|
||||||
|
event.preventDefault();
|
||||||
|
creating = true;
|
||||||
|
createError = null;
|
||||||
|
if (!forceTakeover) createHistoricalConflict = null;
|
||||||
|
try {
|
||||||
|
await api.apps.create({
|
||||||
|
slug: createSlug.trim(),
|
||||||
|
name: createName.trim(),
|
||||||
|
description: createDescription.trim() || null,
|
||||||
|
force_takeover: forceTakeover || undefined
|
||||||
|
});
|
||||||
|
showCreate = false;
|
||||||
|
resetCreate();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||||||
|
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||||||
|
if (body.conflict_kind === 'historical' && body.current_app) {
|
||||||
|
createHistoricalConflict = body.current_app;
|
||||||
|
createError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Apps</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showCreate = !showCreate;
|
||||||
|
if (!showCreate) resetCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showCreate ? 'Cancel' : 'New app'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
value={createName}
|
||||||
|
oninput={onNameInput}
|
||||||
|
required
|
||||||
|
placeholder="My App"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
value={createSlug}
|
||||||
|
oninput={onSlugInput}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
maxlength={SLUG_MAX}
|
||||||
|
placeholder="my-app"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={createDescription} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
{#if createHistoricalConflict}
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Slug previously redirected.</strong>
|
||||||
|
<p>
|
||||||
|
<code>{createSlug}</code> currently redirects to
|
||||||
|
<code>{createHistoricalConflict.slug}</code>. Using it here will break any
|
||||||
|
external links that still target the old slug.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="secondary" onclick={() => (createHistoricalConflict = null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => submitCreate(e, true)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{creating ? 'Claiming…' : 'Claim slug anyway'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if createError}
|
||||||
|
<div class="error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !createHistoricalConflict}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create app'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if listError}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Could not load apps.</strong>
|
||||||
|
<p>{listError}</p>
|
||||||
|
<button type="button" onclick={() => void load()}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if apps && apps.length === 0}
|
||||||
|
<p class="muted">No apps yet. Create one above to get started.</p>
|
||||||
|
{:else if apps}
|
||||||
|
<ul class="list">
|
||||||
|
{#each apps as app (app.id)}
|
||||||
|
<li>
|
||||||
|
<a href="{base}/apps/{app.slug}">
|
||||||
|
<div class="primary">
|
||||||
|
<strong>{app.name}</strong>
|
||||||
|
<span class="muted">/{app.slug}</span>
|
||||||
|
</div>
|
||||||
|
<div class="secondary muted">
|
||||||
|
{app.description ?? '—'}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
background: #3f2e07;
|
||||||
|
color: #fde68a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a:hover {
|
||||||
|
background: #283549;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
747
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
747
dashboard/src/routes/apps/[slug]/+page.svelte
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type App,
|
||||||
|
type AppDomain,
|
||||||
|
type Script
|
||||||
|
} from '$lib/api';
|
||||||
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
|
||||||
|
const SAMPLE_SOURCE =
|
||||||
|
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
|
|
||||||
|
type Tab = 'scripts' | 'domains' | 'settings';
|
||||||
|
|
||||||
|
let slug = $derived(page.params.slug ?? '');
|
||||||
|
let app = $state<App | null>(null);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let activeTab = $state<Tab>('scripts');
|
||||||
|
|
||||||
|
let scripts = $state<Script[]>([]);
|
||||||
|
let domains = $state<AppDomain[]>([]);
|
||||||
|
|
||||||
|
// Script create
|
||||||
|
let showCreateScript = $state(false);
|
||||||
|
let createScriptName = $state('');
|
||||||
|
let createScriptDescription = $state('');
|
||||||
|
let createScriptSource = $state(SAMPLE_SOURCE);
|
||||||
|
let creatingScript = $state(false);
|
||||||
|
let createScriptError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Domain create
|
||||||
|
let createDomainPattern = $state('');
|
||||||
|
let creatingDomain = $state(false);
|
||||||
|
let createDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
let editName = $state('');
|
||||||
|
let editDescription = $state('');
|
||||||
|
let editSlug = $state('');
|
||||||
|
let savingSettings = $state(false);
|
||||||
|
let settingsError = $state<string | null>(null);
|
||||||
|
let slugTakeoverNeeded = $state<App | null>(null);
|
||||||
|
|
||||||
|
// Delete confirmations
|
||||||
|
let confirmingDeleteApp = $state(false);
|
||||||
|
let deletingApp = $state(false);
|
||||||
|
let deleteAppError = $state<string | null>(null);
|
||||||
|
let domainToRemove = $state<AppDomain | null>(null);
|
||||||
|
let removingDomain = $state(false);
|
||||||
|
let removeDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function loadApp() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const fetched = await api.apps.get(slug);
|
||||||
|
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||||||
|
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app = {
|
||||||
|
id: fetched.id,
|
||||||
|
slug: fetched.slug,
|
||||||
|
name: fetched.name,
|
||||||
|
description: fetched.description,
|
||||||
|
created_at: fetched.created_at,
|
||||||
|
updated_at: fetched.updated_at
|
||||||
|
};
|
||||||
|
editName = app.name;
|
||||||
|
editDescription = app.description ?? '';
|
||||||
|
editSlug = app.slug;
|
||||||
|
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScripts(appId: string) {
|
||||||
|
try {
|
||||||
|
scripts = await api.scripts.list({ app: appId });
|
||||||
|
} catch (e) {
|
||||||
|
scripts = [];
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDomains(appId: string) {
|
||||||
|
try {
|
||||||
|
domains = await api.domains.listForApp(appId);
|
||||||
|
} catch (e) {
|
||||||
|
domains = [];
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateScript(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingScript = true;
|
||||||
|
createScriptError = null;
|
||||||
|
try {
|
||||||
|
await api.scripts.create({
|
||||||
|
app_id: app.id,
|
||||||
|
name: createScriptName.trim(),
|
||||||
|
description: createScriptDescription.trim() || null,
|
||||||
|
source: createScriptSource
|
||||||
|
});
|
||||||
|
showCreateScript = false;
|
||||||
|
createScriptName = '';
|
||||||
|
createScriptDescription = '';
|
||||||
|
createScriptSource = SAMPLE_SOURCE;
|
||||||
|
await loadScripts(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
createScriptError = e instanceof Error ? e.message : String(e);
|
||||||
|
if (e instanceof ApiError && e.status === 422) {
|
||||||
|
createScriptError = `Validation: ${createScriptError}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
creatingScript = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateDomain(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
creatingDomain = true;
|
||||||
|
createDomainError = null;
|
||||||
|
try {
|
||||||
|
await api.domains.create(app.id, createDomainPattern.trim());
|
||||||
|
createDomainPattern = '';
|
||||||
|
await loadDomains(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
createDomainError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
creatingDomain = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveDomain(d: AppDomain) {
|
||||||
|
removeDomainError = null;
|
||||||
|
domainToRemove = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveDomain() {
|
||||||
|
if (!app || !domainToRemove) return;
|
||||||
|
removingDomain = true;
|
||||||
|
removeDomainError = null;
|
||||||
|
try {
|
||||||
|
await api.domains.remove(app.id, domainToRemove.id);
|
||||||
|
domainToRemove = null;
|
||||||
|
await loadDomains(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
removeDomainError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
removingDomain = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(event: Event, forceTakeover = false) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app) return;
|
||||||
|
savingSettings = true;
|
||||||
|
settingsError = null;
|
||||||
|
if (!forceTakeover) slugTakeoverNeeded = null;
|
||||||
|
try {
|
||||||
|
const slugChanged = editSlug.trim() !== app.slug;
|
||||||
|
const updated = await api.apps.update(app.id, {
|
||||||
|
name: editName.trim() !== app.name ? editName.trim() : undefined,
|
||||||
|
description:
|
||||||
|
editDescription !== (app.description ?? '')
|
||||||
|
? editDescription || null
|
||||||
|
: undefined,
|
||||||
|
slug: slugChanged ? editSlug.trim() : undefined,
|
||||||
|
force_takeover: forceTakeover || undefined
|
||||||
|
});
|
||||||
|
if (slugChanged) {
|
||||||
|
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app = updated;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 409 && e.body) {
|
||||||
|
const body = e.body as { conflict_kind?: string; current_app?: App };
|
||||||
|
if (body.conflict_kind === 'historical' && body.current_app) {
|
||||||
|
slugTakeoverNeeded = body.current_app;
|
||||||
|
settingsError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
savingSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDeleteApp() {
|
||||||
|
deleteAppError = null;
|
||||||
|
confirmingDeleteApp = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteApp() {
|
||||||
|
if (!app) return;
|
||||||
|
deletingApp = true;
|
||||||
|
deleteAppError = null;
|
||||||
|
try {
|
||||||
|
// force=true cascades scripts (and thereby their routes +
|
||||||
|
// execution logs); domains and slug-history rows cascade off
|
||||||
|
// the app row itself.
|
||||||
|
await api.apps.remove(app.id, { force: true });
|
||||||
|
await goto(`${base}/apps`);
|
||||||
|
} catch (e) {
|
||||||
|
deleteAppError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
deletingApp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadApp();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading && !app}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if loadError && !app}
|
||||||
|
<div class="error">
|
||||||
|
<strong>Could not load app.</strong>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
<a href="{base}/apps">Back to apps</a>
|
||||||
|
</div>
|
||||||
|
{:else if app}
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{base}/apps">Apps</a> / <code>{app.slug}</code>
|
||||||
|
</div>
|
||||||
|
<h1>{app.name}</h1>
|
||||||
|
{#if app.description}<p class="muted">{app.description}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'scripts'}
|
||||||
|
onclick={() => (activeTab = 'scripts')}>Scripts ({scripts.length})</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'domains'}
|
||||||
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'settings'}
|
||||||
|
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if activeTab === 'scripts'}
|
||||||
|
<section>
|
||||||
|
<div class="row">
|
||||||
|
<h2>Scripts</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showCreateScript = !showCreateScript)}
|
||||||
|
>
|
||||||
|
{showCreateScript ? 'Cancel' : 'New script'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateScript}
|
||||||
|
<form class="create-form" onsubmit={submitCreateScript}>
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={createScriptName} required placeholder="echo" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={createScriptDescription} placeholder="optional" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="full">
|
||||||
|
<span>Source (Rhai)</span>
|
||||||
|
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
|
||||||
|
</label>
|
||||||
|
{#if createScriptError}
|
||||||
|
<div class="error">{createScriptError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={creatingScript}>
|
||||||
|
{creatingScript ? 'Creating…' : 'Create script'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if scripts.length === 0}
|
||||||
|
<p class="muted">No scripts in this app yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each scripts as script (script.id)}
|
||||||
|
<li>
|
||||||
|
<a href="{base}/scripts/{script.id}">
|
||||||
|
<div class="primary">
|
||||||
|
<strong>{script.name}</strong>
|
||||||
|
<span class="muted">v{script.version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="secondary muted">{script.description ?? '—'}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'domains'}
|
||||||
|
<section>
|
||||||
|
<h2>Domain claims</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Hosts this app answers on. Routes inside this app can only bind to
|
||||||
|
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
||||||
|
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||||||
|
</p>
|
||||||
|
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||||
|
<input
|
||||||
|
bind:value={createDomainPattern}
|
||||||
|
required
|
||||||
|
placeholder="app.example.com"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={creatingDomain}>
|
||||||
|
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if createDomainError}
|
||||||
|
<div class="error">{createDomainError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if domains.length === 0}
|
||||||
|
<p class="muted">No domain claims yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each domains as d (d.id)}
|
||||||
|
<li class="domain-row">
|
||||||
|
<div>
|
||||||
|
<code>{d.pattern}</code>
|
||||||
|
<span class="muted">— {d.shape}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary danger"
|
||||||
|
onclick={() => askRemoveDomain(d)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if activeTab === 'settings'}
|
||||||
|
<section>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input bind:value={editName} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<input bind:value={editDescription} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
bind:value={editSlug}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*"
|
||||||
|
/>
|
||||||
|
<small class="muted">
|
||||||
|
Renaming records the old slug as a permanent 301 redirect.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if slugTakeoverNeeded}
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Slug previously redirected.</strong>
|
||||||
|
<p>
|
||||||
|
<code>{editSlug}</code> currently redirects to
|
||||||
|
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
|
||||||
|
links.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => (slugTakeoverNeeded = null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => saveSettings(e, true)}
|
||||||
|
disabled={savingSettings}
|
||||||
|
>
|
||||||
|
{savingSettings ? 'Renaming…' : 'Rename anyway'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if settingsError}
|
||||||
|
<div class="error">{settingsError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if !slugTakeoverNeeded}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" disabled={savingSettings}>
|
||||||
|
{savingSettings ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<h3>Delete app</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Permanently removes the app along with all its scripts, routes,
|
||||||
|
execution logs, and domain claims.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingDeleteApp}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete app “{app.name}”"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete app"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
confirmPhrase={app.slug}
|
||||||
|
confirmPhrasePrompt="Type the app slug to confirm:"
|
||||||
|
busy={deletingApp}
|
||||||
|
onConfirm={confirmDeleteApp}
|
||||||
|
onCancel={() => (confirmingDeleteApp = false)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This will <strong>permanently delete</strong> everything inside
|
||||||
|
<strong>{app.name}</strong>. There is no undo.
|
||||||
|
</p>
|
||||||
|
<ul class="impact-list">
|
||||||
|
<li>
|
||||||
|
<span>Scripts</span><strong>{scripts.length}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Domain claims</span><strong>{domains.length}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Routes & execution logs</span><strong>all</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{#if domains.length > 0}
|
||||||
|
<p>The following hosts will stop pointing at this app:</p>
|
||||||
|
<ul class="impact-list">
|
||||||
|
{#each domains as d (d.id)}
|
||||||
|
<li>
|
||||||
|
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if deleteAppError}
|
||||||
|
<p class="modal-error">{deleteAppError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if domainToRemove}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete domain claim"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete claim"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
busy={removingDomain}
|
||||||
|
onConfirm={confirmRemoveDomain}
|
||||||
|
onCancel={() => (domainToRemove = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong>{app.name}</strong> will stop answering on
|
||||||
|
<code>{domainToRemove.pattern}</code>.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Routes already bound to this host are blocked from deletion by the
|
||||||
|
API; if so, you’ll see an error here.
|
||||||
|
</p>
|
||||||
|
{#if removeDomainError}
|
||||||
|
<p class="modal-error">{removeDomainError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
color: #38bdf8;
|
||||||
|
border-bottom-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary.danger {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
background: #3f2e07;
|
||||||
|
color: #fde68a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form.inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form label.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form input {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list a:hover {
|
||||||
|
background: #283549;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-row code {
|
||||||
|
background: #0b1220;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #1e0a0a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
type AppDomain,
|
||||||
type ExecutionLog,
|
type ExecutionLog,
|
||||||
type Route,
|
type Route,
|
||||||
type RouteInput,
|
type RouteInput,
|
||||||
@@ -12,7 +13,13 @@
|
|||||||
type VersionInfo
|
type VersionInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
import {
|
||||||
|
checkHostAgainstClaims,
|
||||||
|
guessPathKind,
|
||||||
|
hostSuggestions,
|
||||||
|
parseHostInput,
|
||||||
|
pathKindMismatchWarning
|
||||||
|
} from '$lib/route-utils';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
import { format as formatRhai } from '$lib/rhai';
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
@@ -38,6 +45,9 @@
|
|||||||
let scriptLoading = $state(true);
|
let scriptLoading = $state(true);
|
||||||
let info = $state<VersionInfo | null>(null);
|
let info = $state<VersionInfo | null>(null);
|
||||||
|
|
||||||
|
let appSlug = $state<string | null>(null);
|
||||||
|
let appDomains = $state<AppDomain[]>([]);
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
scriptError = null;
|
scriptError = null;
|
||||||
@@ -48,6 +58,23 @@
|
|||||||
editableDescription = script.description ?? '';
|
editableDescription = script.description ?? '';
|
||||||
editableTimeout = script.timeout_seconds;
|
editableTimeout = script.timeout_seconds;
|
||||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||||
|
// Resolve the owning app's slug for the breadcrumb and its
|
||||||
|
// domain claims for the route form's suggestions + live
|
||||||
|
// validation. Both are non-fatal — the page works without
|
||||||
|
// them.
|
||||||
|
const appId = script.app_id;
|
||||||
|
void api.apps
|
||||||
|
.get(appId)
|
||||||
|
.then((a) => {
|
||||||
|
appSlug = a.slug;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
void api.domains
|
||||||
|
.listForApp(appId)
|
||||||
|
.then((d) => {
|
||||||
|
appDomains = d;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
scriptError = e instanceof Error ? e.message : String(e);
|
scriptError = e instanceof Error ? e.message : String(e);
|
||||||
script = null;
|
script = null;
|
||||||
@@ -165,12 +192,14 @@
|
|||||||
let routesLoading = $state(true);
|
let routesLoading = $state(true);
|
||||||
|
|
||||||
let showAddRoute = $state(false);
|
let showAddRoute = $state(false);
|
||||||
let newRoutePath = $state('');
|
let newRoutePath = $state('/');
|
||||||
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
||||||
let newRouteHost = $state('');
|
// Host input is free-form; the kind is derived from what the user
|
||||||
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
|
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
||||||
|
// canonical display form for an unrestricted host.
|
||||||
|
let newRouteHost = $state('*');
|
||||||
let newRouteMethod = $state('');
|
let newRouteMethod = $state('');
|
||||||
let routeKindAutoUpdate = $state(true);
|
let pathKindAutoUpdate = $state(true);
|
||||||
let creatingRoute = $state(false);
|
let creatingRoute = $state(false);
|
||||||
let createRouteError = $state<string | null>(null);
|
let createRouteError = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -178,17 +207,17 @@
|
|||||||
let previewMethod = $state('GET');
|
let previewMethod = $state('GET');
|
||||||
let previewResult = $state<string | null>(null);
|
let previewResult = $state<string | null>(null);
|
||||||
|
|
||||||
// Auto-update kind selectors as the user types.
|
// Auto-update the path-kind selector as the user types.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (routeKindAutoUpdate) {
|
if (pathKindAutoUpdate) {
|
||||||
newRoutePathKind = guessPathKind(newRoutePath);
|
newRoutePathKind = guessPathKind(newRoutePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
|
||||||
if (routeKindAutoUpdate) {
|
let parsedHost = $derived(parseHostInput(newRouteHost));
|
||||||
newRouteHostKind = guessHostKind(newRouteHost);
|
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
||||||
}
|
let hostDatalistId = 'route-host-suggestions';
|
||||||
});
|
let suggestions = $derived(hostSuggestions(appDomains));
|
||||||
|
|
||||||
let pathKindWarning = $derived(
|
let pathKindWarning = $derived(
|
||||||
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
||||||
@@ -212,18 +241,18 @@
|
|||||||
createRouteError = null;
|
createRouteError = null;
|
||||||
try {
|
try {
|
||||||
const input: RouteInput = {
|
const input: RouteInput = {
|
||||||
host_kind: newRouteHostKind,
|
host_kind: parsedHost.kind,
|
||||||
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
|
host: parsedHost.host,
|
||||||
path_kind: newRoutePathKind,
|
path_kind: newRoutePathKind,
|
||||||
path: newRoutePath.trim(),
|
path: newRoutePath.trim(),
|
||||||
method: newRouteMethod.trim() || null
|
method: newRouteMethod.trim() || null
|
||||||
};
|
};
|
||||||
await api.routes.create(id, input);
|
await api.routes.create(id, input);
|
||||||
showAddRoute = false;
|
showAddRoute = false;
|
||||||
newRoutePath = '';
|
newRoutePath = '/';
|
||||||
newRouteHost = '';
|
newRouteHost = '*';
|
||||||
newRouteMethod = '';
|
newRouteMethod = '';
|
||||||
routeKindAutoUpdate = true;
|
pathKindAutoUpdate = true;
|
||||||
await loadRoutes();
|
await loadRoutes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 409) {
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
@@ -251,8 +280,9 @@
|
|||||||
|
|
||||||
async function runPreview() {
|
async function runPreview() {
|
||||||
previewResult = null;
|
previewResult = null;
|
||||||
|
if (!script) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.routes.match(previewUrl, previewMethod);
|
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
|
||||||
if (r.matched) {
|
if (r.matched) {
|
||||||
const ours = r.matched.script_id === id;
|
const ours = r.matched.script_id === id;
|
||||||
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
||||||
@@ -368,6 +398,13 @@
|
|||||||
{:else if script}
|
{:else if script}
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
{#if appSlug}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{base}/apps">Apps</a> /
|
||||||
|
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
|
||||||
|
<code>{script.name}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<h1>{script.name}</h1>
|
<h1>{script.name}</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||||
@@ -484,7 +521,7 @@
|
|||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
<input
|
<input
|
||||||
bind:value={newRoutePath}
|
bind:value={newRoutePath}
|
||||||
oninput={() => (routeKindAutoUpdate = true)}
|
oninput={() => (pathKindAutoUpdate = true)}
|
||||||
placeholder="/greet, /greet/:name, /webhooks/*"
|
placeholder="/greet, /greet/:name, /webhooks/*"
|
||||||
required
|
required
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -495,7 +532,7 @@
|
|||||||
<span>Path kind</span>
|
<span>Path kind</span>
|
||||||
<select
|
<select
|
||||||
bind:value={newRoutePathKind}
|
bind:value={newRoutePathKind}
|
||||||
onchange={() => (routeKindAutoUpdate = false)}
|
onchange={() => (pathKindAutoUpdate = false)}
|
||||||
>
|
>
|
||||||
<option value="exact">exact</option>
|
<option value="exact">exact</option>
|
||||||
<option value="param">param</option>
|
<option value="param">param</option>
|
||||||
@@ -514,31 +551,43 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<label class="full">
|
||||||
<label>
|
<span class="host-label">
|
||||||
<span>Host kind</span>
|
Host
|
||||||
<select
|
<span class="kind-chip kind-{parsedHost.kind}">
|
||||||
bind:value={newRouteHostKind}
|
{parsedHost.kind}
|
||||||
onchange={() => (routeKindAutoUpdate = false)}
|
</span>
|
||||||
>
|
</span>
|
||||||
<option value="any">ANY</option>
|
|
||||||
<option value="strict">strict</option>
|
|
||||||
<option value="wildcard">wildcard</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class:disabled={newRouteHostKind === 'any'}>
|
|
||||||
<span>Host</span>
|
|
||||||
<input
|
<input
|
||||||
bind:value={newRouteHost}
|
bind:value={newRouteHost}
|
||||||
oninput={() => (routeKindAutoUpdate = true)}
|
placeholder="* · app.example.com · *.example.com"
|
||||||
disabled={newRouteHostKind === 'any'}
|
list={hostDatalistId}
|
||||||
placeholder={newRouteHostKind === 'wildcard'
|
|
||||||
? '*.example.com'
|
|
||||||
: 'sub.example.com'}
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
|
<datalist id={hostDatalistId}>
|
||||||
|
{#each suggestions as s (s)}
|
||||||
|
<option value={s}></option>
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
<small class="muted">
|
||||||
|
<code>*</code> = any host claimed by this app ·
|
||||||
|
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
||||||
|
strict
|
||||||
|
</small>
|
||||||
</label>
|
</label>
|
||||||
|
{#if !hostCheck.ok}
|
||||||
|
<div class="warning inline">
|
||||||
|
{hostCheck.reason}.
|
||||||
|
{#if appDomains.length > 0}
|
||||||
|
Claims:
|
||||||
|
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
|
||||||
|
{/if}{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{#if pathKindWarning}
|
{#if pathKindWarning}
|
||||||
<div class="warning inline">{pathKindWarning}</div>
|
<div class="warning inline">{pathKindWarning}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -756,6 +805,23 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin: 1rem 0 1.5rem;
|
margin: 1rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.breadcrumb code {
|
||||||
|
background: #1e293b;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -916,9 +982,6 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
label.disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
label.full {
|
label.full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1035,6 +1098,31 @@
|
|||||||
background: #581c87;
|
background: #581c87;
|
||||||
color: #e9d5ff;
|
color: #e9d5ff;
|
||||||
}
|
}
|
||||||
|
.host-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.kind-chip {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-any {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-strict {
|
||||||
|
background: #14532d;
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
.kind-chip.kind-wildcard {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
.route-row .method {
|
.route-row .method {
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ services:
|
|||||||
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
||||||
|
# Bootstrap admin (Phase 3a). Read once on first start to seed the
|
||||||
|
# admin_users table; ignored on subsequent boots if the table is
|
||||||
|
# non-empty. No defaults on purpose — leaving these unset in prod
|
||||||
|
# is a foot-gun. For dev, .env.example documents sensible values.
|
||||||
|
PICLOUD_ADMIN_USERNAME: ${PICLOUD_ADMIN_USERNAME:?set PICLOUD_ADMIN_USERNAME (see .env.example)}
|
||||||
|
PICLOUD_ADMIN_PASSWORD: ${PICLOUD_ADMIN_PASSWORD:?set PICLOUD_ADMIN_PASSWORD (see .env.example)}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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` |
|
| 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 | `3` (matches `migrations/0003_routes.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.
|
||||||
|
|||||||
@@ -853,7 +853,17 @@ Permission checks land in middleware that initially only enforces "authenticated
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11.5 App Scoping (v1.x)
|
## 11.5 App Scoping (Phase 3b) — Shipped
|
||||||
|
|
||||||
|
**Status**: shipped. Implementation lives in:
|
||||||
|
- `crates/shared/src/{app,ids,script,route}.rs` — `App`, `AppDomain`, `AppId`, `app_id` fields on `Script`/`Route`/`ExecutionLog`.
|
||||||
|
- `crates/manager-core/src/{app_repo,app_domain_repo,apps_api,app_bootstrap}.rs` — repos + admin API + Hello-World seed.
|
||||||
|
- `crates/orchestrator-core/src/routing/{app_domains,pattern,table}.rs` — `AppDomainTable`, `parse_app_domain`, per-app `RouteTable`.
|
||||||
|
- Migration `0005_apps.sql`.
|
||||||
|
|
||||||
|
**Deviations from the design below**: none of substance. Two operational notes:
|
||||||
|
- The Hello-World seed lives in `crates/manager-core/seeds/hello.rhai` and is inserted by a Rust bootstrap step (`seed_hello_world_if_fresh`) rather than from the migration — keeps it testable and gives the dashboard editor real source to render. The migration always inserts the `default` app + `localhost` claim; the seed only fires when that app is otherwise empty.
|
||||||
|
- Per-app admin roles/permissions are deferred — every authenticated admin can act on every app. The middleware seam (`auth_middleware::require_admin`) is the place where role checks slot in later.
|
||||||
|
|
||||||
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
|
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
|
||||||
|
|
||||||
@@ -1012,6 +1022,152 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending
|
||||||
|
|
||||||
|
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`.
|
||||||
|
|
||||||
|
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
|
||||||
|
|
||||||
|
**Why this slot**: same logic as Phase 3b. Adding a `Principal` parameter and a capability check to surfaces that don't exist yet is free; retrofitting them onto live SDK services after v1.1 ships is a refactor of every gate.
|
||||||
|
|
||||||
|
### Principal Model
|
||||||
|
|
||||||
|
One `Principal` value represents a human admin user. Service accounts (CI bots, Rhai scripts calling out) get **schema room** in this phase but no runtime support — `users.kind` style differentiation lands when Phase 4's `users.*` SDK arrives. Until then, every authenticated request resolves to exactly one admin row, whether the credential is a session cookie or a bearer API key.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Principal {
|
||||||
|
pub user_id: UserId, // alias of AdminUserId for the transition
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub scopes: Option<Vec<Scope>>, // None = cookie session (full role authority)
|
||||||
|
// Some = API key (intersect with role)
|
||||||
|
pub app_binding: Option<AppId>, // API key bound to one app; denies other apps
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instance Roles (one per user)
|
||||||
|
|
||||||
|
| Role | Powers |
|
||||||
|
|---|---|
|
||||||
|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||||
|
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
||||||
|
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||||
|
|
||||||
|
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||||
|
|
||||||
|
### App-Scoped Roles (zero-to-many per user × app)
|
||||||
|
|
||||||
|
| Role | Grants |
|
||||||
|
|---|---|
|
||||||
|
| `app_admin` | settings, domain claims, delete app |
|
||||||
|
| `editor` | CRUD on scripts, routes, sandbox config |
|
||||||
|
| `viewer` | read scripts + execution logs |
|
||||||
|
|
||||||
|
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
||||||
|
|
||||||
|
### Auth Methods — Same Principal, Different Extractor
|
||||||
|
|
||||||
|
Two credential types feed the same middleware:
|
||||||
|
|
||||||
|
1. **Session cookie** (Phase 3a, unchanged) — `picloud_session=<token>`. Extracted by header or cookie. SHA-256 lookup against `admin_sessions.token_hash`. Sliding 24h TTL. Produces `Principal { scopes: None, app_binding: None }`.
|
||||||
|
|
||||||
|
2. **Bearer API key** (new) — `Authorization: Bearer pic_<base32(32 random bytes)>`. The `pic_` prefix is the discriminator: present → API key path; absent → session path. The 8 chars immediately after `pic_` are indexed (`api_keys.prefix`); the full body after `pic_` is Argon2id-verified against each candidate's `hash`. Last-used timestamp updates inline.
|
||||||
|
|
||||||
|
Both paths converge on the same `Principal` extension; handlers cannot tell which credential was presented unless they introspect `principal.scopes`.
|
||||||
|
|
||||||
|
### API Key Format & Storage
|
||||||
|
|
||||||
|
- Raw form: `pic_<base32(32 random bytes, no padding)>` — ~56 chars total.
|
||||||
|
- Stored: 8-char prefix + Argon2id PHC hash of the body. Raw value returned **exactly once** in the `POST /api/v1/admin/api-keys` response; never logged, never readable again.
|
||||||
|
- Optional `expires_at`. Lookup queries always filter `expires_at IS NULL OR expires_at > NOW()`.
|
||||||
|
- Optional `app_id` ("bound key") — every `App*(other_app)` capability is denied for this key, regardless of the user's role.
|
||||||
|
|
||||||
|
### Scope Set (intentionally narrow)
|
||||||
|
|
||||||
|
Exactly seven scopes; no further subdivision until a real use case appears:
|
||||||
|
|
||||||
|
`script:read`, `script:write`, `route:write`, `domain:manage`, `log:read`, `app:admin`, `instance:admin`
|
||||||
|
|
||||||
|
Mint-time validation rejects unknown values. Bound keys (`app_id` set) cannot carry `instance:*` scopes — the combination is irreconcilable (a bound credential cannot claim instance-wide authority) and is rejected with 422.
|
||||||
|
|
||||||
|
### Effective Capability — `can(principal, capability)`
|
||||||
|
|
||||||
|
```
|
||||||
|
allow = role_grants(principal.instance_role, capability)
|
||||||
|
∧ (principal.scopes.is_none() ∨ required_scope(capability) ∈ principal.scopes)
|
||||||
|
∧ (principal.app_binding.is_none() ∨ capability.app_id() == principal.app_binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
`role_grants` collapses the three tables (instance role + implicit app grants + explicit `app_members`) into a single yes/no. Each handler calls `state.authz.require(&principal, Capability::AppWrite(script.app_id))` after loading the resource (so the capability binds to the resource's actual `app_id`, not a path param the caller controls).
|
||||||
|
|
||||||
|
### Deactivation Symmetry
|
||||||
|
|
||||||
|
Phase 3a's `set_active(false)` wipes that user's `admin_sessions`. Phase 3.5 extends it to also set `expires_at = NOW()` on every row in `api_keys WHERE user_id = $1` — both credential surfaces become inert at the same moment, no enumeration window.
|
||||||
|
|
||||||
|
### CLI Auth Posture (forward note)
|
||||||
|
|
||||||
|
The eventual `picloud` CLI authenticates by **paste-the-token**, not OAuth: the user runs `picloud login`, the dashboard mints a fresh key (or the user mints one via `POST /api/v1/admin/api-keys`), and the CLI prompts for the raw token. The CLI binary itself is deferred; the dashboard surface and the bearer credential type land here so the CLI is a thin wrapper when it arrives.
|
||||||
|
|
||||||
|
### Schema (Migration 0006)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||||
|
CHECK (instance_role IN ('owner','admin','member')),
|
||||||
|
ADD COLUMN email TEXT UNIQUE,
|
||||||
|
ADD COLUMN mfa_secret TEXT; -- reserved slot, not built
|
||||||
|
|
||||||
|
CREATE TABLE app_members (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin','editor','viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
hash TEXT NOT NULL, -- Argon2id PHC
|
||||||
|
prefix TEXT NOT NULL, -- first 8 chars after `pic_`
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT[] NOT NULL, -- intersected with role at check time
|
||||||
|
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||||
|
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||||
|
|
||||||
|
-- Reserved (not built this phase):
|
||||||
|
-- invites (token, email, instance_role, app_id, app_role, invited_by, expires_at, consumed_at)
|
||||||
|
-- service_accounts (id, name, owning_user_id, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Endpoints (additive — no API major bump)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/api-keys — { name, scopes[], app_id?, expires_at? }
|
||||||
|
→ 201 { …, raw_token } (raw returned exactly once)
|
||||||
|
GET /api/v1/admin/api-keys — list caller's own keys (no raw)
|
||||||
|
DELETE /api/v1/admin/api-keys/{id} — caller's own only
|
||||||
|
```
|
||||||
|
|
||||||
|
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
||||||
|
|
||||||
|
### Out of Scope (Phase 3.5)
|
||||||
|
|
||||||
|
Schema room only, not built:
|
||||||
|
|
||||||
|
- **Invites** — email-based join flow; `invites` table reserved in the migration comment block.
|
||||||
|
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
||||||
|
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
||||||
|
|
||||||
|
Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 12. Development Roadmap
|
## 12. Development Roadmap
|
||||||
|
|
||||||
### Phase 1: MVP ✓ (Shipped)
|
### Phase 1: MVP ✓ (Shipped)
|
||||||
@@ -1038,13 +1194,15 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
|||||||
|
|
||||||
### Phase 3: v1.0.x — Foundations (Current focus)
|
### 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** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
|
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
|
||||||
|
|
||||||
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
|
**3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
|
||||||
|
|
||||||
|
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user