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.
|
||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
||||
|
||||
# ---------- Bootstrap admin ----------
|
||||
# Required. Used once on first startup to seed the admin_users table.
|
||||
# Ignored on subsequent boots if the table is non-empty. For prod,
|
||||
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
|
||||
# raw password never lands in env or compose files; see blueprint §11.5.
|
||||
PICLOUD_ADMIN_USERNAME=admin
|
||||
PICLOUD_ADMIN_PASSWORD=admin
|
||||
|
||||
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||
|
||||
**Current focus (Phase 3, pre-v1.1):** admin auth gate, then multi-app scoping. The latter introduces `apps` as the top-level isolation boundary for scripts, routes, domains, and (later) data. See blueprint §11.5 for the design. Every v1.1+ feature must assume `app_id` exists as a scoping dimension.
|
||||
**Current focus (Phase 4, v1.1):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
|
||||
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -408,6 +408,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1305,12 +1311,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "picloud"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"figment",
|
||||
"picloud-executor-core",
|
||||
"picloud-manager-core",
|
||||
@@ -1325,11 +1332,12 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-executor-core",
|
||||
@@ -1341,7 +1349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor-core"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"picloud-shared",
|
||||
@@ -1355,7 +1363,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-manager-core",
|
||||
@@ -1367,13 +1375,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager-core"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"data-encoding",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
@@ -1390,7 +1399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-orchestrator-core",
|
||||
@@ -1402,7 +1411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator-core"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -1421,7 +1430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-shared"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
|
||||
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -66,11 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
# Auth (admin users + sessions)
|
||||
# Auth (admin users + sessions + API keys)
|
||||
argon2 = "0.5"
|
||||
rand = { version = "0.8", features = ["getrandom"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
data-encoding = "2.6"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -27,6 +27,7 @@ argon2.workspace = true
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
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 chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -20,6 +20,12 @@ pub enum AdminUserRepositoryError {
|
||||
|
||||
#[error("username already taken: {0}")]
|
||||
DuplicateUsername(String),
|
||||
|
||||
#[error("email already taken: {0}")]
|
||||
DuplicateEmail(String),
|
||||
|
||||
#[error("invalid instance_role stored in DB: {0}")]
|
||||
InvalidInstanceRole(String),
|
||||
}
|
||||
|
||||
/// Row returned to handlers and bootstrap. Never includes the password
|
||||
@@ -30,6 +36,8 @@ pub struct AdminUserRow {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
@@ -44,6 +52,7 @@ pub struct AdminUserCredentials {
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -58,10 +67,14 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||
/// explicit role.
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn update_username(
|
||||
&self,
|
||||
@@ -73,6 +86,14 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
id: AdminUserId,
|
||||
password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||
/// before invoking when role transitions away from `Owner`.
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn set_active(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
@@ -90,6 +111,15 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError>;
|
||||
/// All active owners — used for the multi-owner startup warning.
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Count of active owners excluding the given id. Used by the
|
||||
/// last-owner guard when demoting / deactivating / deleting an
|
||||
/// owner: "would this leave zero owners?"
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAdminUserRepository {
|
||||
@@ -107,13 +137,14 @@ impl PostgresAdminUserRepository {
|
||||
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_by_username(
|
||||
@@ -121,13 +152,14 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_credentials_by_username(
|
||||
@@ -135,42 +167,46 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||
"SELECT id, username, password_hash, is_active \
|
||||
"SELECT id, username, password_hash, is_active, instance_role \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"INSERT INTO admin_users (username, password_hash) \
|
||||
VALUES ($1, $2) \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
"INSERT INTO admin_users (username, password_hash, instance_role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(instance_role.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Ok(row) => row.try_into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
),
|
||||
@@ -186,7 +222,8 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(username)
|
||||
@@ -194,7 +231,7 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(Some(row)) => row.try_into(),
|
||||
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
@@ -211,14 +248,34 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(password_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(instance_role.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn set_active(
|
||||
@@ -229,14 +286,15 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(is_active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
@@ -277,6 +335,33 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users \
|
||||
WHERE is_active AND instance_role = 'owner' \
|
||||
ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*)::BIGINT FROM admin_users \
|
||||
WHERE is_active AND instance_role = 'owner' AND id <> $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -284,21 +369,28 @@ struct AdminUserRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
is_active: bool,
|
||||
instance_role: String,
|
||||
email: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<AdminUserRecord> for AdminUserRow {
|
||||
fn from(r: AdminUserRecord) -> Self {
|
||||
Self {
|
||||
impl TryFrom<AdminUserRecord> for AdminUserRow {
|
||||
type Error = AdminUserRepositoryError;
|
||||
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||
)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +400,20 @@ struct AdminCredsRecord {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
is_active: bool,
|
||||
instance_role: String,
|
||||
}
|
||||
|
||||
impl From<AdminCredsRecord> for AdminUserCredentials {
|
||||
fn from(r: AdminCredsRecord) -> Self {
|
||||
Self {
|
||||
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
||||
type Error = AdminUserRepositoryError;
|
||||
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
password_hash: r.password_hash,
|
||||
is_active: r.is_active,
|
||||
}
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,17 @@ use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||
use crate::api_key_repo::ApiKeyRepository;
|
||||
use crate::auth::hash_password;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
|
||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||
@@ -36,6 +38,13 @@ const PASSWORD_MIN: usize = 8;
|
||||
pub struct AdminsState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
|
||||
/// also expires every active API key for that user so cookie and
|
||||
/// bearer credentials become inert at the same moment.
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
/// Capability gate: every endpoint here requires
|
||||
/// `InstanceManageUsers` (owner / admin).
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn admins_router(state: AdminsState) -> Router {
|
||||
@@ -57,6 +66,8 @@ pub struct AdminDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -67,6 +78,8 @@ impl From<AdminUserRow> for AdminDto {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
instance_role: r.instance_role,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}
|
||||
@@ -77,6 +90,15 @@ impl From<AdminUserRow> for AdminDto {
|
||||
pub struct CreateAdminRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Defaults to `Admin` when absent — minting an owner via the API
|
||||
/// is a deliberate step. The env-var bootstrap path is the only
|
||||
/// channel that defaults to `Owner`.
|
||||
#[serde(default = "default_create_role")]
|
||||
pub instance_role: InstanceRole,
|
||||
}
|
||||
|
||||
const fn default_create_role() -> InstanceRole {
|
||||
InstanceRole::Admin
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
@@ -84,6 +106,7 @@ pub struct PatchAdminRequest {
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
pub instance_role: Option<InstanceRole>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -92,15 +115,29 @@ pub struct PatchAdminRequest {
|
||||
|
||||
async fn list_admins(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
let rows = state.users.list().await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn get_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.users
|
||||
.get(id)
|
||||
@@ -112,24 +149,49 @@ async fn get_admin(
|
||||
|
||||
async fn create_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateAdminRequest>,
|
||||
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
// Minting an owner via the API requires the caller to ALSO be an
|
||||
// owner — admin cannot self-elevate (or elevate someone else)
|
||||
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||
// bypasses this path.
|
||||
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
|
||||
{
|
||||
return Err(AdminApiError::CannotEscalate);
|
||||
}
|
||||
let username = input.username.trim();
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||
let row = state.users.create(username, &hash).await?;
|
||||
let row = state
|
||||
.users
|
||||
.create(username, &hash, input.instance_role)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(row.into())))
|
||||
}
|
||||
|
||||
async fn patch_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
Json(input): Json<PatchAdminRequest>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
// Verify the target exists upfront — keeps the error path uniform
|
||||
// for "rename a missing user" etc.
|
||||
let _ = state
|
||||
let current = state
|
||||
.users
|
||||
.get(id)
|
||||
.await?
|
||||
@@ -154,6 +216,26 @@ async fn patch_admin(
|
||||
// for the initial cut.)
|
||||
}
|
||||
|
||||
if let Some(new_role) = input.instance_role {
|
||||
// Self-elevation guard: only an owner can promote anyone TO
|
||||
// owner. An admin cannot turn themselves (or anyone else)
|
||||
// into one.
|
||||
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||
return Err(AdminApiError::CannotEscalate);
|
||||
}
|
||||
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||
// leave the install with zero owners. The check is on the
|
||||
// source role (current.instance_role) so demoting an
|
||||
// already-non-owner is always fine.
|
||||
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
|
||||
let remaining = state.users.count_other_active_owners(id).await?;
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveOwner);
|
||||
}
|
||||
}
|
||||
latest = Some(state.users.update_instance_role(id, new_role).await?);
|
||||
}
|
||||
|
||||
if let Some(new_active) = input.is_active {
|
||||
// Last-active-admin guard: only when transitioning to inactive.
|
||||
if !new_active {
|
||||
@@ -161,14 +243,40 @@ async fn patch_admin(
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveAdmin);
|
||||
}
|
||||
// ALSO: if the target is currently the last active owner,
|
||||
// deactivating them leaves no owner. Belt-and-suspenders to
|
||||
// the role guard above (which only triggers on an explicit
|
||||
// role transition).
|
||||
let target_role = latest
|
||||
.as_ref()
|
||||
.map_or(current.instance_role, |r| r.instance_role);
|
||||
if target_role == InstanceRole::Owner {
|
||||
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||
if remaining_owners == 0 {
|
||||
return Err(AdminApiError::LastActiveOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
latest = Some(state.users.set_active(id, new_active).await?);
|
||||
// Deactivation invalidates all of the user's sessions. Cheap
|
||||
// and safer than waiting for sliding-window expiry.
|
||||
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||
// (cookie / session bearer) and API keys. Both writes are
|
||||
// logged on failure but do not undo the deactivation; the
|
||||
// alternative (leaving the user active when one cascade fails)
|
||||
// is worse than slightly stale credential rows on a DB blip.
|
||||
if !new_active {
|
||||
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||
}
|
||||
match state.keys.expire_all_for_user(id).await {
|
||||
Ok(n) => {
|
||||
if n > 0 {
|
||||
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +293,15 @@ async fn patch_admin(
|
||||
|
||||
async fn delete_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<StatusCode, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
let target = state
|
||||
.users
|
||||
.get(id)
|
||||
@@ -197,9 +312,18 @@ async fn delete_admin(
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveAdmin);
|
||||
}
|
||||
// Last-owner guard mirrors the role-transition guard in
|
||||
// patch_admin — deleting the only owner is just as bad as
|
||||
// demoting them.
|
||||
if target.instance_role == InstanceRole::Owner {
|
||||
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||
if remaining_owners == 0 {
|
||||
return Err(AdminApiError::LastActiveOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.users.delete(id).await?;
|
||||
// Sessions cascade via FK; no explicit delete needed.
|
||||
// Sessions + api_keys cascade via FK; no explicit delete needed.
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -252,6 +376,18 @@ pub enum AdminApiError {
|
||||
#[error("cannot leave the system with zero active admins")]
|
||||
LastActiveAdmin,
|
||||
|
||||
#[error("cannot leave the system with zero active owners")]
|
||||
LastActiveOwner,
|
||||
|
||||
#[error("only an owner can grant the owner role")]
|
||||
CannotEscalate,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("failed to hash password: {0}")]
|
||||
Hash(String),
|
||||
|
||||
@@ -259,16 +395,39 @@ pub enum AdminApiError {
|
||||
Repo(#[from] AdminUserRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for AdminApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
|
||||
(StatusCode::CONFLICT, self.to_string())
|
||||
}
|
||||
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
|
||||
Self::Repo(
|
||||
AdminUserRepositoryError::DuplicateUsername(_)
|
||||
| AdminUserRepositoryError::DuplicateEmail(_),
|
||||
) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::InvalidUsername(_)
|
||||
| Self::InvalidPassword(_)
|
||||
| Self::LastActiveAdmin
|
||||
| Self::LastActiveOwner
|
||||
| Self::CannotEscalate
|
||||
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "admin_users authz error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
|
||||
@@ -5,17 +5,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||
ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{
|
||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
};
|
||||
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
||||
pub struct AdminState<R, L> {
|
||||
pub repo: Arc<R>,
|
||||
pub logs: Arc<L>,
|
||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
/// Phase 3.5 capability checks — every script handler resolves
|
||||
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||
/// loading the resource.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub validator: Arc<dyn ScriptValidator>,
|
||||
pub sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
|
||||
Self {
|
||||
repo: self.repo.clone(),
|
||||
logs: self.logs.clone(),
|
||||
apps: self.apps.clone(),
|
||||
authz: self.authz.clone(),
|
||||
validator: self.validator.clone(),
|
||||
sandbox_ceiling: self.sandbox_ceiling,
|
||||
}
|
||||
@@ -70,6 +82,9 @@ where
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateScriptRequest {
|
||||
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
|
||||
pub sandbox: ScriptSandbox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListScriptsQuery {
|
||||
/// Optional filter: list scripts belonging to a single app, by id
|
||||
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||
#[serde(default)]
|
||||
pub app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateScriptRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -113,31 +136,83 @@ where
|
||||
|
||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Query(q): Query<ListScriptsQuery>,
|
||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||
// Membership filter: `member` users see only scripts in apps they
|
||||
// belong to. `?app=` filters further by app and additionally
|
||||
// requires the member to belong to that app (the read check uses
|
||||
// the resource's app_id).
|
||||
if let Some(ident) = q.app {
|
||||
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||
}
|
||||
if principal.instance_role == InstanceRole::Member {
|
||||
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||
}
|
||||
Ok(Json(state.repo.list().await?))
|
||||
}
|
||||
|
||||
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||
/// for redirects, but here we just need the live current id; if a
|
||||
/// retired slug is given, we follow it to the current app silently.
|
||||
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
let id = AppId::from(uuid);
|
||||
apps.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||
return Ok(id);
|
||||
}
|
||||
let lookup = apps
|
||||
.get_by_slug_or_history(ident)
|
||||
.await?
|
||||
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||
Ok(lookup.app.id)
|
||||
}
|
||||
|
||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<Json<Script>, ApiError> {
|
||||
state
|
||||
.repo
|
||||
.get(id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound(id))
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateScriptRequest>,
|
||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||
// Capability is bound to the *requested* app_id since there's no
|
||||
// resource to load yet. If the app doesn't exist we 422 below;
|
||||
// checking authz first means a Member trying to create against an
|
||||
// unknown app gets 403 (no enumeration of app existence).
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.validator.validate(&input.source)?;
|
||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||
// raw FK violation surfacing as 500.
|
||||
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||
}
|
||||
let created = state
|
||||
.repo
|
||||
.create(NewScript {
|
||||
app_id: input.app_id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
Json(input): Json<UpdateScriptRequest>,
|
||||
) -> Result<Json<Script>, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
if let Some(src) = input.source.as_deref() {
|
||||
state.validator.validate(src)?;
|
||||
}
|
||||
@@ -183,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.repo.delete(id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -203,9 +294,17 @@ const fn default_limit() -> i64 {
|
||||
|
||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppLogRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
// Cap to keep the dashboard responsive; the data plane writes are
|
||||
// unbounded over time so a paged read is the only sane default.
|
||||
let limit = q.limit.clamp(1, 200);
|
||||
@@ -223,6 +322,9 @@ pub enum ApiError {
|
||||
#[error("script not found: {0}")]
|
||||
NotFound(ScriptId),
|
||||
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
@@ -232,18 +334,42 @@ pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Ceiling(#[from] CeilingError),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for ApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
|
||||
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 base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// API key generation (Phase 3.5)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
|
||||
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
|
||||
/// generator and the verifier agree.
|
||||
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
|
||||
|
||||
/// Length of the indexed prefix portion (the first 8 chars of the
|
||||
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
|
||||
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
|
||||
|
||||
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
|
||||
///
|
||||
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
|
||||
/// caller in the response body and never persisted.
|
||||
/// * `prefix` is the indexed 8-char slice persisted to
|
||||
/// `api_keys.prefix` for lookup.
|
||||
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
|
||||
/// covers the body after `pic_` (i.e., `raw[4..]`).
|
||||
pub struct GeneratedApiKey {
|
||||
pub raw: String,
|
||||
pub prefix: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
|
||||
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
|
||||
/// key; everything after `pic_` is what the verifier hashes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
|
||||
/// fails (which it shouldn't under normal conditions).
|
||||
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let body = BASE32_NOPAD.encode(&bytes);
|
||||
debug_assert!(
|
||||
body.len() >= API_KEY_INDEX_PREFIX_LEN,
|
||||
"32 bytes base32 must exceed the 8-char prefix length"
|
||||
);
|
||||
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
|
||||
let salt = SaltString::generate(&mut ArgonRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(body.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
|
||||
Ok(GeneratedApiKey { raw, prefix, hash })
|
||||
}
|
||||
|
||||
/// Verify a wire-format token body (the portion *after* `pic_`)
|
||||
/// against a stored Argon2id hash. Convenience wrapper around
|
||||
/// `verify_password` named to reflect its caller.
|
||||
#[must_use]
|
||||
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
|
||||
verify_password(stored_hash, presented_body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -129,4 +190,42 @@ mod tests {
|
||||
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_key_round_trip() {
|
||||
let key = generate_api_key().expect("mint");
|
||||
assert!(
|
||||
key.raw.starts_with(API_KEY_WIRE_PREFIX),
|
||||
"raw must carry the pic_ prefix"
|
||||
);
|
||||
let body = key
|
||||
.raw
|
||||
.strip_prefix(API_KEY_WIRE_PREFIX)
|
||||
.expect("starts with prefix");
|
||||
assert_eq!(
|
||||
&body[..API_KEY_INDEX_PREFIX_LEN],
|
||||
key.prefix,
|
||||
"stored prefix matches the first 8 chars of the body"
|
||||
);
|
||||
assert!(
|
||||
verify_api_key(&key.hash, body),
|
||||
"Argon2 verify must accept the original body"
|
||||
);
|
||||
assert!(
|
||||
!verify_api_key(&key.hash, "wrong-body-entirely"),
|
||||
"Argon2 verify must reject anything else"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_key_unique() {
|
||||
let a = generate_api_key().expect("mint a");
|
||||
let b = generate_api_key().expect("mint b");
|
||||
assert_ne!(a.raw, b.raw);
|
||||
assert_ne!(a.hash, b.hash);
|
||||
assert_ne!(
|
||||
a.prefix, b.prefix,
|
||||
"32 random bytes → prefix collision is negligible"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ use picloud_shared::AdminUserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use picloud_shared::Principal;
|
||||
|
||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
|
||||
|
||||
pub fn auth_router(state: AuthState) -> Router {
|
||||
// /login + /logout are unguarded (login is how you get in; logout
|
||||
@@ -31,7 +33,7 @@ pub fn auth_router(state: AuthState) -> Router {
|
||||
// who you are, so the middleware must run first.
|
||||
let guarded = Router::new()
|
||||
.route("/auth/me", get(me))
|
||||
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
||||
.route_layer(from_fn_with_state(state.clone(), require_authenticated));
|
||||
|
||||
Router::new()
|
||||
.route("/auth/login", post(login))
|
||||
@@ -158,11 +160,25 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
|
||||
(StatusCode::NO_CONTENT, headers).into_response()
|
||||
}
|
||||
|
||||
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||
Json(AdminUserDto {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
async fn me(
|
||||
State(state): State<AuthState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Response {
|
||||
// /me consumes the resolved Principal directly; we re-fetch the
|
||||
// user row only to surface a fresh username (it can change via
|
||||
// PATCH while a session/key is still valid).
|
||||
match state.users.get(principal.user_id).await {
|
||||
Ok(Some(row)) => Json(AdminUserDto {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
})
|
||||
.into_response(),
|
||||
Ok(None) => invalid_credentials(),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||
internal_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -116,7 +116,15 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||
};
|
||||
|
||||
repo.create(&username, &password_hash).await?;
|
||||
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
|
||||
// first row to full instance control. Subsequent admins minted via
|
||||
// the API default to Admin and can be promoted explicitly.
|
||||
repo.create(
|
||||
&username,
|
||||
&password_hash,
|
||||
picloud_shared::InstanceRole::Owner,
|
||||
)
|
||||
.await?;
|
||||
info!(username = %username, "bootstrapped initial admin user");
|
||||
Ok(())
|
||||
}
|
||||
@@ -130,7 +138,7 @@ mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||
@@ -167,11 +175,14 @@ mod tests {
|
||||
&self,
|
||||
username: &str,
|
||||
_password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = AdminUserRow {
|
||||
id: AdminUserId::new(),
|
||||
username: username.to_string(),
|
||||
is_active: true,
|
||||
instance_role,
|
||||
email: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
@@ -193,6 +204,13 @@ mod tests {
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_r: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn set_active(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
@@ -215,6 +233,15 @@ mod tests {
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -245,7 +272,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn populated_db_is_noop() {
|
||||
let repo = InMemoryRepo::default();
|
||||
repo.create("seeded", "x").await.unwrap();
|
||||
repo.create("seeded", "x", InstanceRole::Owner)
|
||||
.await
|
||||
.unwrap();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: Some("supersecret".into()),
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
//! `require_admin` axum middleware: gates a router on a valid admin
|
||||
//! session. Accepts the token from either the `picloud_session` cookie
|
||||
//! or an `Authorization: Bearer …` header — same token system serves
|
||||
//! the dashboard and CLI/CI clients.
|
||||
//! Authentication middleware — resolves the caller's `Principal` from
|
||||
//! either a session cookie / Bearer session-token OR an API key
|
||||
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
||||
//! request extension so downstream handlers see one shape.
|
||||
//!
|
||||
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
||||
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
||||
//! returns 401 with a generic JSON body (no enumeration about whether
|
||||
//! the token was wrong vs. the user was deactivated).
|
||||
//! Capability checks live in `crate::authz` and are called per-handler
|
||||
//! (after the relevant resource is loaded, so the capability binds to
|
||||
//! the actual resource's `app_id`). This middleware is gate-only: it
|
||||
//! ensures *some* `Principal` is attached, or returns 401.
|
||||
//!
|
||||
//! Token discriminator: the `pic_` prefix on a Bearer value selects
|
||||
//! the API-key path; anything else (raw 32-byte base64-url-encoded
|
||||
//! string) takes the session path. The session cookie can only ever
|
||||
//! carry a session token (cookies are never API keys).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, Principal};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::AdminUserRepository;
|
||||
use crate::auth::hash_token;
|
||||
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
||||
use crate::auth::{hash_token, verify_password};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||
|
||||
/// Shared state for auth: the two repos plus the configured sliding
|
||||
/// session TTL. Cheap to clone (`Arc` everywhere).
|
||||
/// Prefix on the wire that selects the API-key path. The body that
|
||||
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
|
||||
/// index into `api_keys.prefix` for verification.
|
||||
pub const API_KEY_PREFIX: &str = "pic_";
|
||||
|
||||
/// Length of the indexed prefix portion of an API key (the 8 chars
|
||||
/// immediately after `pic_`). Schema-side index is on this slice.
|
||||
pub const API_KEY_PREFIX_LEN: usize = 8;
|
||||
|
||||
/// Shared state for auth: the user / session / API-key repos plus the
|
||||
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
/// Request-extension type that authenticated handlers extract via
|
||||
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
||||
/// Legacy request-extension alias retained so the (only remaining)
|
||||
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
|
||||
/// keeps compiling during the migration. New handlers should pull
|
||||
/// `Extension<Principal>` directly.
|
||||
#[deprecated(note = "use Extension<Principal> directly")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthedAdmin {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Middleware function. Wire with
|
||||
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
||||
pub async fn require_admin(
|
||||
/// Middleware entry point. Wire with
|
||||
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
||||
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
|
||||
/// extensions on success; returns 401 on any failure mode.
|
||||
pub async fn require_authenticated(
|
||||
State(state): State<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
@@ -53,48 +74,162 @@ pub async fn require_admin(
|
||||
let Some(token) = extract_token(&req) else {
|
||||
return unauthorized();
|
||||
};
|
||||
let token_hash = hash_token(&token);
|
||||
let principal = match resolve_principal(&state, &token).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return unauthorized(),
|
||||
Err(InternalError) => return internal_error(),
|
||||
};
|
||||
|
||||
let username_for_legacy = username_for(&state, principal.user_id).await;
|
||||
req.extensions_mut().insert(principal.clone());
|
||||
#[allow(deprecated)]
|
||||
if let Some(username) = username_for_legacy {
|
||||
req.extensions_mut().insert(AuthedAdmin {
|
||||
id: principal.user_id,
|
||||
username,
|
||||
});
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias — the single callsite that still names
|
||||
/// `require_admin` keeps working without an immediate rename. New
|
||||
/// wiring should call `require_authenticated`.
|
||||
#[deprecated(note = "renamed to require_authenticated")]
|
||||
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
||||
require_authenticated(state, req, next).await
|
||||
}
|
||||
|
||||
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||
/// means the token was structurally valid but didn't match any active
|
||||
/// credential; `Err(InternalError)` means a DB blip.
|
||||
async fn resolve_principal(
|
||||
state: &AuthState,
|
||||
token: &str,
|
||||
) -> Result<Option<Principal>, InternalError> {
|
||||
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
|
||||
return verify_api_key(state, rest).await;
|
||||
}
|
||||
verify_session(state, token).await
|
||||
}
|
||||
|
||||
async fn verify_session(
|
||||
state: &AuthState,
|
||||
token: &str,
|
||||
) -> Result<Option<Principal>, InternalError> {
|
||||
let token_hash = hash_token(token);
|
||||
|
||||
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||
Ok(Some(lookup)) => lookup,
|
||||
Ok(None) => return unauthorized(),
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_sessions lookup failed");
|
||||
return internal_error();
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the user. A deleted user is impossible here (FK cascade
|
||||
// wipes their sessions), but a deactivated user still needs to be
|
||||
// rejected — and so does the edge case of a session predating the
|
||||
// deactivate (we wipe their sessions on deactivate, but a race
|
||||
// could land a request in flight).
|
||||
let user = match state.users.get(lookup.user_id).await {
|
||||
Ok(Some(u)) if u.is_active => u,
|
||||
Ok(_) => return unauthorized(),
|
||||
Ok(_) => return Ok(None),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup failed");
|
||||
return internal_error();
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
||||
// surfaces as a request error rather than silent stale sessions.
|
||||
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||
// than silent stale sessions. Same shape as Phase 3a.
|
||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||
tracing::error!(?err, "admin_sessions touch failed");
|
||||
return internal_error();
|
||||
return Err(InternalError);
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(AuthedAdmin {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
next.run(req).await
|
||||
Ok(Some(Principal {
|
||||
user_id: user.id,
|
||||
instance_role: user.instance_role,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// API-key verification path. `rest` is the portion of the bearer
|
||||
/// value *after* `pic_`. We slice off the first 8 chars as the
|
||||
/// indexed lookup key, then Argon2id-verify each candidate's hash
|
||||
/// against the full `rest`. At most one match is expected; multiple
|
||||
/// candidates with the same prefix is statistically negligible but
|
||||
/// handled correctly (verify each, take the first match).
|
||||
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
||||
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||
return Ok(None);
|
||||
}
|
||||
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
||||
|
||||
let candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "api_keys lookup failed");
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
let matched: Option<ApiKeyVerification> = candidates
|
||||
.into_iter()
|
||||
.find(|c| verify_password(&c.hash, rest));
|
||||
let Some(matched) = matched else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Resolve the owning user. is_active = false → reject even if the
|
||||
// key itself hasn't been expired yet (the expire_all_for_user
|
||||
// cascade on deactivation is the primary defense; this is the
|
||||
// belt-and-suspenders check at request time).
|
||||
let user = match state.users.get(matched.user_id).await {
|
||||
Ok(Some(u)) if u.is_active => u,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup for api key failed");
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state.keys.touch_last_used(matched.id).await {
|
||||
tracing::error!(?err, "api_keys touch_last_used failed");
|
||||
// Soft-fail: a timestamp blip should not invalidate the
|
||||
// request. Continue with the resolved Principal.
|
||||
}
|
||||
|
||||
Ok(Some(Principal {
|
||||
user_id: user.id,
|
||||
instance_role: user.instance_role,
|
||||
scopes: Some(matched.scopes),
|
||||
app_binding: matched.app_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
|
||||
/// Returns `None` on DB error (the caller treats `None` as "skip the
|
||||
/// legacy extension"). New handlers use `Principal` and don't depend
|
||||
/// on this.
|
||||
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
||||
match state.users.get(id).await {
|
||||
Ok(Some(u)) => Some(u.username),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||
/// Same shape as Phase 3a; the cookie only ever carries session
|
||||
/// tokens — no `pic_` prefix expected there.
|
||||
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
@@ -121,6 +256,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Sentinel returned from the resolve functions when a DB error should
|
||||
/// produce a 500 rather than a 401. Empty struct because the actual
|
||||
/// error is already logged at the failure site.
|
||||
struct InternalError;
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -141,6 +281,7 @@ fn internal_error() -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
@@ -155,6 +296,12 @@ mod tests {
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_bearer_pic_prefixed_token() {
|
||||
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_bearer_with_no_token() {
|
||||
let r = req_with_header("authorization", "Bearer ");
|
||||
@@ -182,4 +329,20 @@ mod tests {
|
||||
let r = Request::builder().body(Body::empty()).unwrap();
|
||||
assert_eq!(extract_token(&r), None);
|
||||
}
|
||||
|
||||
// Round-trip test for the unused-variable to keep `Principal`
|
||||
// visibly tied to InstanceRole — caught a real bug during dev when
|
||||
// the field order in the struct literal had drifted.
|
||||
#[test]
|
||||
fn principal_construction_is_explicit() {
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
};
|
||||
assert_eq!(p.instance_role, InstanceRole::Owner);
|
||||
assert!(p.scopes.is_none());
|
||||
assert!(p.app_binding.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
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_users_api;
|
||||
pub mod api;
|
||||
pub mod api_key_repo;
|
||||
pub mod api_keys_api;
|
||||
pub mod app_bootstrap;
|
||||
pub mod app_domain_repo;
|
||||
pub mod app_members_repo;
|
||||
pub mod app_repo;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod log_sink;
|
||||
pub mod migrations;
|
||||
pub mod repo;
|
||||
@@ -30,11 +38,28 @@ pub use admin_user_repo::{
|
||||
};
|
||||
pub use admin_users_api::{admins_router, AdminsState};
|
||||
pub use api::{admin_router, AdminState};
|
||||
pub use api_key_repo::{
|
||||
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
||||
PostgresApiKeyRepository,
|
||||
};
|
||||
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||
pub use app_members_repo::{
|
||||
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
|
||||
};
|
||||
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||
};
|
||||
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
#[allow(deprecated)]
|
||||
pub use auth_middleware::{
|
||||
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
|
||||
API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||
};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use log_sink::PostgresExecutionLogSink;
|
||||
pub use repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
|
||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO execution_logs ( \
|
||||
id, script_id, request_id, \
|
||||
id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
) VALUES ( \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||
)",
|
||||
)
|
||||
.bind(log.id)
|
||||
.bind(log.app_id.into_inner())
|
||||
.bind(log.script_id.into_inner())
|
||||
.bind(log.request_id.into_inner())
|
||||
.bind(&log.request_path)
|
||||
|
||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -21,7 +23,18 @@ pub enum ScriptRepositoryError {
|
||||
#[async_trait]
|
||||
pub trait ScriptRepository: Send + Sync {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// Every script in any app the user is a member of. Drives
|
||||
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||
/// API never returns scripts they shouldn't see — even before the
|
||||
/// per-handler capability check fires.
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -35,6 +48,7 @@ pub trait ScriptRepository: Send + Sync {
|
||||
/// constraints; the repo enforces them in the DB regardless.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewScript {
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -78,7 +92,7 @@ impl PostgresScriptRepository {
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE id = $1",
|
||||
)
|
||||
@@ -90,7 +104,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts ORDER BY name",
|
||||
)
|
||||
@@ -99,17 +113,48 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||
FROM scripts s \
|
||||
JOIN app_members m ON m.app_id = s.app_id \
|
||||
WHERE m.user_id = $1 \
|
||||
ORDER BY s.name",
|
||||
)
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"INSERT INTO scripts ( \
|
||||
name, description, source, \
|
||||
app_id, name, description, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
||||
RETURNING id, name, description, version, source, \
|
||||
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
@@ -123,7 +168,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists",
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)))
|
||||
}
|
||||
@@ -141,12 +186,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||
// Sandbox is replaced wholesale when present; per-field merging
|
||||
// happens in the API layer (clearer semantics for a "PUT a new
|
||||
// sandbox config" call).
|
||||
// sandbox config" call). app_id is immutable — moving a script
|
||||
// to another app is a copy-and-delete, not an in-place edit.
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
@@ -157,7 +203,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, name, description, version, source, \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -169,10 +215,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
row.map(Into::into)
|
||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -191,6 +245,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
@@ -211,6 +266,7 @@ impl From<ScriptRow> for Script {
|
||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
@@ -284,7 +340,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
offset: i64,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||
"SELECT id, script_id, request_id, \
|
||||
"SELECT id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
@@ -306,6 +362,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExecutionLogRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
script_id: uuid::Uuid,
|
||||
request_id: uuid::Uuid,
|
||||
request_path: Option<String>,
|
||||
@@ -331,6 +388,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
||||
};
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
request_id: RequestId::from(r.request_id),
|
||||
request_path: r.request_path.unwrap_or_default(),
|
||||
|
||||
@@ -10,42 +10,56 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::app_domain_repo::AppDomainRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
pub struct RouteAdminState<RR> {
|
||||
pub struct RouteAdminState<RR, SR> {
|
||||
pub routes: Arc<RR>,
|
||||
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||
/// route inherits the script's app) and to scope conflict checks.
|
||||
pub scripts: Arc<SR>,
|
||||
/// Used to validate the route's host against the parent app's
|
||||
/// declared domain claims.
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub table: Arc<RouteTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl<RR> Clone for RouteAdminState<RR> {
|
||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
routes: self.routes.clone(),
|
||||
scripts: self.scripts.clone(),
|
||||
domains: self.domains.clone(),
|
||||
table: self.table.clone(),
|
||||
authz: self.authz.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
||||
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||
where
|
||||
RR: RouteRepository + 'static,
|
||||
SR: ScriptRepository + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/scripts/{id}/routes",
|
||||
get(list_routes::<RR>).post(create_route::<RR>),
|
||||
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||
)
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
||||
.route("/routes:check", post(check_route::<RR>))
|
||||
.route("/routes:match", post(match_route::<RR>))
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||
.route("/routes:check", post(check_route::<RR, SR>))
|
||||
.route("/routes:match", post(match_route::<RR, SR>))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -67,6 +81,10 @@ pub struct CreateRouteRequest {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteRequest {
|
||||
/// Required: which app's route table this hypothetical route would
|
||||
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||
/// errors would leak tenant info — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
@@ -84,6 +102,9 @@ pub struct CheckRouteResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteRequest {
|
||||
/// Which app's route table to dispatch against. The dashboard's
|
||||
/// route-preview tester always knows the current app context.
|
||||
pub app_id: AppId,
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
@@ -111,15 +132,28 @@ pub struct MatchedRoute {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_routes<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
}
|
||||
|
||||
async fn create_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
@@ -130,8 +164,28 @@ async fn create_route<RR: RouteRepository>(
|
||||
input.host_param_name.as_deref(),
|
||||
)?;
|
||||
|
||||
// Within-kind conflict check against existing routes.
|
||||
let existing = state.routes.list_all().await?;
|
||||
// Look up the script's owning app — every route inherits it.
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Validate the route's host is consistent with one of the app's
|
||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||
// host the app already owns). Specific hosts must match a claim.
|
||||
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||
.await?;
|
||||
|
||||
// Within-app conflict check (cross-app is impossible by construction).
|
||||
let existing = state.routes.list_for_app(app_id).await?;
|
||||
if let Some((conflicting, reason)) = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -149,6 +203,7 @@ async fn create_route<RR: RouteRepository>(
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
app_id,
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
@@ -162,23 +217,47 @@ async fn create_route<RR: RouteRepository>(
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, RouteApiError> {
|
||||
// Resolve the route's app before we delete, so the capability
|
||||
// binds to the actual route's app_id (not a path param).
|
||||
let route = state
|
||||
.routes
|
||||
.get(route_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(route.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.routes.delete(route_id).await?;
|
||||
refresh_table(&state).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn check_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CheckRouteRequest>,
|
||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||
// routes:check is read-only — peeking at a hypothetical conflict
|
||||
// is bounded by AppRead on the target app (otherwise members
|
||||
// could probe other apps).
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||
|
||||
let existing = state.routes.list_all().await?;
|
||||
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||
let conflict = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -201,16 +280,25 @@ async fn check_route<RR: RouteRepository>(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn match_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||
let host = parsed.host_str().unwrap_or("").to_string();
|
||||
let path = parsed.path().to_string();
|
||||
|
||||
let result = state.table.match_request(&host, &input.method, &path);
|
||||
let result = state
|
||||
.table
|
||||
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||
Ok(Json(MatchRouteResponse {
|
||||
matched: result.map(|r| MatchedRoute {
|
||||
route_id: r.matched.route_id,
|
||||
@@ -263,12 +351,12 @@ fn first_conflict(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_table<RR: RouteRepository>(
|
||||
state: &RouteAdminState<RR>,
|
||||
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
state.table.replace(compiled);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -277,6 +365,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
@@ -286,6 +375,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<(), RouteApiError> {
|
||||
if matches!(host_kind, HostKind::Any) {
|
||||
return Ok(());
|
||||
}
|
||||
let claims = domains.list_for_app(app_id).await?;
|
||||
if claims.is_empty() {
|
||||
return Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for claim in &claims {
|
||||
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||
match (host_kind, claim.shape) {
|
||||
// Strict route under exact claim: must match exactly.
|
||||
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||
if host_lower == claim_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Strict route under wildcard/parameterized: must end with
|
||||
// ".<suffix>" where the claim's suffix is the part after
|
||||
// `*.` or `{...}.`.
|
||||
(
|
||||
HostKind::Strict,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let needle = format!(".{suffix}");
|
||||
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route: must match a wildcard or parameterized
|
||||
// claim with identical suffix.
|
||||
(
|
||||
HostKind::Wildcard,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let claim_suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
if claim_suffix == host_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route under exact claim: not allowed (would
|
||||
// shadow other apps' subdomains the operator didn't claim).
|
||||
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||
(HostKind::Any, _) => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -304,10 +466,37 @@ pub enum RouteApiError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("script not found: {0}")]
|
||||
ScriptNotFound(ScriptId),
|
||||
|
||||
#[error("route not found: {0}")]
|
||||
RouteNotFound(Uuid),
|
||||
|
||||
#[error("host {host:?} is not claimed by this app")]
|
||||
HostNotClaimed {
|
||||
host: String,
|
||||
available_claims: Vec<String>,
|
||||
},
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for RouteApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for RouteApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
@@ -326,10 +515,34 @@ impl IntoResponse for RouteApiError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
Self::ScriptNotFound(_)
|
||||
| Self::RouteNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "route authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
serde_json::json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::HostNotClaimed {
|
||||
host,
|
||||
available_claims,
|
||||
} => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({
|
||||
"error": self.to_string(),
|
||||
"host": host,
|
||||
"available_claims": available_claims,
|
||||
}),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! CRUD over the `routes` table.
|
||||
//!
|
||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
||||
//! every write — see the route_admin module for the binding.
|
||||
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||
//! after every write — see the route_admin module for the binding.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewRoute {
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
@@ -24,12 +25,25 @@ pub struct NewRoute {
|
||||
#[async_trait]
|
||||
pub trait RouteRepository: Send + Sync {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
|
||||
/// the capability check binds to the route's actual `app_id`
|
||||
/// (not a path param).
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
/// Count routes whose host_kind/host pair matches a pattern in
|
||||
/// `app_id`. Used by the domain-claim delete guard.
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresRouteRepository {
|
||||
@@ -47,7 +61,7 @@ impl PostgresRouteRepository {
|
||||
impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
@@ -56,12 +70,36 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
.bind(route_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
@@ -74,12 +112,13 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
script_id, host_kind, host, host_param_name, \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
@@ -112,6 +151,24 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM routes \
|
||||
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(host_kind_str(host_kind))
|
||||
.bind(host)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
@@ -133,6 +190,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
@@ -147,6 +205,7 @@ impl From<RouteRow> for Route {
|
||||
fn from(r: RouteRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
|
||||
@@ -3,6 +3,64 @@
|
||||
|
||||
## tables
|
||||
|
||||
table: admin_sessions
|
||||
token_hash: text NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
expires_at: timestamp with time zone NOT NULL
|
||||
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: admin_users
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
username: text NOT NULL
|
||||
password_hash: text NOT NULL
|
||||
is_active: boolean NOT NULL default=true
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
last_login_at: timestamp with time zone NULL
|
||||
instance_role: text NOT NULL default='owner'::text
|
||||
email: text NULL
|
||||
mfa_secret: text NULL
|
||||
|
||||
table: api_keys
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
user_id: uuid NOT NULL
|
||||
hash: text NOT NULL
|
||||
prefix: text NOT NULL
|
||||
name: text NOT NULL
|
||||
scopes: ARRAY NOT NULL
|
||||
app_id: uuid NULL
|
||||
expires_at: timestamp with time zone NULL
|
||||
last_used_at: timestamp with time zone NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_domains
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
pattern: text NOT NULL
|
||||
shape: text NOT NULL
|
||||
shape_key: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_members
|
||||
app_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_slug_history
|
||||
slug: text NOT NULL
|
||||
current_app_id: uuid NOT NULL
|
||||
retired_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: apps
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
description: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: execution_logs
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -16,6 +74,7 @@ table: execution_logs
|
||||
duration_ms: integer NOT NULL default=0
|
||||
status: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -27,6 +86,7 @@ table: routes
|
||||
path: text NOT NULL
|
||||
method: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: scripts
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -39,42 +99,119 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
## indexes
|
||||
|
||||
indexes on admin_sessions:
|
||||
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||
|
||||
indexes on admin_users:
|
||||
admin_users_email_key: public.admin_users USING btree (email)
|
||||
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
|
||||
admin_users_pkey: public.admin_users USING btree (id)
|
||||
admin_users_username_key: public.admin_users USING btree (username)
|
||||
|
||||
indexes on api_keys:
|
||||
api_keys_pkey: public.api_keys USING btree (id)
|
||||
api_keys_prefix_idx: public.api_keys USING btree (prefix)
|
||||
api_keys_user_id_idx: public.api_keys USING btree (user_id)
|
||||
|
||||
indexes on app_domains:
|
||||
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||
app_domains_pkey: public.app_domains USING btree (id)
|
||||
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||
|
||||
indexes on app_members:
|
||||
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||
|
||||
indexes on app_slug_history:
|
||||
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||
|
||||
indexes on apps:
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
indexes on execution_logs:
|
||||
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
|
||||
indexes on scripts:
|
||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on admin_sessions:
|
||||
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||
|
||||
constraints on admin_users:
|
||||
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
|
||||
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] admin_users_email_key: UNIQUE (email)
|
||||
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||
|
||||
constraints on api_keys:
|
||||
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on app_domains:
|
||||
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||
|
||||
constraints on app_members:
|
||||
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||
|
||||
constraints on app_slug_history:
|
||||
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||
|
||||
constraints on apps:
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on execution_logs:
|
||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on scripts:
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
0003: routes
|
||||
0004: admin auth
|
||||
0005: apps
|
||||
0006: users authz
|
||||
|
||||
@@ -17,22 +17,26 @@ use axum::{
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_shared::{
|
||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
};
|
||||
use serde_json::Value as Json_;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::ExecutorClient;
|
||||
use crate::resolver::{ResolverError, ScriptResolver};
|
||||
use crate::routing::RouteTable;
|
||||
use crate::routing::{AppDomainTable, RouteTable};
|
||||
|
||||
/// State shared by data-plane handlers.
|
||||
pub struct DataPlaneState<E, R> {
|
||||
pub executor: Arc<E>,
|
||||
pub resolver: Arc<R>,
|
||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||
/// Routing table for user-defined paths. Shared with the manager
|
||||
/// (admin router writes; this side reads).
|
||||
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||
/// owning app's slice. Shared with the manager (writes invalidate
|
||||
/// the cache by replacing the table).
|
||||
pub app_domains: Arc<AppDomainTable>,
|
||||
/// Routing table for user-defined paths, partitioned per app.
|
||||
/// Shared with the manager (admin router writes; this side reads).
|
||||
pub routes: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
executor: self.executor.clone(),
|
||||
resolver: self.resolver.clone(),
|
||||
log_sink: self.log_sink.clone(),
|
||||
app_domains: self.app_domains.clone(),
|
||||
routes: self.routes.clone(),
|
||||
}
|
||||
}
|
||||
@@ -109,6 +114,7 @@ where
|
||||
// audit-visible platform — but a sink failure must not mask the
|
||||
// user-facing result, so we only log a warning if it fails.
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -145,7 +151,23 @@ where
|
||||
.to_string();
|
||||
let headers = request.headers().clone();
|
||||
|
||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
||||
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||
// then run the existing matcher on that app's slice. No app claims
|
||||
// this host → flat 404; the path doesn't get the chance to fire.
|
||||
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no app claims host {host:?}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let Some(matched) = state
|
||||
.routes
|
||||
.match_request_for_app(app_id, &host, &method, &path)
|
||||
else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
@@ -191,6 +213,7 @@ where
|
||||
let finished = Utc::now();
|
||||
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
matched.matched.script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
@@ -336,6 +360,7 @@ fn build_execution_log(
|
||||
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// A single route ready for matching.
|
||||
/// A single route ready for matching. `app_id` is carried so the
|
||||
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||
/// table; the matcher itself doesn't read it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledRoute {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub app_id: picloud_shared::AppId,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
pub host: HostPattern,
|
||||
pub path: PathPattern,
|
||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
||||
mod tests {
|
||||
use super::super::pattern::parse_path;
|
||||
use super::*;
|
||||
use picloud_shared::{PathKind, ScriptId};
|
||||
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||
CompiledRoute {
|
||||
route_id: Uuid::new_v4(),
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
host,
|
||||
path: parse_path(path_kind, raw).unwrap(),
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||
//! wildcard suffix breaks ties between wildcards.
|
||||
|
||||
pub mod app_domains;
|
||||
pub mod conflict;
|
||||
pub mod matcher;
|
||||
pub mod pattern;
|
||||
pub mod table;
|
||||
|
||||
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||
pub use conflict::{conflicts, ConflictReason};
|
||||
pub use matcher::{MatchResult, Matched};
|
||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
||||
pub use pattern::{
|
||||
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||
};
|
||||
pub use table::RouteTable;
|
||||
|
||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App-domain patterns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
use picloud_shared::DomainShape;
|
||||
|
||||
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||
/// host pattern (used at request time), the shape (used at write time
|
||||
/// for collision checks), and the normalized shape_key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedAppDomain {
|
||||
pub pattern: HostPattern,
|
||||
pub shape: DomainShape,
|
||||
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||
/// for both wildcard AND parameterized — they share a shape per
|
||||
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||
/// check").
|
||||
pub shape_key: String,
|
||||
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||
/// is surfaced into request context in a future iteration.
|
||||
pub binding: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a user-supplied app domain claim. Accepts:
|
||||
/// * `app.example.com` — exact host
|
||||
/// * `*.example.com` — wildcard suffix
|
||||
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||
///
|
||||
/// Distinct from `parse_host` (which is for route host fields): the
|
||||
/// route parser still rejects `{...}` syntax — see
|
||||
/// `ParseError::ReservedHostBraceSyntax`.
|
||||
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::EmptyHost);
|
||||
}
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
|
||||
// Wildcard: starts with "*."
|
||||
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||
if suffix.is_empty() {
|
||||
return Err(ParseError::EmptyWildcardSuffix);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: None,
|
||||
},
|
||||
shape: DomainShape::Wildcard,
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||
let (binding, rest) = stripped
|
||||
.split_once('}')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if binding.is_empty()
|
||||
|| !binding
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||
{
|
||||
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||
}
|
||||
let suffix = rest
|
||||
.strip_prefix('.')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: Some(binding.to_string()),
|
||||
},
|
||||
shape: DomainShape::Parameterized,
|
||||
// Same shape_key as the equivalent wildcard — parameter
|
||||
// name is a binding, not a discriminator.
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: Some(binding.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Anything else: exact host. Reject braces anywhere in the body
|
||||
// (they'd be a malformed parameterized form).
|
||||
if lowered.contains('{') || lowered.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Strict(lowered.clone()),
|
||||
shape: DomainShape::Exact,
|
||||
shape_key: format!("exact:{lowered}"),
|
||||
binding: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -393,6 +493,49 @@ mod tests {
|
||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_exact() {
|
||||
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||
assert_eq!(d.shape, DomainShape::Exact);
|
||||
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||
assert!(d.binding.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||
let w = parse_app_domain("*.example.com").unwrap();
|
||||
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_rejects_garbage() {
|
||||
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||
assert!(matches!(
|
||||
parse_app_domain("*."),
|
||||
Err(ParseError::EmptyWildcardSuffix)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{1tenant}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
// Mid-host braces — disallowed.
|
||||
assert!(matches!(
|
||||
parse_app_domain("foo.{tenant}.example.com"),
|
||||
Err(ParseError::ReservedHostBraceSyntax)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_literal_count_works() {
|
||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
||||
//! and orchestrator (reads).
|
||||
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||
//!
|
||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
||||
//! read without contending against the writer; in MVP-single-process
|
||||
//! we just use `RwLock` and accept the cheap contention.
|
||||
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||
|
||||
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||
/// via `replace_all`); contention against readers is minimal so a plain
|
||||
/// `RwLock` is fine.
|
||||
#[derive(Default)]
|
||||
pub struct RouteTable {
|
||||
inner: RwLock<Vec<CompiledRoute>>,
|
||||
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||
}
|
||||
|
||||
impl RouteTable {
|
||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace the whole table atomically. The manager calls this after
|
||||
/// each successful route CRUD operation (by re-reading from DB).
|
||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
||||
/// Replace every per-app slice atomically. The manager calls this
|
||||
/// after each successful route CRUD operation; in cluster mode the
|
||||
/// orchestrator's HTTP-fed receiver will too.
|
||||
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||
for r in routes {
|
||||
by_app.entry(r.app_id).or_default().push(r);
|
||||
}
|
||||
let mut guard = self.inner.write().expect("route table poisoned");
|
||||
*guard = routes;
|
||||
*guard = by_app;
|
||||
}
|
||||
|
||||
/// Dispatch a request to a matching route, or `None`.
|
||||
/// Dispatch a request to a matching route within `app_id`, or
|
||||
/// `None`. Returns `None` when the app has no routes at all.
|
||||
#[must_use]
|
||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
||||
pub fn match_request_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
) -> Option<MatchResult> {
|
||||
let guard = self.inner.read().expect("route table poisoned");
|
||||
r#match(guard.iter(), host, method, path)
|
||||
let slice = guard.get(&app_id)?;
|
||||
r#match(slice.iter(), host, method, path)
|
||||
}
|
||||
|
||||
/// Returns a clone of the currently compiled routes; intended for
|
||||
/// the dashboard's "list routes" admin endpoint.
|
||||
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||
/// intended for admin endpoints like "list this app's routes".
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
||||
self.inner.read().expect("route table poisoned").clone()
|
||||
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.get(&app_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All compiled routes across all apps. Used by tests and the
|
||||
/// global admin "every route on this install" view.
|
||||
#[must_use]
|
||||
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.values()
|
||||
.flat_map(|v| v.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,5 @@ figment.workspace = true
|
||||
axum-test = "17"
|
||||
serde.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 picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
|
||||
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
||||
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
|
||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::RouteTable;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
use picloud_orchestrator_core::{
|
||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||
};
|
||||
@@ -35,6 +38,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
||||
pub struct AuthDeps {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
@@ -44,7 +48,8 @@ impl AuthDeps {
|
||||
pub fn from_pool(pool: PgPool) -> Self {
|
||||
Self {
|
||||
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
|
||||
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
|
||||
ttl: read_session_ttl(),
|
||||
}
|
||||
}
|
||||
@@ -80,14 +85,37 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||
// Authz: app_members repo doubles as the AuthzRepo impl for the
|
||||
// per-handler capability checks introduced in Phase 3.5.
|
||||
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||
|
||||
// Compile the routes table once at startup; admin writes refresh it.
|
||||
let route_table = Arc::new(RouteTable::new());
|
||||
let initial = route_repo.list_all().await?;
|
||||
let compiled = compile_routes(&initial)
|
||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||
route_table.replace(compiled);
|
||||
route_table.replace_all(compiled);
|
||||
|
||||
// Same shape for app domains (Host → app_id cache).
|
||||
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||
let initial_domains = domains_repo.list_all().await?;
|
||||
let compiled_domains: Vec<_> = initial_domains
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||
.ok()
|
||||
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||
app_id: d.app_id,
|
||||
pattern: p.pattern,
|
||||
shape_key: p.shape_key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
app_domain_table.replace(compiled_domains);
|
||||
|
||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||
script_repo.clone(),
|
||||
@@ -95,41 +123,69 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
||||
|
||||
let admin = AdminState {
|
||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||
logs: log_repo,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine as Arc<dyn ScriptValidator>,
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let route_admin = RouteAdminState {
|
||||
routes: route_repo,
|
||||
routes: route_repo.clone(),
|
||||
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||
domains: domains_repo.clone(),
|
||||
table: route_table.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let data_plane = DataPlaneState {
|
||||
executor,
|
||||
resolver,
|
||||
log_sink,
|
||||
app_domains: app_domain_table.clone(),
|
||||
routes: route_table,
|
||||
};
|
||||
let apps_state = AppsState {
|
||||
apps: apps_repo,
|
||||
domains: domains_repo,
|
||||
routes: route_repo,
|
||||
domain_table: app_domain_table,
|
||||
authz: authz.clone(),
|
||||
};
|
||||
|
||||
let auth_state = AuthState {
|
||||
users: auth.users.clone(),
|
||||
sessions: auth.sessions.clone(),
|
||||
keys: auth.keys.clone(),
|
||||
ttl: auth.ttl,
|
||||
};
|
||||
let admins_state = AdminsState {
|
||||
users: auth.users,
|
||||
sessions: auth.sessions,
|
||||
keys: auth.keys.clone(),
|
||||
authz,
|
||||
};
|
||||
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||
|
||||
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||
// you get in). /admin/auth/me applies the middleware internally so
|
||||
// the same Router::with_state machinery composes cleanly. Everything
|
||||
// else under /admin gets the require_admin layer.
|
||||
// else under /admin gets the require_authenticated layer; capability
|
||||
// checks live in each handler (after the resource is loaded so the
|
||||
// capability binds to the resource's actual app_id).
|
||||
let guarded_admin = Router::new()
|
||||
.merge(admin_router(admin))
|
||||
.merge(route_admin_router(route_admin))
|
||||
.merge(admins_router(admins_state))
|
||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(api_keys_router(api_keys_state))
|
||||
.layer(from_fn_with_state(
|
||||
auth_state.clone(),
|
||||
require_authenticated,
|
||||
));
|
||||
|
||||
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||
// facade above; the bare module path is retained so it's discoverable.
|
||||
let _ = apps_api::AppsState::clone;
|
||||
|
||||
let api_v1 = Router::new()
|
||||
.nest("/admin", auth_router(auth_state))
|
||||
@@ -201,6 +257,18 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list().await
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: picloud_shared::AppId,
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list_for_app(app_id).await
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: picloud_shared::AdminUserId,
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list_for_user(user_id).await
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
input: picloud_manager_core::NewScript,
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::time::Duration;
|
||||
use picloud::{build_app, init_db, AuthDeps};
|
||||
use picloud_manager_core::{
|
||||
auth::{hash_password, validate_password_hash},
|
||||
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
|
||||
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
|
||||
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
|
||||
PostgresScriptRepository,
|
||||
};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -43,6 +45,24 @@ async fn run_server() -> anyhow::Result<()> {
|
||||
|
||||
let auth = AuthDeps::from_pool(pool.clone());
|
||||
bootstrap_first_admin(&*auth.users).await?;
|
||||
warn_on_multi_owner_install(&*auth.users).await;
|
||||
|
||||
// Seed Hello World into the default app when this is a fresh
|
||||
// install (no scripts and no routes). Idempotent on upgrades.
|
||||
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
match seed_hello_world_if_fresh(apps, scripts, routes).await {
|
||||
Ok(HelloWorldOutcome::Seeded) => {
|
||||
tracing::info!("hello-world seed inserted into the default app");
|
||||
}
|
||||
Ok(HelloWorldOutcome::SkippedExisting) => {
|
||||
tracing::debug!("hello-world seed skipped (default app already populated)");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
|
||||
}
|
||||
}
|
||||
|
||||
// Background session-prune sweep. Cheap; keeps the table from
|
||||
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||
@@ -60,6 +80,34 @@ async fn run_server() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Multi-owner startup warning — Phase 3.5 migration upgraded every
|
||||
/// pre-existing admin_users row to `Owner` via DEFAULT, which for
|
||||
/// installs with several Phase 3a admins means several co-owners.
|
||||
/// Surface this once at boot so the operator can demote extras via
|
||||
/// `PATCH /api/v1/admin/admins/{id}` with `instance_role: "admin"`.
|
||||
/// Soft-fail: a DB blip should not block startup.
|
||||
async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) {
|
||||
match users.list_active_owners().await {
|
||||
Ok(owners) if owners.len() > 1 => {
|
||||
let names: Vec<String> = owners.into_iter().map(|u| u.username).collect();
|
||||
tracing::warn!(
|
||||
count = names.len(),
|
||||
owners = ?names,
|
||||
"multiple active owners detected — Phase 3.5 promoted every \
|
||||
pre-existing admin to owner. Demote extras via \
|
||||
PATCH /api/v1/admin/admins/{{id}} with instance_role."
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"could not count active owners for multi-owner startup check"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
||||
|
||||
@@ -23,12 +23,20 @@ use sqlx::PgPool;
|
||||
/// the bearer token into the TestServer as a default header so every
|
||||
/// request in the test passes the `require_admin` middleware.
|
||||
async fn server(pool: PgPool) -> TestServer {
|
||||
let (server, _app_id) = server_with_app(pool).await;
|
||||
server
|
||||
}
|
||||
|
||||
/// Like `server`, but also returns the default app's id — needed by
|
||||
/// any test that creates scripts (every script now requires `app_id`).
|
||||
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||
use picloud_manager_core::auth::hash_password;
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||
let hash = hash_password("test-pw").expect("hash");
|
||||
auth.users
|
||||
.create("test-admin", &hash)
|
||||
.create("test-admin", &hash, InstanceRole::Owner)
|
||||
.await
|
||||
.expect("seed admin");
|
||||
|
||||
@@ -45,7 +53,32 @@ async fn server(pool: PgPool) -> TestServer {
|
||||
.expect("login should return token")
|
||||
.to_string();
|
||||
server.add_header("authorization", format!("Bearer {token}"));
|
||||
server
|
||||
// Note: user-route dispatch needs an explicit `host: <claim>` header
|
||||
// on each request (the axum_test client doesn't default to a real
|
||||
// host). The default app claims `localhost`; user-route tests below
|
||||
// add the header per request via `.add_header("host", "localhost")`
|
||||
// so per-test overrides for other apps cleanly replace it.
|
||||
|
||||
// The 0005 migration unconditionally inserts a `default` app; fetch
|
||||
// its id so tests can attach scripts to it without re-running the
|
||||
// Rust-side hello-world seed (which only fires from main.rs).
|
||||
// The get-app handler returns `{ ...App, redirect_to?: ... }` —
|
||||
// the app fields are flattened at the response root.
|
||||
let app: Value = server.get("/api/v1/admin/apps/default").await.json();
|
||||
let app_id = app["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("default app id missing from response: {app}"))
|
||||
.to_string();
|
||||
(server, app_id)
|
||||
}
|
||||
|
||||
/// Merge `{ "app_id": <default> }` into a create-script body. Saves
|
||||
/// repeating the same field in 25+ tests.
|
||||
fn with_app(app_id: &str, mut body: Value) -> Value {
|
||||
body.as_object_mut()
|
||||
.expect("script body must be a JSON object")
|
||||
.insert("app_id".into(), Value::String(app_id.to_string()));
|
||||
body
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -67,30 +100,37 @@ async fn healthz_responds_ok(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"description": "test",
|
||||
"source": "#{ statusCode: 200, body: 42 }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CREATED);
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["name"], "echo");
|
||||
assert_eq!(body["version"], 1);
|
||||
assert_eq!(body["timeout_seconds"], 30);
|
||||
assert_eq!(body["app_id"], app_id);
|
||||
assert!(body["id"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||
let r = server(pool)
|
||||
.await
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({ "name": "broken", "source": "@@@ not rhai @@@" }),
|
||||
))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: Value = r.json();
|
||||
@@ -100,14 +140,14 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn duplicate_name_returns_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "dup", "source": "42" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "dup", "source": "42" })))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "dup", "source": "43" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "dup", "source": "43" })))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
@@ -115,10 +155,10 @@ async fn duplicate_name_returns_409(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn list_returns_all_scripts(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
for name in ["alpha", "bravo", "charlie"] {
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": name, "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": name, "source": "1" })))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
}
|
||||
@@ -133,10 +173,10 @@ async fn list_returns_all_scripts(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "u", "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -155,10 +195,10 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "u", "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "u", "source": "1" })))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -173,10 +213,10 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn delete_then_get_returns_404(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "d", "source": "1" }))
|
||||
.json(&with_app(&app_id, json!({ "name": "d", "source": "1" })))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -207,13 +247,16 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execute_echoes_body_back(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -230,13 +273,16 @@ async fn execute_echoes_body_back(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "header-test",
|
||||
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -263,13 +309,16 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "logger",
|
||||
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -320,10 +369,13 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({ "name": "no-sandbox", "source": "1" }),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(created["sandbox"], json!({}));
|
||||
@@ -332,14 +384,17 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "tight",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(
|
||||
@@ -359,14 +414,17 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "too-loose",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operations": 100_000_000 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: Value = r.json();
|
||||
@@ -376,14 +434,17 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let r = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "typo",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operashuns": 500 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||
// 400 depending on extractor; the routing is irrelevant here, just
|
||||
@@ -397,15 +458,18 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
// Tight max_operations on a loop the default would happily run.
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "tight-exec",
|
||||
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||
"sandbox": { "max_operations": 500 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -422,14 +486,17 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "patch-target",
|
||||
"source": "1",
|
||||
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
@@ -455,10 +522,10 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||
// Custom routing
|
||||
// ============================================================================
|
||||
|
||||
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
|
||||
async fn create_basic_script(s: &TestServer, app_id: &str, name: &str, source: &str) -> String {
|
||||
let v: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({ "name": name, "source": source }))
|
||||
.json(&with_app(app_id, json!({ "name": name, "source": source })))
|
||||
.await
|
||||
.json();
|
||||
v["id"].as_str().unwrap().to_string()
|
||||
@@ -467,9 +534,10 @@ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"greet",
|
||||
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
|
||||
)
|
||||
@@ -483,7 +551,7 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/greet").await;
|
||||
let r = s.get("/greet").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["msg"], "hi");
|
||||
@@ -493,9 +561,10 @@ async fn route_exact_dispatches_to_script(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_param_captures_path_vars(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"greet-name",
|
||||
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
|
||||
)
|
||||
@@ -509,7 +578,7 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/greet/alice").await;
|
||||
let r = s.get("/greet/alice").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["name"], "alice");
|
||||
@@ -518,9 +587,10 @@ async fn route_param_captures_path_vars(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_prefix_captures_rest(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"echo-prefix",
|
||||
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
|
||||
)
|
||||
@@ -534,19 +604,28 @@ async fn route_prefix_captures_rest(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/echo/foo/bar").await;
|
||||
let r = s.get("/echo/foo/bar").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["rest"], "foo/bar");
|
||||
|
||||
s.get("/echo").await.assert_status_not_found();
|
||||
s.get("/echo")
|
||||
.add_header("host", "localhost")
|
||||
.await
|
||||
.assert_status_not_found();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"qs",
|
||||
"#{ statusCode: 200, body: ctx.request.query }",
|
||||
)
|
||||
.await;
|
||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
"host_kind": "any",
|
||||
@@ -556,7 +635,7 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let r = s.get("/qs?a=1&b=two").await;
|
||||
let r = s.get("/qs?a=1&b=two").add_header("host", "localhost").await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body, json!({ "a": "1", "b": "two" }));
|
||||
@@ -565,8 +644,8 @@ async fn route_query_string_exposed_to_script(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "x", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||
let r = s
|
||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
@@ -581,8 +660,8 @@ async fn route_invalid_pattern_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_conflict_returns_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "x", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
"host_kind": "any",
|
||||
@@ -608,8 +687,8 @@ async fn route_conflict_returns_409(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "x", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "x", "1").await;
|
||||
let r = s
|
||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
@@ -624,8 +703,8 @@ async fn route_reserved_path_returns_422(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_match_preview_endpoint(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "g", "1").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "g", "1").await;
|
||||
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
"host_kind": "any",
|
||||
@@ -637,7 +716,11 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
||||
|
||||
let r = s
|
||||
.post("/api/v1/admin/routes:match")
|
||||
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
|
||||
.json(&json!({
|
||||
"app_id": app_id,
|
||||
"url": "http://localhost:8000/greet/alice",
|
||||
"method": "GET"
|
||||
}))
|
||||
.await;
|
||||
r.assert_status_ok();
|
||||
let body: Value = r.json();
|
||||
@@ -648,8 +731,8 @@ async fn route_match_preview_endpoint(pool: PgPool) {
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id = create_basic_script(&s, &app_id, "g", "#{ statusCode: 200, body: 1 }").await;
|
||||
let created: Value = s
|
||||
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
|
||||
.json(&json!({
|
||||
@@ -661,27 +744,35 @@ async fn route_delete_removes_dispatch(pool: PgPool) {
|
||||
.json();
|
||||
let route_id = created["id"].as_str().unwrap();
|
||||
|
||||
s.get("/g").await.assert_status_ok();
|
||||
s.get("/g")
|
||||
.add_header("host", "localhost")
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||
|
||||
s.get("/g").await.assert_status_not_found();
|
||||
s.get("/g")
|
||||
.add_header("host", "localhost")
|
||||
.await
|
||||
.assert_status_not_found();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let id_p = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"by-param",
|
||||
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
|
||||
)
|
||||
.await;
|
||||
let id_pr = create_basic_script(
|
||||
&s,
|
||||
&app_id,
|
||||
"by-prefix",
|
||||
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
|
||||
)
|
||||
@@ -704,12 +795,12 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
// Single segment under /foo/ — both match; param wins by spec.
|
||||
let r = s.get("/foo/x").await;
|
||||
let r = s.get("/foo/x").add_header("host", "localhost").await;
|
||||
let body: Value = r.json();
|
||||
assert_eq!(body["tag"], "param");
|
||||
|
||||
// Two segments — only prefix matches.
|
||||
let r2 = s.get("/foo/x/y").await;
|
||||
let r2 = s.get("/foo/x/y").add_header("host", "localhost").await;
|
||||
let body2: Value = r2.json();
|
||||
assert_eq!(body2["tag"], "prefix");
|
||||
}
|
||||
@@ -718,7 +809,7 @@ async fn route_specificity_param_beats_prefix(pool: PgPool) {
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn root_returns_404_when_no_route(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let r = s.get("/").await;
|
||||
let r = s.get("/").add_header("host", "localhost").await;
|
||||
r.assert_status_not_found();
|
||||
}
|
||||
|
||||
@@ -731,22 +822,325 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
||||
let v: Value = r.json();
|
||||
assert!(v["public_base_url"].is_string());
|
||||
assert_eq!(v["api"], 1);
|
||||
assert_eq!(v["schema"], 4);
|
||||
assert_eq!(v["schema"], 6);
|
||||
assert_eq!(v["sdk"], "1.1");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// App scoping (Phase 3b)
|
||||
// ============================================================================
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn default_app_is_seeded_by_migration(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let r = s.get("/api/v1/admin/apps").await;
|
||||
r.assert_status_ok();
|
||||
let apps: Vec<Value> = r.json();
|
||||
let default = apps
|
||||
.iter()
|
||||
.find(|a| a["slug"] == "default")
|
||||
.expect("default app must exist");
|
||||
assert_eq!(default["name"], "Default");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn cross_app_isolation_at_dispatch(pool: PgPool) {
|
||||
let (s, default_id) = server_with_app(pool).await;
|
||||
|
||||
// Two apps each create a script with the same name (per-app
|
||||
// uniqueness — would have collided pre-3b).
|
||||
let app_b: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "tenant-b", "name": "Tenant B" }))
|
||||
.await
|
||||
.json();
|
||||
let b_id = app_b["id"].as_str().unwrap();
|
||||
s.post(&format!("/api/v1/admin/apps/{b_id}/domains"))
|
||||
.json(&json!({ "pattern": "b.localhost" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let id_default: String = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
&default_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"source": "#{ statusCode: 200, body: #{ from: \"default\" } }"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json::<Value>()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let id_b: String = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
b_id,
|
||||
json!({
|
||||
"name": "echo",
|
||||
"source": "#{ statusCode: 200, body: #{ from: \"b\" } }"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json::<Value>()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
s.post(&format!("/api/v1/admin/scripts/{id_default}/routes"))
|
||||
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
s.post(&format!("/api/v1/admin/scripts/{id_b}/routes"))
|
||||
.json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
// Same path, different host — routes land in different apps.
|
||||
let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json();
|
||||
assert_eq!(from_default["from"], "default");
|
||||
|
||||
let from_b: Value = s
|
||||
.get("/echo")
|
||||
.add_header("host", "b.localhost")
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(from_b["from"], "b");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn unknown_host_returns_404(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let r = s.get("/whatever").add_header("host", "nope.invalid").await;
|
||||
r.assert_status_not_found();
|
||||
let body: Value = r.json();
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("no app claims host"));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execute_by_id_works_without_host_claim(pool: PgPool) {
|
||||
// The /api/v1/execute/{id} bypass is the implicit __internal__
|
||||
// claim of every app — it MUST keep working for an app with zero
|
||||
// public domain claims.
|
||||
let (s, _) = server_with_app(pool).await;
|
||||
let app: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "internal-only", "name": "Internal Only" }))
|
||||
.await
|
||||
.json();
|
||||
let app_id = app["id"].as_str().unwrap();
|
||||
let script: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
app_id,
|
||||
json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = script["id"].as_str().unwrap();
|
||||
let r = s
|
||||
.post(&format!("/api/v1/execute/{id}"))
|
||||
.json(&json!({}))
|
||||
.await;
|
||||
r.assert_status_ok();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn duplicate_slug_creates_a_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
s.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "alpha", "name": "First" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "alpha", "name": "Second" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn reserved_slug_rejected(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
for bad in ["new", "api", "admin", "login"] {
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": bad, "name": "x" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn slug_rename_keeps_old_as_redirect(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let app: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "old-slug", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
let id = app["id"].as_str().unwrap();
|
||||
s.patch(&format!("/api/v1/admin/apps/{id}"))
|
||||
.json(&json!({ "slug": "new-slug" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json();
|
||||
// The old slug resolves via history and surfaces `redirect_to`.
|
||||
assert_eq!(resp["redirect_to"], "new-slug");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
// Set up a history row.
|
||||
let first: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "soon-retired", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
s.patch(&format!(
|
||||
"/api/v1/admin/apps/{}",
|
||||
first["id"].as_str().unwrap()
|
||||
))
|
||||
.json(&json!({ "slug": "kept" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
// Plain create against the retired slug → 409.
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "soon-retired", "name": "y" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
|
||||
// With force_takeover → 201.
|
||||
let r = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CREATED);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn shape_key_collision_rejected(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let a: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "a", "name": "A" }))
|
||||
.await
|
||||
.json();
|
||||
let b: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "b", "name": "B" }))
|
||||
.await
|
||||
.json();
|
||||
s.post(&format!(
|
||||
"/api/v1/admin/apps/{}/domains",
|
||||
a["id"].as_str().unwrap()
|
||||
))
|
||||
.json(&json!({ "pattern": "*.example.com" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
// Parameterized form should collide with wildcard form.
|
||||
let r = s
|
||||
.post(&format!(
|
||||
"/api/v1/admin/apps/{}/domains",
|
||||
b["id"].as_str().unwrap()
|
||||
))
|
||||
.json(&json!({ "pattern": "{tenant}.example.com" }))
|
||||
.await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn delete_app_with_scripts_returns_409(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let app: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "with-scripts", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
let id = app["id"].as_str().unwrap();
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(id, json!({ "name": "s", "source": "1" })))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
let r = s.delete(&format!("/api/v1/admin/apps/{id}")).await;
|
||||
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn list_scripts_filtered_by_app(pool: PgPool) {
|
||||
let (s, default_id) = server_with_app(pool).await;
|
||||
let other: Value = s
|
||||
.post("/api/v1/admin/apps")
|
||||
.json(&json!({ "slug": "filter-target", "name": "x" }))
|
||||
.await
|
||||
.json();
|
||||
let other_id = other["id"].as_str().unwrap();
|
||||
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
&default_id,
|
||||
json!({ "name": "in-default", "source": "1" }),
|
||||
))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
s.post("/api/v1/admin/scripts")
|
||||
.json(&with_app(
|
||||
other_id,
|
||||
json!({ "name": "in-other", "source": "1" }),
|
||||
))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
// Filter by id.
|
||||
let filtered: Vec<Value> = s
|
||||
.get(&format!("/api/v1/admin/scripts?app={other_id}"))
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0]["name"], "in-other");
|
||||
|
||||
// Filter by slug.
|
||||
let filtered_by_slug: Vec<Value> = s
|
||||
.get("/api/v1/admin/scripts?app=filter-target")
|
||||
.await
|
||||
.json();
|
||||
assert_eq!(filtered_by_slug.len(), 1);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||
let s = server(pool).await;
|
||||
let (s, app_id) = server_with_app(pool).await;
|
||||
let created: Value = s
|
||||
.post("/api/v1/admin/scripts")
|
||||
.json(&json!({
|
||||
.json(&with_app(
|
||||
&app_id,
|
||||
json!({
|
||||
"name": "boom",
|
||||
"source": "1 / 0",
|
||||
}))
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.json();
|
||||
let id = created["id"].as_str().unwrap();
|
||||
|
||||
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}")]
|
||||
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 uuid::Uuid;
|
||||
|
||||
use crate::{RequestId, ScriptId};
|
||||
use crate::{AppId, RequestId, ScriptId};
|
||||
|
||||
/// One row in the `execution_logs` table. Same shape flows through the
|
||||
/// `ExecutionLogSink` trait and the `GET /scripts/{id}/logs` response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutionLog {
|
||||
pub id: Uuid,
|
||||
/// Owning app at the time of execution. Materialized at write time
|
||||
/// so a future "move script to another app" doesn't retag history.
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub request_id: RequestId,
|
||||
|
||||
|
||||
@@ -51,3 +51,5 @@ id_type!(ScriptId);
|
||||
id_type!(ExecutionId);
|
||||
id_type!(RequestId);
|
||||
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
|
||||
//! entity, error roots, transport DTOs).
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod execution_log;
|
||||
pub mod ids;
|
||||
@@ -14,9 +16,11 @@ pub mod script;
|
||||
pub mod validator;
|
||||
pub mod version;
|
||||
|
||||
pub use app::{App, AppDomain, DomainShape};
|
||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use error::Error;
|
||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||
pub use ids::{AdminUserId, ExecutionId, RequestId, ScriptId};
|
||||
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
pub use route::{HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
|
||||
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ScriptId;
|
||||
use crate::{AppId, ScriptId};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -40,6 +40,10 @@ pub enum PathKind {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Route {
|
||||
pub id: Uuid,
|
||||
/// Owning app. Always equals `scripts.app_id` for the bound script.
|
||||
/// Carried on the route row so the orchestrator can partition the
|
||||
/// route table without joining back to scripts on every refresh.
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
|
||||
pub host_kind: HostKind,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ScriptId, ScriptSandbox};
|
||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||
|
||||
/// A user-uploaded Rhai script and its execution configuration.
|
||||
///
|
||||
@@ -11,6 +11,10 @@ use crate::{ScriptId, ScriptSandbox};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: ScriptId,
|
||||
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||
/// semantics — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
|
||||
17
dashboard/package-lock.json
generated
17
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.2",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -1275,7 +1275,6 @@
|
||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@@ -1318,7 +1317,6 @@
|
||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||
"debug": "^4.4.1",
|
||||
@@ -1398,7 +1396,6 @@
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1455,7 +1452,6 @@
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
@@ -1563,7 +1559,6 @@
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1815,7 +1810,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2217,7 +2211,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3010,7 +3003,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3038,7 +3030,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3172,7 +3163,6 @@
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3433,7 +3423,6 @@
|
||||
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -3614,7 +3603,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3677,7 +3665,6 @@
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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 {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
version: number;
|
||||
@@ -32,11 +33,64 @@ export interface Script {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||
|
||||
export interface AppDomain {
|
||||
id: string;
|
||||
app_id: string;
|
||||
pattern: string;
|
||||
shape: DomainShape;
|
||||
shape_key: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AppLookupResponse {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/// Present only when the requested slug was a retired redirect.
|
||||
redirect_to?: string;
|
||||
}
|
||||
|
||||
export interface SlugCheckResponse {
|
||||
ok: boolean;
|
||||
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
|
||||
current_app: App | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAppInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
force_takeover?: boolean;
|
||||
}
|
||||
|
||||
export interface PatchAppInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
slug?: string;
|
||||
force_takeover?: boolean;
|
||||
}
|
||||
|
||||
export type HostKind = 'any' | 'strict' | 'wildcard';
|
||||
export type PathKind = 'exact' | 'prefix' | 'param';
|
||||
|
||||
export interface Route {
|
||||
id: string;
|
||||
app_id: string;
|
||||
script_id: string;
|
||||
host_kind: HostKind;
|
||||
host: string;
|
||||
@@ -106,6 +160,7 @@ export interface ExecutionLog {
|
||||
}
|
||||
|
||||
export interface CreateScriptInput {
|
||||
app_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
source: string;
|
||||
@@ -257,20 +312,23 @@ export const api = {
|
||||
}),
|
||||
remove: (routeId: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
|
||||
check: (input: RouteInput) =>
|
||||
check: (appId: string, input: RouteInput) =>
|
||||
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
body: JSON.stringify({ ...input, app_id: appId })
|
||||
}),
|
||||
match: (url: string, method = 'GET') =>
|
||||
match: (appId: string, url: string, method = 'GET') =>
|
||||
adminRequest<MatchRouteResponse>('/api/v1/admin/routes:match', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url, method })
|
||||
body: JSON.stringify({ app_id: appId, url, method })
|
||||
})
|
||||
},
|
||||
|
||||
scripts: {
|
||||
list: () => adminRequest<Script[]>('/api/v1/admin/scripts'),
|
||||
list: (opts: { app?: string } = {}) => {
|
||||
const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : '';
|
||||
return adminRequest<Script[]>(`/api/v1/admin/scripts${qs}`);
|
||||
},
|
||||
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
|
||||
create: (input: CreateScriptInput) =>
|
||||
adminRequest<Script>('/api/v1/admin/scripts', {
|
||||
@@ -295,6 +353,54 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
apps: {
|
||||
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
|
||||
get: (idOrSlug: string) =>
|
||||
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
|
||||
create: (input: CreateAppInput) =>
|
||||
adminRequest<App>('/api/v1/admin/apps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (idOrSlug: string, input: PatchAppInput) =>
|
||||
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
|
||||
const qs = opts.force ? '?force=true' : '';
|
||||
return adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
slugCheck: (idOrSlug: string, newSlug: string) =>
|
||||
adminRequest<SlugCheckResponse>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_slug: newSlug })
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
domains: {
|
||||
listForApp: (idOrSlug: string) =>
|
||||
adminRequest<AppDomain[]>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
|
||||
),
|
||||
create: (idOrSlug: string, pattern: string) =>
|
||||
adminRequest<AppDomain>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
|
||||
{ method: 'POST', body: JSON.stringify({ pattern }) }
|
||||
),
|
||||
remove: (idOrSlug: string, domainId: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
},
|
||||
|
||||
execute: async (
|
||||
id: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostKind, PathKind } from './api';
|
||||
import type { AppDomain, HostKind, PathKind } from './api';
|
||||
|
||||
/** Guess a path kind from the literal user input. The dashboard pre-fills
|
||||
* the kind selector but the user can override (the backend trusts the
|
||||
@@ -30,3 +30,97 @@ export function pathKindMismatchWarning(raw: string, kind: PathKind): string | n
|
||||
: 'this looks like a literal path';
|
||||
return `Selected kind is "${kind}", but ${hint}. Routing will use the selected kind.`;
|
||||
}
|
||||
|
||||
/** Parse the user's free-text host input into the (kind, stored host)
|
||||
* pair the API expects. Mirrors the dashboard's pre-existing storage
|
||||
* convention: wildcard route hosts are stored as the bare suffix
|
||||
* (`*.foo.com` → `foo.com`); display-time formatting re-adds the `*.`. */
|
||||
export interface ParsedHostInput {
|
||||
kind: HostKind;
|
||||
/** What gets sent in the API payload as `host`. */
|
||||
host: string;
|
||||
/** Canonical display form, for chips/labels. */
|
||||
display: string;
|
||||
}
|
||||
|
||||
export function parseHostInput(raw: string): ParsedHostInput {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '' || trimmed === '*') {
|
||||
return { kind: 'any', host: '', display: '*' };
|
||||
}
|
||||
if (trimmed.startsWith('*.')) {
|
||||
const suffix = trimmed.slice(2);
|
||||
return { kind: 'wildcard', host: suffix, display: `*.${suffix}` };
|
||||
}
|
||||
return { kind: 'strict', host: trimmed, display: trimmed };
|
||||
}
|
||||
|
||||
/** Frontend mirror of `validate_route_host_against_app` in
|
||||
* crates/manager-core/src/route_admin.rs. Lets us surface a live
|
||||
* warning instead of waiting for a 422. The server runs the same
|
||||
* check on submit — this is purely an early signal. */
|
||||
export type HostClaimCheck =
|
||||
| { ok: true; matched: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export function checkHostAgainstClaims(
|
||||
parsed: ParsedHostInput,
|
||||
claims: AppDomain[]
|
||||
): HostClaimCheck {
|
||||
if (parsed.kind === 'any') return { ok: true, matched: '*' };
|
||||
if (claims.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'this app has no domain claims yet — add one in the 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>
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href={base + '/'}>Scripts</a>
|
||||
<a href={base + '/apps'}>Apps</a>
|
||||
<a href={base + '/admins'}>Admins</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
|
||||
@@ -1,242 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type Script } from '$lib/api';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||
|
||||
let scripts = $state<Script[] | null>(null);
|
||||
let listError = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
let showCreate = $state(false);
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
let createSource = $state(SAMPLE_SOURCE);
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
listError = null;
|
||||
try {
|
||||
scripts = await api.scripts.list();
|
||||
} catch (e) {
|
||||
listError = e instanceof Error ? e.message : String(e);
|
||||
scripts = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate(event: Event) {
|
||||
event.preventDefault();
|
||||
creating = true;
|
||||
createError = null;
|
||||
try {
|
||||
await api.scripts.create({
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
source: createSource
|
||||
});
|
||||
showCreate = false;
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createSource = SAMPLE_SOURCE;
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : String(e);
|
||||
if (e instanceof ApiError && e.status === 422) {
|
||||
createError = `Syntax error: ${createError}`;
|
||||
}
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load();
|
||||
// Dashboard entry: always lands on the apps list now (multi-app
|
||||
// scoping makes "scripts at root" no longer meaningful — every
|
||||
// script lives inside an app).
|
||||
onMount(() => {
|
||||
void goto(`${base}/apps`, { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<header class="page-header">
|
||||
<h1>Scripts</h1>
|
||||
<button type="button" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Cancel' : 'New script'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={submitCreate}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input bind:value={createName} required minlength="1" placeholder="echo" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="full">
|
||||
<span>Source (Rhai)</span>
|
||||
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create script'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if listError}
|
||||
<div class="error">
|
||||
<strong>Could not load scripts.</strong>
|
||||
<p>{listError}</p>
|
||||
<button type="button" onclick={() => void load()}>Retry</button>
|
||||
</div>
|
||||
{:else if scripts && scripts.length === 0}
|
||||
<p class="muted">No scripts yet. Create one above to get started.</p>
|
||||
{:else if scripts}
|
||||
<ul class="list">
|
||||
{#each scripts as script (script.id)}
|
||||
<li>
|
||||
<a href="{base}/scripts/{script.id}">
|
||||
<div class="primary">
|
||||
<strong>{script.name}</strong>
|
||||
<span class="muted">v{script.version}</span>
|
||||
</div>
|
||||
<div class="secondary muted">
|
||||
{script.description ?? '—'}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
<p class="muted">Redirecting…</p>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.create-form label.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.list a:hover {
|
||||
background: #283549;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
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 {
|
||||
api,
|
||||
ApiError,
|
||||
type AppDomain,
|
||||
type ExecutionLog,
|
||||
type Route,
|
||||
type RouteInput,
|
||||
@@ -12,7 +13,13 @@
|
||||
type VersionInfo
|
||||
} from '$lib/api';
|
||||
import { logLevelColor, statusColor } from '$lib/styles';
|
||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
||||
import {
|
||||
checkHostAgainstClaims,
|
||||
guessPathKind,
|
||||
hostSuggestions,
|
||||
parseHostInput,
|
||||
pathKindMismatchWarning
|
||||
} from '$lib/route-utils';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
import { format as formatRhai } from '$lib/rhai';
|
||||
|
||||
@@ -38,6 +45,9 @@
|
||||
let scriptLoading = $state(true);
|
||||
let info = $state<VersionInfo | null>(null);
|
||||
|
||||
let appSlug = $state<string | null>(null);
|
||||
let appDomains = $state<AppDomain[]>([]);
|
||||
|
||||
async function loadScript() {
|
||||
scriptLoading = true;
|
||||
scriptError = null;
|
||||
@@ -48,6 +58,23 @@
|
||||
editableDescription = script.description ?? '';
|
||||
editableTimeout = script.timeout_seconds;
|
||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||
// Resolve the owning app's slug for the breadcrumb and its
|
||||
// domain claims for the route form's suggestions + live
|
||||
// validation. Both are non-fatal — the page works without
|
||||
// them.
|
||||
const appId = script.app_id;
|
||||
void api.apps
|
||||
.get(appId)
|
||||
.then((a) => {
|
||||
appSlug = a.slug;
|
||||
})
|
||||
.catch(() => {});
|
||||
void api.domains
|
||||
.listForApp(appId)
|
||||
.then((d) => {
|
||||
appDomains = d;
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
scriptError = e instanceof Error ? e.message : String(e);
|
||||
script = null;
|
||||
@@ -165,12 +192,14 @@
|
||||
let routesLoading = $state(true);
|
||||
|
||||
let showAddRoute = $state(false);
|
||||
let newRoutePath = $state('');
|
||||
let newRoutePath = $state('/');
|
||||
let newRoutePathKind = $state<'exact' | 'prefix' | 'param'>('exact');
|
||||
let newRouteHost = $state('');
|
||||
let newRouteHostKind = $state<'any' | 'strict' | 'wildcard'>('any');
|
||||
// Host input is free-form; the kind is derived from what the user
|
||||
// typed (see `parsedHost` below). Default `*` = Any, matching the
|
||||
// canonical display form for an unrestricted host.
|
||||
let newRouteHost = $state('*');
|
||||
let newRouteMethod = $state('');
|
||||
let routeKindAutoUpdate = $state(true);
|
||||
let pathKindAutoUpdate = $state(true);
|
||||
let creatingRoute = $state(false);
|
||||
let createRouteError = $state<string | null>(null);
|
||||
|
||||
@@ -178,17 +207,17 @@
|
||||
let previewMethod = $state('GET');
|
||||
let previewResult = $state<string | null>(null);
|
||||
|
||||
// Auto-update kind selectors as the user types.
|
||||
// Auto-update the path-kind selector as the user types.
|
||||
$effect(() => {
|
||||
if (routeKindAutoUpdate) {
|
||||
if (pathKindAutoUpdate) {
|
||||
newRoutePathKind = guessPathKind(newRoutePath);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (routeKindAutoUpdate) {
|
||||
newRouteHostKind = guessHostKind(newRouteHost);
|
||||
}
|
||||
});
|
||||
|
||||
let parsedHost = $derived(parseHostInput(newRouteHost));
|
||||
let hostCheck = $derived(checkHostAgainstClaims(parsedHost, appDomains));
|
||||
let hostDatalistId = 'route-host-suggestions';
|
||||
let suggestions = $derived(hostSuggestions(appDomains));
|
||||
|
||||
let pathKindWarning = $derived(
|
||||
newRoutePath.trim() ? pathKindMismatchWarning(newRoutePath, newRoutePathKind) : null
|
||||
@@ -212,18 +241,18 @@
|
||||
createRouteError = null;
|
||||
try {
|
||||
const input: RouteInput = {
|
||||
host_kind: newRouteHostKind,
|
||||
host: newRouteHostKind === 'any' ? '' : newRouteHost.trim(),
|
||||
host_kind: parsedHost.kind,
|
||||
host: parsedHost.host,
|
||||
path_kind: newRoutePathKind,
|
||||
path: newRoutePath.trim(),
|
||||
method: newRouteMethod.trim() || null
|
||||
};
|
||||
await api.routes.create(id, input);
|
||||
showAddRoute = false;
|
||||
newRoutePath = '';
|
||||
newRouteHost = '';
|
||||
newRoutePath = '/';
|
||||
newRouteHost = '*';
|
||||
newRouteMethod = '';
|
||||
routeKindAutoUpdate = true;
|
||||
pathKindAutoUpdate = true;
|
||||
await loadRoutes();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
@@ -251,8 +280,9 @@
|
||||
|
||||
async function runPreview() {
|
||||
previewResult = null;
|
||||
if (!script) return;
|
||||
try {
|
||||
const r = await api.routes.match(previewUrl, previewMethod);
|
||||
const r = await api.routes.match(script.app_id, previewUrl, previewMethod);
|
||||
if (r.matched) {
|
||||
const ours = r.matched.script_id === id;
|
||||
const tag = ours ? '✓ matches THIS script' : '⚠ matches a DIFFERENT script';
|
||||
@@ -368,6 +398,13 @@
|
||||
{:else if script}
|
||||
<header class="page-header">
|
||||
<div>
|
||||
{#if appSlug}
|
||||
<div class="breadcrumb">
|
||||
<a href="{base}/apps">Apps</a> /
|
||||
<a href="{base}/apps/{appSlug}">{appSlug}</a> / Scripts /
|
||||
<code>{script.name}</code>
|
||||
</div>
|
||||
{/if}
|
||||
<h1>{script.name}</h1>
|
||||
<p class="muted">
|
||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||
@@ -484,7 +521,7 @@
|
||||
<span>Path</span>
|
||||
<input
|
||||
bind:value={newRoutePath}
|
||||
oninput={() => (routeKindAutoUpdate = true)}
|
||||
oninput={() => (pathKindAutoUpdate = true)}
|
||||
placeholder="/greet, /greet/:name, /webhooks/*"
|
||||
required
|
||||
autocomplete="off"
|
||||
@@ -495,7 +532,7 @@
|
||||
<span>Path kind</span>
|
||||
<select
|
||||
bind:value={newRoutePathKind}
|
||||
onchange={() => (routeKindAutoUpdate = false)}
|
||||
onchange={() => (pathKindAutoUpdate = false)}
|
||||
>
|
||||
<option value="exact">exact</option>
|
||||
<option value="param">param</option>
|
||||
@@ -514,31 +551,43 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Host kind</span>
|
||||
<select
|
||||
bind:value={newRouteHostKind}
|
||||
onchange={() => (routeKindAutoUpdate = false)}
|
||||
>
|
||||
<option value="any">ANY</option>
|
||||
<option value="strict">strict</option>
|
||||
<option value="wildcard">wildcard</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class:disabled={newRouteHostKind === 'any'}>
|
||||
<span>Host</span>
|
||||
<label class="full">
|
||||
<span class="host-label">
|
||||
Host
|
||||
<span class="kind-chip kind-{parsedHost.kind}">
|
||||
{parsedHost.kind}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
bind:value={newRouteHost}
|
||||
oninput={() => (routeKindAutoUpdate = true)}
|
||||
disabled={newRouteHostKind === 'any'}
|
||||
placeholder={newRouteHostKind === 'wildcard'
|
||||
? '*.example.com'
|
||||
: 'sub.example.com'}
|
||||
placeholder="* · app.example.com · *.example.com"
|
||||
list={hostDatalistId}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<datalist id={hostDatalistId}>
|
||||
{#each suggestions as s (s)}
|
||||
<option value={s}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<small class="muted">
|
||||
<code>*</code> = any host claimed by this app ·
|
||||
<code>*.foo.com</code> = wildcard · <code>foo.com</code> =
|
||||
strict
|
||||
</small>
|
||||
</label>
|
||||
{#if !hostCheck.ok}
|
||||
<div class="warning inline">
|
||||
{hostCheck.reason}.
|
||||
{#if appDomains.length > 0}
|
||||
Claims:
|
||||
{#each appDomains as d, i (d.id)}<code>{d.pattern}</code>{#if i < appDomains.length - 1},
|
||||
{/if}{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if pathKindWarning}
|
||||
<div class="warning inline">{pathKindWarning}</div>
|
||||
{/if}
|
||||
@@ -756,6 +805,23 @@
|
||||
align-items: flex-start;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.breadcrumb code {
|
||||
background: #1e293b;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
@@ -916,9 +982,6 @@
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
label.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
label.full {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1035,6 +1098,31 @@
|
||||
background: #581c87;
|
||||
color: #e9d5ff;
|
||||
}
|
||||
.host-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kind-chip {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.kind-chip.kind-any {
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.kind-chip.kind-strict {
|
||||
background: #14532d;
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.kind-chip.kind-wildcard {
|
||||
background: #1e3a8a;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
.route-row .method {
|
||||
color: #fbbf24;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -40,6 +40,12 @@ services:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
|
||||
# Bootstrap admin (Phase 3a). Read once on first start to seed the
|
||||
# admin_users table; ignored on subsequent boots if the table is
|
||||
# non-empty. No defaults on purpose — leaving these unset in prod
|
||||
# is a foot-gun. For dev, .env.example documents sensible values.
|
||||
PICLOUD_ADMIN_USERNAME: ${PICLOUD_ADMIN_USERNAME:?set PICLOUD_ADMIN_USERNAME (see .env.example)}
|
||||
PICLOUD_ADMIN_PASSWORD: ${PICLOUD_ADMIN_PASSWORD:?set PICLOUD_ADMIN_PASSWORD (see .env.example)}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -126,10 +126,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|
||||
|
||||
| | Version |
|
||||
|---|---|
|
||||
| Product | `0.5.1` |
|
||||
| Product | `0.6.0` |
|
||||
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
||||
| API | `1` |
|
||||
| Schema | `3` (matches `migrations/0003_routes.sql`) |
|
||||
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` and `/api/v1/admin/api-keys/*` endpoints, `?app=` filter on script list, `Authorization: Bearer pic_…` credential type, 403 responses on previously-401-only admin endpoints when the caller lacks the required capability) |
|
||||
| Schema | `6` (matches `migrations/0006_users_authz.sql`) |
|
||||
| Wire | `1` (reserved; cluster mode not implemented) |
|
||||
|
||||
Read live from `GET /version` on any running instance.
|
||||
|
||||
@@ -853,7 +853,17 @@ Permission checks land in middleware that initially only enforces "authenticated
|
||||
|
||||
---
|
||||
|
||||
## 11.5 App Scoping (v1.x)
|
||||
## 11.5 App Scoping (Phase 3b) — Shipped
|
||||
|
||||
**Status**: shipped. Implementation lives in:
|
||||
- `crates/shared/src/{app,ids,script,route}.rs` — `App`, `AppDomain`, `AppId`, `app_id` fields on `Script`/`Route`/`ExecutionLog`.
|
||||
- `crates/manager-core/src/{app_repo,app_domain_repo,apps_api,app_bootstrap}.rs` — repos + admin API + Hello-World seed.
|
||||
- `crates/orchestrator-core/src/routing/{app_domains,pattern,table}.rs` — `AppDomainTable`, `parse_app_domain`, per-app `RouteTable`.
|
||||
- Migration `0005_apps.sql`.
|
||||
|
||||
**Deviations from the design below**: none of substance. Two operational notes:
|
||||
- The Hello-World seed lives in `crates/manager-core/seeds/hello.rhai` and is inserted by a Rust bootstrap step (`seed_hello_world_if_fresh`) rather than from the migration — keeps it testable and gives the dashboard editor real source to render. The migration always inserts the `default` app + `localhost` claim; the seed only fires when that app is otherwise empty.
|
||||
- Per-app admin roles/permissions are deferred — every authenticated admin can act on every app. The middleware seam (`auth_middleware::require_admin`) is the place where role checks slot in later.
|
||||
|
||||
**Purpose**: PiCloud hosts multiple independent applications on one platform. Each app is the isolation boundary for scripts, routes, domains, and (later) data — App A cannot see or modify App B's resources except through HTTP calls between them.
|
||||
|
||||
@@ -1012,6 +1022,152 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
||||
|
||||
---
|
||||
|
||||
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending
|
||||
|
||||
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`.
|
||||
|
||||
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
|
||||
|
||||
**Why this slot**: same logic as Phase 3b. Adding a `Principal` parameter and a capability check to surfaces that don't exist yet is free; retrofitting them onto live SDK services after v1.1 ships is a refactor of every gate.
|
||||
|
||||
### Principal Model
|
||||
|
||||
One `Principal` value represents a human admin user. Service accounts (CI bots, Rhai scripts calling out) get **schema room** in this phase but no runtime support — `users.kind` style differentiation lands when Phase 4's `users.*` SDK arrives. Until then, every authenticated request resolves to exactly one admin row, whether the credential is a session cookie or a bearer API key.
|
||||
|
||||
```rust
|
||||
pub struct Principal {
|
||||
pub user_id: UserId, // alias of AdminUserId for the transition
|
||||
pub instance_role: InstanceRole,
|
||||
pub scopes: Option<Vec<Scope>>, // None = cookie session (full role authority)
|
||||
// Some = API key (intersect with role)
|
||||
pub app_binding: Option<AppId>, // API key bound to one app; denies other apps
|
||||
}
|
||||
```
|
||||
|
||||
### Instance Roles (one per user)
|
||||
|
||||
| Role | Powers |
|
||||
|---|---|
|
||||
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
||||
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||
|
||||
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||
|
||||
### App-Scoped Roles (zero-to-many per user × app)
|
||||
|
||||
| Role | Grants |
|
||||
|---|---|
|
||||
| `app_admin` | settings, domain claims, delete app |
|
||||
| `editor` | CRUD on scripts, routes, sandbox config |
|
||||
| `viewer` | read scripts + execution logs |
|
||||
|
||||
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
||||
|
||||
### Auth Methods — Same Principal, Different Extractor
|
||||
|
||||
Two credential types feed the same middleware:
|
||||
|
||||
1. **Session cookie** (Phase 3a, unchanged) — `picloud_session=<token>`. Extracted by header or cookie. SHA-256 lookup against `admin_sessions.token_hash`. Sliding 24h TTL. Produces `Principal { scopes: None, app_binding: None }`.
|
||||
|
||||
2. **Bearer API key** (new) — `Authorization: Bearer pic_<base32(32 random bytes)>`. The `pic_` prefix is the discriminator: present → API key path; absent → session path. The 8 chars immediately after `pic_` are indexed (`api_keys.prefix`); the full body after `pic_` is Argon2id-verified against each candidate's `hash`. Last-used timestamp updates inline.
|
||||
|
||||
Both paths converge on the same `Principal` extension; handlers cannot tell which credential was presented unless they introspect `principal.scopes`.
|
||||
|
||||
### API Key Format & Storage
|
||||
|
||||
- Raw form: `pic_<base32(32 random bytes, no padding)>` — ~56 chars total.
|
||||
- Stored: 8-char prefix + Argon2id PHC hash of the body. Raw value returned **exactly once** in the `POST /api/v1/admin/api-keys` response; never logged, never readable again.
|
||||
- Optional `expires_at`. Lookup queries always filter `expires_at IS NULL OR expires_at > NOW()`.
|
||||
- Optional `app_id` ("bound key") — every `App*(other_app)` capability is denied for this key, regardless of the user's role.
|
||||
|
||||
### Scope Set (intentionally narrow)
|
||||
|
||||
Exactly seven scopes; no further subdivision until a real use case appears:
|
||||
|
||||
`script:read`, `script:write`, `route:write`, `domain:manage`, `log:read`, `app:admin`, `instance:admin`
|
||||
|
||||
Mint-time validation rejects unknown values. Bound keys (`app_id` set) cannot carry `instance:*` scopes — the combination is irreconcilable (a bound credential cannot claim instance-wide authority) and is rejected with 422.
|
||||
|
||||
### Effective Capability — `can(principal, capability)`
|
||||
|
||||
```
|
||||
allow = role_grants(principal.instance_role, capability)
|
||||
∧ (principal.scopes.is_none() ∨ required_scope(capability) ∈ principal.scopes)
|
||||
∧ (principal.app_binding.is_none() ∨ capability.app_id() == principal.app_binding)
|
||||
```
|
||||
|
||||
`role_grants` collapses the three tables (instance role + implicit app grants + explicit `app_members`) into a single yes/no. Each handler calls `state.authz.require(&principal, Capability::AppWrite(script.app_id))` after loading the resource (so the capability binds to the resource's actual `app_id`, not a path param the caller controls).
|
||||
|
||||
### Deactivation Symmetry
|
||||
|
||||
Phase 3a's `set_active(false)` wipes that user's `admin_sessions`. Phase 3.5 extends it to also set `expires_at = NOW()` on every row in `api_keys WHERE user_id = $1` — both credential surfaces become inert at the same moment, no enumeration window.
|
||||
|
||||
### CLI Auth Posture (forward note)
|
||||
|
||||
The eventual `picloud` CLI authenticates by **paste-the-token**, not OAuth: the user runs `picloud login`, the dashboard mints a fresh key (or the user mints one via `POST /api/v1/admin/api-keys`), and the CLI prompts for the raw token. The CLI binary itself is deferred; the dashboard surface and the bearer credential type land here so the CLI is a thin wrapper when it arrives.
|
||||
|
||||
### Schema (Migration 0006)
|
||||
|
||||
```sql
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||
CHECK (instance_role IN ('owner','admin','member')),
|
||||
ADD COLUMN email TEXT UNIQUE,
|
||||
ADD COLUMN mfa_secret TEXT; -- reserved slot, not built
|
||||
|
||||
CREATE TABLE app_members (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('app_admin','editor','viewer')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, user_id)
|
||||
);
|
||||
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
hash TEXT NOT NULL, -- Argon2id PHC
|
||||
prefix TEXT NOT NULL, -- first 8 chars after `pic_`
|
||||
name TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL, -- intersected with role at check time
|
||||
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||
|
||||
-- Reserved (not built this phase):
|
||||
-- invites (token, email, instance_role, app_id, app_role, invited_by, expires_at, consumed_at)
|
||||
-- service_accounts (id, name, owning_user_id, …)
|
||||
```
|
||||
|
||||
### New Endpoints (additive — no API major bump)
|
||||
|
||||
```
|
||||
POST /api/v1/admin/api-keys — { name, scopes[], app_id?, expires_at? }
|
||||
→ 201 { …, raw_token } (raw returned exactly once)
|
||||
GET /api/v1/admin/api-keys — list caller's own keys (no raw)
|
||||
DELETE /api/v1/admin/api-keys/{id} — caller's own only
|
||||
```
|
||||
|
||||
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
||||
|
||||
### Out of Scope (Phase 3.5)
|
||||
|
||||
Schema room only, not built:
|
||||
|
||||
- **Invites** — email-based join flow; `invites` table reserved in the migration comment block.
|
||||
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
||||
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
||||
|
||||
Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
||||
|
||||
---
|
||||
|
||||
## 12. Development Roadmap
|
||||
|
||||
### Phase 1: MVP ✓ (Shipped)
|
||||
@@ -1038,13 +1194,15 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
||||
|
||||
### Phase 3: v1.0.x — Foundations (Current focus)
|
||||
|
||||
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||
Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||
|
||||
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||
|
||||
**3b. Multi-app scoping** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
|
||||
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
|
||||
|
||||
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
|
||||
**3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
|
||||
|
||||
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user