Compare commits
21 Commits
feat/multi
...
feat/users
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb32a78bf | ||
|
|
fc35d59236 | ||
|
|
0c9f11558a | ||
|
|
39a6df2bfe | ||
|
|
d21cbdb164 | ||
|
|
700ae7b7d1 | ||
|
|
f16ff22a5a | ||
|
|
bd2258499e | ||
|
|
df691038d7 | ||
|
|
3688c26cb4 | ||
|
|
2aab92af31 | ||
|
|
063595be31 | ||
|
|
30a1584667 | ||
|
|
d229120df6 | ||
|
|
8659a58eb2 | ||
|
|
5f7ddd23ab | ||
|
|
44db8d107a | ||
|
|
abaabb68d8 | ||
|
|
fd6f2b1f13 | ||
|
|
d435322f9c | ||
|
|
5546323cdc |
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -408,6 +408,12 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -1305,12 +1311,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud"
|
name = "picloud"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
|
"chrono",
|
||||||
"figment",
|
"figment",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1325,11 +1332,12 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor"
|
name = "picloud-executor"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-executor-core",
|
"picloud-executor-core",
|
||||||
@@ -1341,7 +1349,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-executor-core"
|
name = "picloud-executor-core"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
@@ -1355,7 +1363,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager"
|
name = "picloud-manager"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-manager-core",
|
"picloud-manager-core",
|
||||||
@@ -1367,13 +1375,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-manager-core"
|
name = "picloud-manager-core"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"data-encoding",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
"picloud-shared",
|
"picloud-shared",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
@@ -1390,7 +1399,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator"
|
name = "picloud-orchestrator"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"picloud-orchestrator-core",
|
"picloud-orchestrator-core",
|
||||||
@@ -1402,7 +1411,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-orchestrator-core"
|
name = "picloud-orchestrator-core"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1421,7 +1430,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picloud-shared"
|
name = "picloud-shared"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -66,11 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
# Auth (admin users + sessions)
|
# Auth (admin users + sessions + API keys)
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = { version = "0.8", features = ["getrandom"] }
|
rand = { version = "0.8", features = ["getrandom"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
data-encoding = "2.6"
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ argon2.workspace = true
|
|||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
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()
|
||||||
|
-- );
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -20,6 +20,12 @@ pub enum AdminUserRepositoryError {
|
|||||||
|
|
||||||
#[error("username already taken: {0}")]
|
#[error("username already taken: {0}")]
|
||||||
DuplicateUsername(String),
|
DuplicateUsername(String),
|
||||||
|
|
||||||
|
#[error("email already taken: {0}")]
|
||||||
|
DuplicateEmail(String),
|
||||||
|
|
||||||
|
#[error("invalid instance_role stored in DB: {0}")]
|
||||||
|
InvalidInstanceRole(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row returned to handlers and bootstrap. Never includes the password
|
/// Row returned to handlers and bootstrap. Never includes the password
|
||||||
@@ -30,6 +36,8 @@ pub struct AdminUserRow {
|
|||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
@@ -44,6 +52,7 @@ pub struct AdminUserCredentials {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -58,10 +67,16 @@ pub trait AdminUserRepository: Send + Sync {
|
|||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||||
|
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||||
|
/// explicit role. `email` is optional — pass `None` to leave the
|
||||||
|
/// column NULL.
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
async fn update_username(
|
async fn update_username(
|
||||||
&self,
|
&self,
|
||||||
@@ -73,6 +88,20 @@ pub trait AdminUserRepository: Send + Sync {
|
|||||||
id: AdminUserId,
|
id: AdminUserId,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
/// Set or clear the email address. `None` writes NULL to the column.
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||||
|
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||||
|
/// before invoking when role transitions away from `Owner`.
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
async fn set_active(
|
async fn set_active(
|
||||||
&self,
|
&self,
|
||||||
id: AdminUserId,
|
id: AdminUserId,
|
||||||
@@ -90,6 +119,15 @@ pub trait AdminUserRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
id: AdminUserId,
|
id: AdminUserId,
|
||||||
) -> Result<i64, AdminUserRepositoryError>;
|
) -> Result<i64, AdminUserRepositoryError>;
|
||||||
|
/// All active owners — used for the multi-owner startup warning.
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
|
/// Count of active owners excluding the given id. Used by the
|
||||||
|
/// last-owner guard when demoting / deactivating / deleting an
|
||||||
|
/// owner: "would this leave zero owners?"
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresAdminUserRepository {
|
pub struct PostgresAdminUserRepository {
|
||||||
@@ -107,13 +145,14 @@ impl PostgresAdminUserRepository {
|
|||||||
impl AdminUserRepository for PostgresAdminUserRepository {
|
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||||
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
FROM admin_users WHERE id = $1",
|
FROM admin_users WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(Into::into))
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_by_username(
|
async fn get_by_username(
|
||||||
@@ -121,13 +160,14 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
FROM admin_users WHERE username = $1",
|
FROM admin_users WHERE username = $1",
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(Into::into))
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_credentials_by_username(
|
async fn get_credentials_by_username(
|
||||||
@@ -135,45 +175,62 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||||
"SELECT id, username, password_hash, is_active \
|
"SELECT id, username, password_hash, is_active, instance_role \
|
||||||
FROM admin_users WHERE username = $1",
|
FROM admin_users WHERE username = $1",
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(Into::into))
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
FROM admin_users ORDER BY username",
|
FROM admin_users ORDER BY username",
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"INSERT INTO admin_users (username, password_hash) \
|
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
|
||||||
VALUES ($1, $2) \
|
VALUES ($1, $2, $3, $4) \
|
||||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
|
.bind(instance_role.as_str())
|
||||||
|
.bind(email)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(row) => Ok(row.into()),
|
Ok(row) => row.try_into(),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
// username and email both have unique constraints; the
|
||||||
),
|
// create path can collide on either, so peek at the
|
||||||
|
// constraint name to surface the right error.
|
||||||
|
if e.constraint() == Some("admin_users_email_key") {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateEmail(
|
||||||
|
email.unwrap_or("").to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateUsername(
|
||||||
|
username.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +243,8 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(username)
|
.bind(username)
|
||||||
@@ -194,7 +252,7 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(Some(row)) => Ok(row.into()),
|
Ok(Some(row)) => row.try_into(),
|
||||||
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||||
@@ -211,14 +269,60 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
row.map(Into::into)
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
.and_then(TryInto::try_into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET email = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => row.try_into(),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(instance_role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
|
.and_then(TryInto::try_into)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_active(
|
async fn set_active(
|
||||||
@@ -229,14 +333,15 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(is_active)
|
.bind(is_active)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
row.map(Into::into)
|
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
.and_then(TryInto::try_into)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||||
@@ -277,6 +382,33 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"SELECT id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at \
|
||||||
|
FROM admin_users \
|
||||||
|
WHERE is_active AND instance_role = 'owner' \
|
||||||
|
ORDER BY username",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
let (count,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*)::BIGINT FROM admin_users \
|
||||||
|
WHERE is_active AND instance_role = 'owner' AND id <> $1",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -284,21 +416,28 @@ struct AdminUserRecord {
|
|||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
username: String,
|
username: String,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
|
instance_role: String,
|
||||||
|
email: Option<String>,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
updated_at: DateTime<Utc>,
|
updated_at: DateTime<Utc>,
|
||||||
last_login_at: Option<DateTime<Utc>>,
|
last_login_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AdminUserRecord> for AdminUserRow {
|
impl TryFrom<AdminUserRecord> for AdminUserRow {
|
||||||
fn from(r: AdminUserRecord) -> Self {
|
type Error = AdminUserRepositoryError;
|
||||||
Self {
|
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
username: r.username,
|
username: r.username,
|
||||||
is_active: r.is_active,
|
is_active: r.is_active,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
|
email: r.email,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
last_login_at: r.last_login_at,
|
last_login_at: r.last_login_at,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,15 +447,20 @@ struct AdminCredsRecord {
|
|||||||
username: String,
|
username: String,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
|
instance_role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AdminCredsRecord> for AdminUserCredentials {
|
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
||||||
fn from(r: AdminCredsRecord) -> Self {
|
type Error = AdminUserRepositoryError;
|
||||||
Self {
|
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
username: r.username,
|
username: r.username,
|
||||||
password_hash: r.password_hash,
|
password_hash: r.password_hash,
|
||||||
is_active: r.is_active,
|
is_active: r.is_active,
|
||||||
}
|
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||||
|
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||||
|
)?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ use axum::extract::{Path, State};
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::{Extension, Router};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::admin_session_repo::AdminSessionRepository;
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::api_key_repo::ApiKeyRepository;
|
||||||
use crate::auth::hash_password;
|
use crate::auth::hash_password;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
|
||||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||||
/// a strict ASCII subset so the lookup column stays predictable, and
|
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||||
@@ -36,6 +38,13 @@ const PASSWORD_MIN: usize = 8;
|
|||||||
pub struct AdminsState {
|
pub struct AdminsState {
|
||||||
pub users: Arc<dyn AdminUserRepository>,
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
|
||||||
|
/// also expires every active API key for that user so cookie and
|
||||||
|
/// bearer credentials become inert at the same moment.
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
|
/// Capability gate: every endpoint here requires
|
||||||
|
/// `InstanceManageUsers` (owner / admin).
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn admins_router(state: AdminsState) -> Router {
|
pub fn admins_router(state: AdminsState) -> Router {
|
||||||
@@ -57,6 +66,8 @@ pub struct AdminDto {
|
|||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
pub last_login_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
@@ -67,6 +78,8 @@ impl From<AdminUserRow> for AdminDto {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
username: r.username,
|
username: r.username,
|
||||||
is_active: r.is_active,
|
is_active: r.is_active,
|
||||||
|
instance_role: r.instance_role,
|
||||||
|
email: r.email,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
last_login_at: r.last_login_at,
|
last_login_at: r.last_login_at,
|
||||||
}
|
}
|
||||||
@@ -77,6 +90,18 @@ impl From<AdminUserRow> for AdminDto {
|
|||||||
pub struct CreateAdminRequest {
|
pub struct CreateAdminRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
/// Defaults to `Admin` when absent — minting an owner via the API
|
||||||
|
/// is a deliberate step. The env-var bootstrap path is the only
|
||||||
|
/// channel that defaults to `Owner`.
|
||||||
|
#[serde(default = "default_create_role")]
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
/// Optional contact email. Blank/whitespace is normalized to None.
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_create_role() -> InstanceRole {
|
||||||
|
InstanceRole::Admin
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
@@ -84,6 +109,27 @@ pub struct PatchAdminRequest {
|
|||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
|
pub instance_role: Option<InstanceRole>,
|
||||||
|
/// JSON Merge Patch (RFC 7396) semantics for email:
|
||||||
|
/// absent → don't change
|
||||||
|
/// null → clear (set DB column to NULL)
|
||||||
|
/// "<string>" → set to that string
|
||||||
|
/// `Option<Option<T>>` is the idiomatic Rust shape for that
|
||||||
|
/// tri-state; the custom deserializer below distinguishes the
|
||||||
|
/// "missing" case from the "present-and-null" case that serde
|
||||||
|
/// would otherwise collapse together.
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
#[serde(default, deserialize_with = "deserialize_present_optional")]
|
||||||
|
pub email: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::<T>::deserialize(deserializer)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -92,15 +138,29 @@ pub struct PatchAdminRequest {
|
|||||||
|
|
||||||
async fn list_admins(
|
async fn list_admins(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let rows = state.users.list().await?;
|
let rows = state.users.list().await?;
|
||||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_admin(
|
async fn get_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<AdminUserId>,
|
Path(id): Path<AdminUserId>,
|
||||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state
|
state
|
||||||
.users
|
.users
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -112,24 +172,50 @@ async fn get_admin(
|
|||||||
|
|
||||||
async fn create_admin(
|
async fn create_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateAdminRequest>,
|
Json(input): Json<CreateAdminRequest>,
|
||||||
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Minting an owner via the API requires the caller to ALSO be an
|
||||||
|
// owner — admin cannot self-elevate (or elevate someone else)
|
||||||
|
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||||
|
// bypasses this path.
|
||||||
|
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
|
||||||
|
{
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
let username = input.username.trim();
|
let username = input.username.trim();
|
||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
validate_password(&input.password)?;
|
||||||
|
let email = normalize_email(input.email.as_deref())?;
|
||||||
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
let row = state.users.create(username, &hash).await?;
|
let row = state
|
||||||
|
.users
|
||||||
|
.create(username, &hash, input.instance_role, email.as_deref())
|
||||||
|
.await?;
|
||||||
Ok((StatusCode::CREATED, Json(row.into())))
|
Ok((StatusCode::CREATED, Json(row.into())))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn patch_admin(
|
async fn patch_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<AdminUserId>,
|
Path(id): Path<AdminUserId>,
|
||||||
Json(input): Json<PatchAdminRequest>,
|
Json(input): Json<PatchAdminRequest>,
|
||||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Verify the target exists upfront — keeps the error path uniform
|
// Verify the target exists upfront — keeps the error path uniform
|
||||||
// for "rename a missing user" etc.
|
// for "rename a missing user" etc.
|
||||||
let _ = state
|
let current = state
|
||||||
.users
|
.users
|
||||||
.get(id)
|
.get(id)
|
||||||
.await?
|
.await?
|
||||||
@@ -154,6 +240,32 @@ async fn patch_admin(
|
|||||||
// for the initial cut.)
|
// for the initial cut.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(email_patch) = input.email.as_ref() {
|
||||||
|
// email_patch is Some(None) → clear, Some(Some(s)) → set.
|
||||||
|
let normalized = normalize_email(email_patch.as_deref())?;
|
||||||
|
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_role) = input.instance_role {
|
||||||
|
// Self-elevation guard: only an owner can promote anyone TO
|
||||||
|
// owner. An admin cannot turn themselves (or anyone else)
|
||||||
|
// into one.
|
||||||
|
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||||
|
return Err(AdminApiError::CannotEscalate);
|
||||||
|
}
|
||||||
|
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||||
|
// leave the install with zero owners. The check is on the
|
||||||
|
// source role (current.instance_role) so demoting an
|
||||||
|
// already-non-owner is always fine.
|
||||||
|
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
|
||||||
|
let remaining = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest = Some(state.users.update_instance_role(id, new_role).await?);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(new_active) = input.is_active {
|
if let Some(new_active) = input.is_active {
|
||||||
// Last-active-admin guard: only when transitioning to inactive.
|
// Last-active-admin guard: only when transitioning to inactive.
|
||||||
if !new_active {
|
if !new_active {
|
||||||
@@ -161,14 +273,40 @@ async fn patch_admin(
|
|||||||
if remaining == 0 {
|
if remaining == 0 {
|
||||||
return Err(AdminApiError::LastActiveAdmin);
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
}
|
}
|
||||||
|
// ALSO: if the target is currently the last active owner,
|
||||||
|
// deactivating them leaves no owner. Belt-and-suspenders to
|
||||||
|
// the role guard above (which only triggers on an explicit
|
||||||
|
// role transition).
|
||||||
|
let target_role = latest
|
||||||
|
.as_ref()
|
||||||
|
.map_or(current.instance_role, |r| r.instance_role);
|
||||||
|
if target_role == InstanceRole::Owner {
|
||||||
|
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining_owners == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
latest = Some(state.users.set_active(id, new_active).await?);
|
latest = Some(state.users.set_active(id, new_active).await?);
|
||||||
// Deactivation invalidates all of the user's sessions. Cheap
|
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||||
// and safer than waiting for sliding-window expiry.
|
// (cookie / session bearer) and API keys. Both writes are
|
||||||
|
// logged on failure but do not undo the deactivation; the
|
||||||
|
// alternative (leaving the user active when one cascade fails)
|
||||||
|
// is worse than slightly stale credential rows on a DB blip.
|
||||||
if !new_active {
|
if !new_active {
|
||||||
if let Err(err) = state.sessions.delete_for_user(id).await {
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||||
}
|
}
|
||||||
|
match state.keys.expire_all_for_user(id).await {
|
||||||
|
Ok(n) => {
|
||||||
|
if n > 0 {
|
||||||
|
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +323,15 @@ async fn patch_admin(
|
|||||||
|
|
||||||
async fn delete_admin(
|
async fn delete_admin(
|
||||||
State(state): State<AdminsState>,
|
State(state): State<AdminsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<AdminUserId>,
|
Path(id): Path<AdminUserId>,
|
||||||
) -> Result<StatusCode, AdminApiError> {
|
) -> Result<StatusCode, AdminApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let target = state
|
let target = state
|
||||||
.users
|
.users
|
||||||
.get(id)
|
.get(id)
|
||||||
@@ -197,9 +342,18 @@ async fn delete_admin(
|
|||||||
if remaining == 0 {
|
if remaining == 0 {
|
||||||
return Err(AdminApiError::LastActiveAdmin);
|
return Err(AdminApiError::LastActiveAdmin);
|
||||||
}
|
}
|
||||||
|
// Last-owner guard mirrors the role-transition guard in
|
||||||
|
// patch_admin — deleting the only owner is just as bad as
|
||||||
|
// demoting them.
|
||||||
|
if target.instance_role == InstanceRole::Owner {
|
||||||
|
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||||
|
if remaining_owners == 0 {
|
||||||
|
return Err(AdminApiError::LastActiveOwner);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state.users.delete(id).await?;
|
state.users.delete(id).await?;
|
||||||
// Sessions cascade via FK; no explicit delete needed.
|
// Sessions + api_keys cascade via FK; no explicit delete needed.
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +388,26 @@ fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trim and reject empty / pathological emails, returning the
|
||||||
|
/// canonical form (or None when the input was blank). The shape
|
||||||
|
/// check is intentionally loose — we mainly want to reject blanks
|
||||||
|
/// and obvious junk; real verification is a future concern.
|
||||||
|
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if trimmed.len() > 254 || !trimmed.contains('@') {
|
||||||
|
return Err(AdminApiError::InvalidEmail(
|
||||||
|
"email must contain '@' and be at most 254 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -249,9 +423,24 @@ pub enum AdminApiError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
InvalidPassword(String),
|
InvalidPassword(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidEmail(String),
|
||||||
|
|
||||||
#[error("cannot leave the system with zero active admins")]
|
#[error("cannot leave the system with zero active admins")]
|
||||||
LastActiveAdmin,
|
LastActiveAdmin,
|
||||||
|
|
||||||
|
#[error("cannot leave the system with zero active owners")]
|
||||||
|
LastActiveOwner,
|
||||||
|
|
||||||
|
#[error("only an owner can grant the owner role")]
|
||||||
|
CannotEscalate,
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("failed to hash password: {0}")]
|
#[error("failed to hash password: {0}")]
|
||||||
Hash(String),
|
Hash(String),
|
||||||
|
|
||||||
@@ -259,16 +448,40 @@ pub enum AdminApiError {
|
|||||||
Repo(#[from] AdminUserRepositoryError),
|
Repo(#[from] AdminUserRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AdminApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AdminApiError {
|
impl IntoResponse for AdminApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
|
Self::Repo(
|
||||||
(StatusCode::CONFLICT, self.to_string())
|
AdminUserRepositoryError::DuplicateUsername(_)
|
||||||
}
|
| AdminUserRepositoryError::DuplicateEmail(_),
|
||||||
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
|
) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
|
Self::InvalidUsername(_)
|
||||||
|
| Self::InvalidPassword(_)
|
||||||
|
| Self::InvalidEmail(_)
|
||||||
|
| Self::LastActiveAdmin
|
||||||
|
| Self::LastActiveOwner
|
||||||
|
| Self::CannotEscalate
|
||||||
|
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "admin_users authz error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||||
|
ValidationError,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
@@ -31,6 +33,10 @@ pub struct AdminState<R, L> {
|
|||||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||||
pub apps: Arc<dyn AppRepository>,
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
/// Phase 3.5 capability checks — every script handler resolves
|
||||||
|
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||||
|
/// loading the resource.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
pub sandbox_ceiling: SandboxCeiling,
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
@@ -41,6 +47,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.clone(),
|
||||||
apps: self.apps.clone(),
|
apps: self.apps.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
sandbox_ceiling: self.sandbox_ceiling,
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
@@ -129,14 +136,22 @@ where
|
|||||||
|
|
||||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Query(q): Query<ListScriptsQuery>,
|
Query(q): Query<ListScriptsQuery>,
|
||||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||||
|
// Membership filter: `member` users see only scripts in apps they
|
||||||
|
// belong to. `?app=` filters further by app and additionally
|
||||||
|
// requires the member to belong to that app (the read check uses
|
||||||
|
// the resource's app_id).
|
||||||
if let Some(ident) = q.app {
|
if let Some(ident) = q.app {
|
||||||
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||||
Ok(Json(state.repo.list_for_app(app).await?))
|
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||||
} else {
|
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||||
Ok(Json(state.repo.list().await?))
|
|
||||||
}
|
}
|
||||||
|
if principal.instance_role == InstanceRole::Member {
|
||||||
|
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||||
|
}
|
||||||
|
Ok(Json(state.repo.list().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||||
@@ -159,20 +174,34 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
|||||||
|
|
||||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
state
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
.repo
|
require(
|
||||||
.get(id)
|
state.authz.as_ref(),
|
||||||
.await?
|
&principal,
|
||||||
.map(Json)
|
Capability::AppRead(script.app_id),
|
||||||
.ok_or(ApiError::NotFound(id))
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(script))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateScriptRequest>,
|
Json(input): Json<CreateScriptRequest>,
|
||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
|
// Capability is bound to the *requested* app_id since there's no
|
||||||
|
// resource to load yet. If the app doesn't exist we 422 below;
|
||||||
|
// checking authz first means a Member trying to create against an
|
||||||
|
// unknown app gets 403 (no enumeration of app existence).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.validator.validate(&input.source)?;
|
state.validator.validate(&input.source)?;
|
||||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||||
@@ -201,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
Json(input): Json<UpdateScriptRequest>,
|
Json(input): Json<UpdateScriptRequest>,
|
||||||
) -> Result<Json<Script>, ApiError> {
|
) -> Result<Json<Script>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if let Some(src) = input.source.as_deref() {
|
if let Some(src) = input.source.as_deref() {
|
||||||
state.validator.validate(src)?;
|
state.validator.validate(src)?;
|
||||||
}
|
}
|
||||||
@@ -229,8 +266,16 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
|
|
||||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteScript(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -249,9 +294,17 @@ const fn default_limit() -> i64 {
|
|||||||
|
|
||||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||||
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppLogRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Cap to keep the dashboard responsive; the data plane writes are
|
// Cap to keep the dashboard responsive; the data plane writes are
|
||||||
// unbounded over time so a paged read is the only sane default.
|
// unbounded over time so a paged read is the only sane default.
|
||||||
let limit = q.limit.clamp(1, 200);
|
let limit = q.limit.clamp(1, 200);
|
||||||
@@ -281,10 +334,25 @@ pub enum ApiError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Ceiling(#[from] CeilingError),
|
Ceiling(#[from] CeilingError),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for ApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
@@ -294,6 +362,14 @@ impl IntoResponse for ApiError {
|
|||||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
//! that writes the history row in the same transaction.
|
//! that writes the history row in the same transaction.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{App, AppId};
|
use picloud_shared::{AdminUserId, App, AppId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
@@ -22,7 +22,12 @@ pub struct AppLookup {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AppRepository: Send + Sync {
|
pub trait AppRepository: Send + Sync {
|
||||||
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
/// users go through `list_for_user`.
|
||||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
|
/// Only apps the user has an `app_members` row for. Drives the
|
||||||
|
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||||
async fn get_by_slug_or_history(
|
async fn get_by_slug_or_history(
|
||||||
@@ -92,6 +97,20 @@ impl AppRepository for PostgresAppRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppRow>(
|
||||||
|
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||||
|
FROM apps a \
|
||||||
|
JOIN app_members m ON m.app_id = a.id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, AppRow>(
|
let row = sqlx::query_as::<_, AppRow>(
|
||||||
"SELECT id, slug, name, description, created_at, updated_at \
|
"SELECT id, slug, name, description, created_at, updated_at \
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ use axum::extract::{Path, Query, State};
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::Router;
|
use axum::{Extension, Router};
|
||||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
use picloud_shared::{App, AppDomain, AppId};
|
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
use crate::route_repo::RouteRepository;
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ pub struct AppsState {
|
|||||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||||
/// operation so the orchestrator sees changes immediately.
|
/// operation so the orchestrator sees changes immediately.
|
||||||
pub domain_table: Arc<AppDomainTable>,
|
pub domain_table: Arc<AppDomainTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apps_router(state: AppsState) -> Router {
|
pub fn apps_router(state: AppsState) -> Router {
|
||||||
@@ -144,14 +147,27 @@ pub struct AppLookupResponse {
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
async fn list_apps(
|
||||||
Ok(Json(s.apps.list().await?))
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||||
|
// Member callers see only apps they're a member of; owner/admin
|
||||||
|
// see everything. Filter at the SQL layer (not just in the
|
||||||
|
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||||
|
let apps = if principal.instance_role == InstanceRole::Member {
|
||||||
|
s.apps.list_for_user(principal.user_id).await?
|
||||||
|
} else {
|
||||||
|
s.apps.list().await?
|
||||||
|
};
|
||||||
|
Ok(Json(apps))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_app(
|
async fn create_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CreateAppRequest>,
|
Json(input): Json<CreateAppRequest>,
|
||||||
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||||
validate_slug(&input.slug)?;
|
validate_slug(&input.slug)?;
|
||||||
|
|
||||||
// Historical-slug check before insert: if the slug is in history
|
// Historical-slug check before insert: if the slug is in history
|
||||||
@@ -178,9 +194,16 @@ async fn create_app(
|
|||||||
|
|
||||||
async fn get_app(
|
async fn get_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||||
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(lookup.app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let redirect_to = if lookup.redirected {
|
let redirect_to = if lookup.redirected {
|
||||||
Some(lookup.app.slug.clone())
|
Some(lookup.app.slug.clone())
|
||||||
} else {
|
} else {
|
||||||
@@ -194,10 +217,17 @@ async fn get_app(
|
|||||||
|
|
||||||
async fn patch_app(
|
async fn patch_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
Json(input): Json<PatchAppRequest>,
|
Json(input): Json<PatchAppRequest>,
|
||||||
) -> Result<Json<App>, AppsApiError> {
|
) -> Result<Json<App>, AppsApiError> {
|
||||||
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppAdmin(current.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Edits to name/description go first (separate from rename so we
|
// Edits to name/description go first (separate from rename so we
|
||||||
// don't conflate the two errors).
|
// don't conflate the two errors).
|
||||||
@@ -240,10 +270,12 @@ async fn patch_app(
|
|||||||
|
|
||||||
async fn delete_app(
|
async fn delete_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
Query(q): Query<DeleteAppQuery>,
|
Query(q): Query<DeleteAppQuery>,
|
||||||
) -> Result<StatusCode, AppsApiError> {
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
if q.force {
|
if q.force {
|
||||||
s.apps.delete_cascade(app.id).await?;
|
s.apps.delete_cascade(app.id).await?;
|
||||||
@@ -262,9 +294,12 @@ async fn delete_app(
|
|||||||
|
|
||||||
async fn slug_check(
|
async fn slug_check(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
Path(_id_or_slug): Path<String>,
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
Json(input): Json<SlugCheckRequest>,
|
Json(input): Json<SlugCheckRequest>,
|
||||||
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
match validate_slug(&input.new_slug) {
|
match validate_slug(&input.new_slug) {
|
||||||
Err(AppsApiError::InvalidSlug(reason)) => {
|
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||||
return Ok(Json(SlugCheckResponse {
|
return Ok(Json(SlugCheckResponse {
|
||||||
@@ -303,18 +338,27 @@ async fn slug_check(
|
|||||||
|
|
||||||
async fn list_domains(
|
async fn list_domains(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||||
Ok(Json(s.domains.list_for_app(app.id).await?))
|
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_domain(
|
async fn create_domain(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(id_or_slug): Path<String>,
|
Path(id_or_slug): Path<String>,
|
||||||
Json(input): Json<CreateDomainRequest>,
|
Json(input): Json<CreateDomainRequest>,
|
||||||
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||||
let created = s
|
let created = s
|
||||||
.domains
|
.domains
|
||||||
@@ -331,9 +375,16 @@ async fn create_domain(
|
|||||||
|
|
||||||
async fn delete_domain(
|
async fn delete_domain(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||||
) -> Result<StatusCode, AppsApiError> {
|
) -> Result<StatusCode, AppsApiError> {
|
||||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||||
|
require(
|
||||||
|
s.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppManageDomains(app.id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let Some(domain) = s.domains.get(domain_id).await? else {
|
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||||
};
|
};
|
||||||
@@ -476,10 +527,25 @@ pub enum AppsApiError {
|
|||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppsApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppsApiError {
|
impl IntoResponse for AppsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
@@ -511,6 +577,14 @@ impl IntoResponse for AppsApiError {
|
|||||||
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
}
|
}
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "apps authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||||
tracing::error!(error = %e, "apps api db error");
|
tracing::error!(error = %e, "apps api db error");
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt
|
|||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
use data_encoding::BASE32_NOPAD;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// API key generation (Phase 3.5)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
|
||||||
|
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
|
||||||
|
/// generator and the verifier agree.
|
||||||
|
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
|
||||||
|
|
||||||
|
/// Length of the indexed prefix portion (the first 8 chars of the
|
||||||
|
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
|
||||||
|
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
|
||||||
|
///
|
||||||
|
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
|
||||||
|
/// caller in the response body and never persisted.
|
||||||
|
/// * `prefix` is the indexed 8-char slice persisted to
|
||||||
|
/// `api_keys.prefix` for lookup.
|
||||||
|
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
|
||||||
|
/// covers the body after `pic_` (i.e., `raw[4..]`).
|
||||||
|
pub struct GeneratedApiKey {
|
||||||
|
pub raw: String,
|
||||||
|
pub prefix: String,
|
||||||
|
pub hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
|
||||||
|
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
|
||||||
|
/// key; everything after `pic_` is what the verifier hashes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
|
||||||
|
/// fails (which it shouldn't under normal conditions).
|
||||||
|
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let body = BASE32_NOPAD.encode(&bytes);
|
||||||
|
debug_assert!(
|
||||||
|
body.len() >= API_KEY_INDEX_PREFIX_LEN,
|
||||||
|
"32 bytes base32 must exceed the 8-char prefix length"
|
||||||
|
);
|
||||||
|
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
|
||||||
|
let salt = SaltString::generate(&mut ArgonRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(body.as_bytes(), &salt)?
|
||||||
|
.to_string();
|
||||||
|
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
|
||||||
|
Ok(GeneratedApiKey { raw, prefix, hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a wire-format token body (the portion *after* `pic_`)
|
||||||
|
/// against a stored Argon2id hash. Convenience wrapper around
|
||||||
|
/// `verify_password` named to reflect its caller.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
|
||||||
|
verify_password(stored_hash, presented_body)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -129,4 +190,42 @@ mod tests {
|
|||||||
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||||
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_api_key_round_trip() {
|
||||||
|
let key = generate_api_key().expect("mint");
|
||||||
|
assert!(
|
||||||
|
key.raw.starts_with(API_KEY_WIRE_PREFIX),
|
||||||
|
"raw must carry the pic_ prefix"
|
||||||
|
);
|
||||||
|
let body = key
|
||||||
|
.raw
|
||||||
|
.strip_prefix(API_KEY_WIRE_PREFIX)
|
||||||
|
.expect("starts with prefix");
|
||||||
|
assert_eq!(
|
||||||
|
&body[..API_KEY_INDEX_PREFIX_LEN],
|
||||||
|
key.prefix,
|
||||||
|
"stored prefix matches the first 8 chars of the body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_api_key(&key.hash, body),
|
||||||
|
"Argon2 verify must accept the original body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!verify_api_key(&key.hash, "wrong-body-entirely"),
|
||||||
|
"Argon2 verify must reject anything else"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_api_key_unique() {
|
||||||
|
let a = generate_api_key().expect("mint a");
|
||||||
|
let b = generate_api_key().expect("mint b");
|
||||||
|
assert_ne!(a.raw, b.raw);
|
||||||
|
assert_ne!(a.hash, b.hash);
|
||||||
|
assert_ne!(
|
||||||
|
a.prefix, b.prefix,
|
||||||
|
"32 random bytes → prefix collision is negligible"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ use axum::response::{IntoResponse, Json, Response};
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use picloud_shared::Principal;
|
||||||
|
|
||||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||||
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
|
||||||
|
|
||||||
pub fn auth_router(state: AuthState) -> Router {
|
pub fn auth_router(state: AuthState) -> Router {
|
||||||
// /login + /logout are unguarded (login is how you get in; logout
|
// /login + /logout are unguarded (login is how you get in; logout
|
||||||
@@ -31,7 +33,7 @@ pub fn auth_router(state: AuthState) -> Router {
|
|||||||
// who you are, so the middleware must run first.
|
// who you are, so the middleware must run first.
|
||||||
let guarded = Router::new()
|
let guarded = Router::new()
|
||||||
.route("/auth/me", get(me))
|
.route("/auth/me", get(me))
|
||||||
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
.route_layer(from_fn_with_state(state.clone(), require_authenticated));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/auth/login", post(login))
|
.route("/auth/login", post(login))
|
||||||
@@ -61,6 +63,8 @@ pub struct LoginResponse {
|
|||||||
pub struct AdminUserDto {
|
pub struct AdminUserDto {
|
||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -85,9 +89,11 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (stored_hash, user_id, username, is_active) = match creds {
|
// username from creds is discarded — the re-fetch below carries the
|
||||||
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
// canonical row used in the response DTO.
|
||||||
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
let (stored_hash, user_id, is_active) = match creds {
|
||||||
|
Some(c) => (c.password_hash, Some(c.id), c.is_active),
|
||||||
|
None => (DUMMY_HASH.to_string(), None, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
let password_ok = verify_password(&stored_hash, &input.password);
|
let password_ok = verify_password(&stored_hash, &input.password);
|
||||||
@@ -96,6 +102,18 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
}
|
}
|
||||||
let user_id = user_id.unwrap();
|
let user_id = user_id.unwrap();
|
||||||
|
|
||||||
|
// Re-fetch the full row so the login response carries the same
|
||||||
|
// shape /me does (instance_role, email). The credentials struct
|
||||||
|
// intentionally omits email; one extra query per login is fine.
|
||||||
|
let user_row = match state.users.get(user_id).await {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => return invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup after login failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let token = generate_session_token();
|
let token = generate_session_token();
|
||||||
let expires_at = Utc::now()
|
let expires_at = Utc::now()
|
||||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
@@ -128,8 +146,10 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
headers,
|
headers,
|
||||||
Json(LoginResponse {
|
Json(LoginResponse {
|
||||||
user: AdminUserDto {
|
user: AdminUserDto {
|
||||||
id: user_id,
|
id: user_row.id,
|
||||||
username,
|
username: user_row.username,
|
||||||
|
instance_role: user_row.instance_role,
|
||||||
|
email: user_row.email,
|
||||||
},
|
},
|
||||||
token: token.raw,
|
token: token.raw,
|
||||||
expires_at,
|
expires_at,
|
||||||
@@ -158,11 +178,27 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
|
|||||||
(StatusCode::NO_CONTENT, headers).into_response()
|
(StatusCode::NO_CONTENT, headers).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
async fn me(
|
||||||
Json(AdminUserDto {
|
State(state): State<AuthState>,
|
||||||
id: admin.id,
|
Extension(principal): Extension<Principal>,
|
||||||
username: admin.username,
|
) -> Response {
|
||||||
})
|
// /me consumes the resolved Principal directly; we re-fetch the
|
||||||
|
// user row only to surface a fresh username (it can change via
|
||||||
|
// PATCH while a session/key is still valid).
|
||||||
|
match state.users.get(principal.user_id).await {
|
||||||
|
Ok(Some(row)) => Json(AdminUserDto {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
instance_role: row.instance_role,
|
||||||
|
email: row.email,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Ok(None) => invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||||
|
internal_error()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -116,7 +116,16 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
|||||||
(None, None) => return Err(BootstrapError::MissingPassword),
|
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||||
};
|
};
|
||||||
|
|
||||||
repo.create(&username, &password_hash).await?;
|
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
|
||||||
|
// first row to full instance control. Subsequent admins minted via
|
||||||
|
// the API default to Admin and can be promoted explicitly.
|
||||||
|
repo.create(
|
||||||
|
&username,
|
||||||
|
&password_hash,
|
||||||
|
picloud_shared::InstanceRole::Owner,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
info!(username = %username, "bootstrapped initial admin user");
|
info!(username = %username, "bootstrapped initial admin user");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -130,7 +139,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||||
@@ -167,11 +176,15 @@ mod tests {
|
|||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
_password_hash: &str,
|
_password_hash: &str,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
let row = AdminUserRow {
|
let row = AdminUserRow {
|
||||||
id: AdminUserId::new(),
|
id: AdminUserId::new(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
instance_role,
|
||||||
|
email: email.map(str::to_string),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
last_login_at: None,
|
last_login_at: None,
|
||||||
@@ -193,6 +206,20 @@ mod tests {
|
|||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_e: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn update_instance_role(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_r: InstanceRole,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn set_active(
|
async fn set_active(
|
||||||
&self,
|
&self,
|
||||||
_i: AdminUserId,
|
_i: AdminUserId,
|
||||||
@@ -215,6 +242,15 @@ mod tests {
|
|||||||
) -> Result<i64, AdminUserRepositoryError> {
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
async fn count_other_active_owners(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
) -> Result<i64, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -245,7 +281,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn populated_db_is_noop() {
|
async fn populated_db_is_noop() {
|
||||||
let repo = InMemoryRepo::default();
|
let repo = InMemoryRepo::default();
|
||||||
repo.create("seeded", "x").await.unwrap();
|
repo.create("seeded", "x", InstanceRole::Owner, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let env = BootstrapEnv {
|
let env = BootstrapEnv {
|
||||||
username: Some("alice".into()),
|
username: Some("alice".into()),
|
||||||
password: Some("supersecret".into()),
|
password: Some("supersecret".into()),
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
//! `require_admin` axum middleware: gates a router on a valid admin
|
//! Authentication middleware — resolves the caller's `Principal` from
|
||||||
//! session. Accepts the token from either the `picloud_session` cookie
|
//! either a session cookie / Bearer session-token OR an API key
|
||||||
//! or an `Authorization: Bearer …` header — same token system serves
|
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
||||||
//! the dashboard and CLI/CI clients.
|
//! request extension so downstream handlers see one shape.
|
||||||
//!
|
//!
|
||||||
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
//! Capability checks live in `crate::authz` and are called per-handler
|
||||||
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
//! (after the relevant resource is loaded, so the capability binds to
|
||||||
//! returns 401 with a generic JSON body (no enumeration about whether
|
//! the actual resource's `app_id`). This middleware is gate-only: it
|
||||||
//! the token was wrong vs. the user was deactivated).
|
//! ensures *some* `Principal` is attached, or returns 401.
|
||||||
|
//!
|
||||||
|
//! Token discriminator: the `pic_` prefix on a Bearer value selects
|
||||||
|
//! the API-key path; anything else (raw 32-byte base64-url-encoded
|
||||||
|
//! string) takes the session path. The session cookie can only ever
|
||||||
|
//! carry a session token (cookies are never API keys).
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
|
|||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Json, Response};
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, Principal};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::admin_session_repo::AdminSessionRepository;
|
use crate::admin_session_repo::AdminSessionRepository;
|
||||||
use crate::admin_user_repo::AdminUserRepository;
|
use crate::admin_user_repo::AdminUserRepository;
|
||||||
use crate::auth::hash_token;
|
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
||||||
|
use crate::auth::{hash_token, verify_password};
|
||||||
|
|
||||||
pub const SESSION_COOKIE: &str = "picloud_session";
|
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||||
|
|
||||||
/// Shared state for auth: the two repos plus the configured sliding
|
/// Prefix on the wire that selects the API-key path. The body that
|
||||||
/// session TTL. Cheap to clone (`Arc` everywhere).
|
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
|
||||||
|
/// index into `api_keys.prefix` for verification.
|
||||||
|
pub const API_KEY_PREFIX: &str = "pic_";
|
||||||
|
|
||||||
|
/// Length of the indexed prefix portion of an API key (the 8 chars
|
||||||
|
/// immediately after `pic_`). Schema-side index is on this slice.
|
||||||
|
pub const API_KEY_PREFIX_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// Shared state for auth: the user / session / API-key repos plus the
|
||||||
|
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
pub users: Arc<dyn AdminUserRepository>,
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request-extension type that authenticated handlers extract via
|
/// Legacy request-extension alias retained so the (only remaining)
|
||||||
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
|
||||||
|
/// keeps compiling during the migration. New handlers should pull
|
||||||
|
/// `Extension<Principal>` directly.
|
||||||
|
#[deprecated(note = "use Extension<Principal> directly")]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthedAdmin {
|
pub struct AuthedAdmin {
|
||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware function. Wire with
|
/// Middleware entry point. Wire with
|
||||||
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
||||||
pub async fn require_admin(
|
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
|
||||||
|
/// extensions on success; returns 401 on any failure mode.
|
||||||
|
pub async fn require_authenticated(
|
||||||
State(state): State<AuthState>,
|
State(state): State<AuthState>,
|
||||||
mut req: Request<Body>,
|
mut req: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
@@ -53,48 +74,162 @@ pub async fn require_admin(
|
|||||||
let Some(token) = extract_token(&req) else {
|
let Some(token) = extract_token(&req) else {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
};
|
};
|
||||||
let token_hash = hash_token(&token);
|
let principal = match resolve_principal(&state, &token).await {
|
||||||
|
Ok(Some(p)) => p,
|
||||||
|
Ok(None) => return unauthorized(),
|
||||||
|
Err(InternalError) => return internal_error(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let username_for_legacy = username_for(&state, principal.user_id).await;
|
||||||
|
req.extensions_mut().insert(principal.clone());
|
||||||
|
#[allow(deprecated)]
|
||||||
|
if let Some(username) = username_for_legacy {
|
||||||
|
req.extensions_mut().insert(AuthedAdmin {
|
||||||
|
id: principal.user_id,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backwards-compatible alias — the single callsite that still names
|
||||||
|
/// `require_admin` keeps working without an immediate rename. New
|
||||||
|
/// wiring should call `require_authenticated`.
|
||||||
|
#[deprecated(note = "renamed to require_authenticated")]
|
||||||
|
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
||||||
|
require_authenticated(state, req, next).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||||
|
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||||
|
/// means the token was structurally valid but didn't match any active
|
||||||
|
/// credential; `Err(InternalError)` means a DB blip.
|
||||||
|
async fn resolve_principal(
|
||||||
|
state: &AuthState,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<Principal>, InternalError> {
|
||||||
|
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
|
||||||
|
return verify_api_key(state, rest).await;
|
||||||
|
}
|
||||||
|
verify_session(state, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_session(
|
||||||
|
state: &AuthState,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<Option<Principal>, InternalError> {
|
||||||
|
let token_hash = hash_token(token);
|
||||||
|
|
||||||
let lookup = match state.sessions.lookup(&token_hash).await {
|
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||||
Ok(Some(lookup)) => lookup,
|
Ok(Some(l)) => l,
|
||||||
Ok(None) => return unauthorized(),
|
Ok(None) => return Ok(None),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(?err, "admin_sessions lookup failed");
|
tracing::error!(?err, "admin_sessions lookup failed");
|
||||||
return internal_error();
|
return Err(InternalError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the user. A deleted user is impossible here (FK cascade
|
|
||||||
// wipes their sessions), but a deactivated user still needs to be
|
|
||||||
// rejected — and so does the edge case of a session predating the
|
|
||||||
// deactivate (we wipe their sessions on deactivate, but a race
|
|
||||||
// could land a request in flight).
|
|
||||||
let user = match state.users.get(lookup.user_id).await {
|
let user = match state.users.get(lookup.user_id).await {
|
||||||
Ok(Some(u)) if u.is_active => u,
|
Ok(Some(u)) if u.is_active => u,
|
||||||
Ok(_) => return unauthorized(),
|
Ok(_) => return Ok(None),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(?err, "admin_users lookup failed");
|
tracing::error!(?err, "admin_users lookup failed");
|
||||||
return internal_error();
|
return Err(InternalError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||||
// surfaces as a request error rather than silent stale sessions.
|
// than silent stale sessions. Same shape as Phase 3a.
|
||||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||||
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||||
tracing::error!(?err, "admin_sessions touch failed");
|
tracing::error!(?err, "admin_sessions touch failed");
|
||||||
return internal_error();
|
return Err(InternalError);
|
||||||
}
|
}
|
||||||
|
|
||||||
req.extensions_mut().insert(AuthedAdmin {
|
Ok(Some(Principal {
|
||||||
id: user.id,
|
user_id: user.id,
|
||||||
username: user.username,
|
instance_role: user.instance_role,
|
||||||
});
|
scopes: None,
|
||||||
next.run(req).await
|
app_binding: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API-key verification path. `rest` is the portion of the bearer
|
||||||
|
/// value *after* `pic_`. We slice off the first 8 chars as the
|
||||||
|
/// indexed lookup key, then Argon2id-verify each candidate's hash
|
||||||
|
/// against the full `rest`. At most one match is expected; multiple
|
||||||
|
/// candidates with the same prefix is statistically negligible but
|
||||||
|
/// handled correctly (verify each, take the first match).
|
||||||
|
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
||||||
|
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
||||||
|
|
||||||
|
let candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "api_keys lookup failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let matched: Option<ApiKeyVerification> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| verify_password(&c.hash, rest));
|
||||||
|
let Some(matched) = matched else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve the owning user. is_active = false → reject even if the
|
||||||
|
// key itself hasn't been expired yet (the expire_all_for_user
|
||||||
|
// cascade on deactivation is the primary defense; this is the
|
||||||
|
// belt-and-suspenders check at request time).
|
||||||
|
let user = match state.users.get(matched.user_id).await {
|
||||||
|
Ok(Some(u)) if u.is_active => u,
|
||||||
|
Ok(_) => return Ok(None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup for api key failed");
|
||||||
|
return Err(InternalError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = state.keys.touch_last_used(matched.id).await {
|
||||||
|
tracing::error!(?err, "api_keys touch_last_used failed");
|
||||||
|
// Soft-fail: a timestamp blip should not invalidate the
|
||||||
|
// request. Continue with the resolved Principal.
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Principal {
|
||||||
|
user_id: user.id,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
scopes: Some(matched.scopes),
|
||||||
|
app_binding: matched.app_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
|
||||||
|
/// Returns `None` on DB error (the caller treats `None` as "skip the
|
||||||
|
/// legacy extension"). New handlers use `Principal` and don't depend
|
||||||
|
/// on this.
|
||||||
|
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
||||||
|
match state.users.get(id).await {
|
||||||
|
Ok(Some(u)) => Some(u.username),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the bearer token out of an `Authorization` header (preferred)
|
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||||
/// or the `picloud_session` cookie (fallback for browser clients).
|
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||||
|
/// Same shape as Phase 3a; the cookie only ever carries session
|
||||||
|
/// tokens — no `pic_` prefix expected there.
|
||||||
fn extract_token(req: &Request<Body>) -> Option<String> {
|
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||||
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||||
if let Ok(s) = value.to_str() {
|
if let Ok(s) = value.to_str() {
|
||||||
@@ -121,6 +256,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sentinel returned from the resolve functions when a DB error should
|
||||||
|
/// produce a 500 rather than a 401. Empty struct because the actual
|
||||||
|
/// error is already logged at the failure site.
|
||||||
|
struct InternalError;
|
||||||
|
|
||||||
fn unauthorized() -> Response {
|
fn unauthorized() -> Response {
|
||||||
(
|
(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
@@ -141,6 +281,7 @@ fn internal_error() -> Response {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use axum::http::Request;
|
use axum::http::Request;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
@@ -155,6 +296,12 @@ mod tests {
|
|||||||
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_bearer_pic_prefixed_token() {
|
||||||
|
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
|
||||||
|
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_bearer_with_no_token() {
|
fn ignores_bearer_with_no_token() {
|
||||||
let r = req_with_header("authorization", "Bearer ");
|
let r = req_with_header("authorization", "Bearer ");
|
||||||
@@ -182,4 +329,20 @@ mod tests {
|
|||||||
let r = Request::builder().body(Body::empty()).unwrap();
|
let r = Request::builder().body(Body::empty()).unwrap();
|
||||||
assert_eq!(extract_token(&r), None);
|
assert_eq!(extract_token(&r), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Round-trip test for the unused-variable to keep `Principal`
|
||||||
|
// visibly tied to InstanceRole — caught a real bug during dev when
|
||||||
|
// the field order in the struct literal had drifted.
|
||||||
|
#[test]
|
||||||
|
fn principal_construction_is_explicit() {
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
};
|
||||||
|
assert_eq!(p.instance_role, InstanceRole::Owner);
|
||||||
|
assert!(p.scopes.is_none());
|
||||||
|
assert!(p.app_binding.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
599
crates/manager-core/src/authz.rs
Normal file
599
crates/manager-core/src/authz.rs
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
//! Capability-based authorization — see blueprint §11.6.
|
||||||
|
//!
|
||||||
|
//! Single entry point for every admin endpoint: `can(repo, principal,
|
||||||
|
//! capability)` returns whether the caller can perform the action.
|
||||||
|
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
|
||||||
|
//! after loading the resource so the capability binds to the resource's
|
||||||
|
//! actual `app_id`, not a path param the caller controls.
|
||||||
|
//!
|
||||||
|
//! Three layers of intersection, evaluated in order:
|
||||||
|
//!
|
||||||
|
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
|
||||||
|
//! `app_members` row authorize this capability?
|
||||||
|
//! 2. **Scope intersection** — if the principal came from an API key
|
||||||
|
//! (`principal.scopes.is_some()`), does the key's scope set cover
|
||||||
|
//! the capability's required scope?
|
||||||
|
//! 3. **App binding** — if the key was minted bound to a specific
|
||||||
|
//! app (`principal.app_binding`), does the capability target the
|
||||||
|
//! same app? (Instance-level capabilities are denied for bound
|
||||||
|
//! keys; the mint handler also rejects the combination upfront.)
|
||||||
|
//!
|
||||||
|
//! The capability set is intentionally finer-grained than the seven
|
||||||
|
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
|
||||||
|
//! the `script:write` / `route:write` scopes respectively). Keeping
|
||||||
|
//! capabilities precise lets a `script:write`-only key write scripts
|
||||||
|
//! without also being able to mutate routes. The scope set stays at
|
||||||
|
//! seven values — capabilities are the internal check, scopes are the
|
||||||
|
//! external user-facing label.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
|
|
||||||
|
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||||
|
/// the `AppId` of the resource the action targets — handlers compute
|
||||||
|
/// it from the loaded resource (e.g., `script.app_id`), not from a
|
||||||
|
/// path param.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Capability {
|
||||||
|
/// Create a new app. Owner / admin only.
|
||||||
|
InstanceCreateApp,
|
||||||
|
/// Create / update / delete admin_users rows (other than self
|
||||||
|
/// password change, which is a separate flow). Owner / admin.
|
||||||
|
InstanceManageUsers,
|
||||||
|
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
|
||||||
|
/// Owner only.
|
||||||
|
InstanceManageSettings,
|
||||||
|
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
|
||||||
|
/// (member); implicit for admin / owner.
|
||||||
|
AppRead(AppId),
|
||||||
|
/// Create / update / delete a script in this app.
|
||||||
|
AppWriteScript(AppId),
|
||||||
|
/// Create / update / delete a route in this app.
|
||||||
|
AppWriteRoute(AppId),
|
||||||
|
/// Manage domain claims on this app (add / remove).
|
||||||
|
AppManageDomains(AppId),
|
||||||
|
/// App settings + delete app. app_admin only (or owner via
|
||||||
|
/// implicit grant).
|
||||||
|
AppAdmin(AppId),
|
||||||
|
/// Read execution logs for scripts in this app.
|
||||||
|
AppLogRead(AppId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capability {
|
||||||
|
/// Extract the `AppId` for app-scoped capabilities; `None` for
|
||||||
|
/// instance-scoped ones. Used by the app-binding check on API keys.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn app_id(self) -> Option<AppId> {
|
||||||
|
match self {
|
||||||
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Self::AppRead(id)
|
||||||
|
| Self::AppWriteScript(id)
|
||||||
|
| Self::AppWriteRoute(id)
|
||||||
|
| Self::AppManageDomains(id)
|
||||||
|
| Self::AppAdmin(id)
|
||||||
|
| Self::AppLogRead(id) => Some(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single scope that authorizes this capability on an API key.
|
||||||
|
/// Strict mapping — a `script:write` key cannot read scripts unless
|
||||||
|
/// it also carries `script:read`. The intent is predictability: a
|
||||||
|
/// key has exactly the scopes it was minted with, no implicit
|
||||||
|
/// upgrades.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn required_scope(self) -> Scope {
|
||||||
|
match self {
|
||||||
|
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||||
|
Scope::InstanceAdmin
|
||||||
|
}
|
||||||
|
Self::AppRead(_) => Scope::ScriptRead,
|
||||||
|
Self::AppWriteScript(_) => Scope::ScriptWrite,
|
||||||
|
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||||
|
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||||
|
Self::AppAdmin(_) => Scope::AppAdmin,
|
||||||
|
Self::AppLogRead(_) => Scope::LogRead,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo seam for membership lookups. Implemented in the DB-backed
|
||||||
|
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
|
||||||
|
/// means unit tests can stub it.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AuthzRepo: Send + Sync {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo errors surface here so handlers can map them to 500 without
|
||||||
|
/// dragging sqlx types across the boundary.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthzError {
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
Repo(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decision flavor returned by `can` — distinguishes outright denial
|
||||||
|
/// from a partial answer that requires further checks (none today,
|
||||||
|
/// but the shape lets us add audit/explain mode later without rewriting
|
||||||
|
/// every caller).
|
||||||
|
#[must_use = "an authorization decision must be acted on"]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Allow,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_allow(self) -> bool {
|
||||||
|
matches!(self, Self::Allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core authorization check. Walks the three intersection layers in
|
||||||
|
/// order and returns the resulting `Decision`.
|
||||||
|
pub async fn can(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<Decision, AuthzError> {
|
||||||
|
if !role_grants(repo, principal, cap).await? {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
if !scope_allows(principal, cap) {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
if !binding_allows(principal, cap) {
|
||||||
|
return Ok(Decision::Deny);
|
||||||
|
}
|
||||||
|
Ok(Decision::Allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
|
||||||
|
/// Handlers call this so the `?` operator threads the 403 through
|
||||||
|
/// naturally.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `AuthzDenied::Denied` when the capability is not granted,
|
||||||
|
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
|
||||||
|
pub async fn require(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<(), AuthzDenied> {
|
||||||
|
match can(repo, principal, cap).await {
|
||||||
|
Ok(Decision::Allow) => Ok(()),
|
||||||
|
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
|
||||||
|
Err(e) => Err(AuthzDenied::Repo(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthzDenied {
|
||||||
|
#[error("forbidden")]
|
||||||
|
Denied,
|
||||||
|
#[error(transparent)]
|
||||||
|
Repo(#[from] AuthzError),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 1: role-derived grant
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn role_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
match principal.instance_role {
|
||||||
|
InstanceRole::Owner => Ok(true),
|
||||||
|
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||||
|
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
|
||||||
|
/// can create apps and manage users, but NOT touch instance-wide
|
||||||
|
/// settings or take app-admin-only actions on apps they're not
|
||||||
|
/// explicitly app_admin of. Everything not in this set falls through
|
||||||
|
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
||||||
|
const fn admin_grants(cap: Capability) -> bool {
|
||||||
|
matches!(
|
||||||
|
cap,
|
||||||
|
Capability::InstanceCreateApp
|
||||||
|
| Capability::InstanceManageUsers
|
||||||
|
| Capability::AppRead(_)
|
||||||
|
| Capability::AppWriteScript(_)
|
||||||
|
| Capability::AppWriteRoute(_)
|
||||||
|
| Capability::AppLogRead(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member has zero instance authority. App authority requires an
|
||||||
|
/// explicit `app_members` row with sufficient `AppRole`.
|
||||||
|
async fn member_grants(
|
||||||
|
repo: &dyn AuthzRepo,
|
||||||
|
user_id: UserId,
|
||||||
|
cap: Capability,
|
||||||
|
) -> Result<bool, AuthzError> {
|
||||||
|
let Some(app_id) = cap.app_id() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(role_satisfies(role, cap))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||||
|
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||||
|
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||||
|
/// the check is "is this capability in the role's set?".
|
||||||
|
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||||
|
let in_viewer = matches!(cap, Capability::AppRead(_) | Capability::AppLogRead(_));
|
||||||
|
let in_editor = in_viewer
|
||||||
|
|| matches!(
|
||||||
|
cap,
|
||||||
|
Capability::AppWriteScript(_) | Capability::AppWriteRoute(_)
|
||||||
|
);
|
||||||
|
let in_app_admin = in_editor
|
||||||
|
|| matches!(
|
||||||
|
cap,
|
||||||
|
Capability::AppManageDomains(_) | Capability::AppAdmin(_)
|
||||||
|
);
|
||||||
|
match role {
|
||||||
|
AppRole::Viewer => in_viewer,
|
||||||
|
AppRole::Editor => in_editor,
|
||||||
|
AppRole::AppAdmin => in_app_admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 2: API-key scope intersection
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
|
||||||
|
match &principal.scopes {
|
||||||
|
None => true, // cookie session — full role authority
|
||||||
|
Some(scopes) => scopes.contains(&cap.required_scope()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Layer 3: API-key app binding
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
|
||||||
|
let Some(bound_app) = principal.app_binding else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
match cap.app_id() {
|
||||||
|
// Instance-scoped capability + bound key → always denied. The
|
||||||
|
// mint handler also rejects this combination upfront, but
|
||||||
|
// defending in depth here means a stale/malformed row can't
|
||||||
|
// escalate.
|
||||||
|
None => false,
|
||||||
|
Some(target_app) => target_app == bound_app,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use picloud_shared::{AdminUserId, AppId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryAuthzRepo {
|
||||||
|
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryAuthzRepo {
|
||||||
|
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||||
|
self.memberships.lock().await.insert((user, app), role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthzRepo for InMemoryAuthzRepo {
|
||||||
|
async fn membership(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AuthzError> {
|
||||||
|
Ok(self
|
||||||
|
.memberships
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(user_id, app_id))
|
||||||
|
.copied())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn principal(role: InstanceRole) -> Principal {
|
||||||
|
Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: role,
|
||||||
|
scopes: None,
|
||||||
|
app_binding: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_can_do_everything() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Owner);
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
"owner denied {cap:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
let app = AppId::new();
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageUsers)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageSettings)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
// Editor-like grants succeed
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Allow,
|
||||||
|
);
|
||||||
|
// App-admin grants do not
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_without_row_is_denied_everywhere() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"member granted {cap:?} without a membership row"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_viewer_role_can_read_but_not_write() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppLogRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_editor_role_can_write_scripts_and_routes() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert!(can(&repo, &p, Capability::AppManageDomains(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
// Membership in App A does NOT grant access to App B
|
||||||
|
let other_app = AppId::new();
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_key_intersects_with_role() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let app = AppId::new();
|
||||||
|
// Owner key with only script:read — cannot write
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptRead]),
|
||||||
|
app_binding: None,
|
||||||
|
};
|
||||||
|
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
// Even though the user is owner — the key's scope set is the
|
||||||
|
// hard ceiling.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_escape_its_app() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let bound_app = AppId::new();
|
||||||
|
let other_app = AppId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::ScriptWrite]),
|
||||||
|
app_binding: Some(bound_app),
|
||||||
|
};
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppWriteScript(other_app))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bound_key_cannot_do_instance_actions() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let bound_app = AppId::new();
|
||||||
|
let p = Principal {
|
||||||
|
user_id: AdminUserId::new(),
|
||||||
|
instance_role: InstanceRole::Owner,
|
||||||
|
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
|
||||||
|
app_binding: Some(bound_app),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
"bound key with instance scope must still be denied at the binding layer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_app_id_extraction() {
|
||||||
|
let app = AppId::new();
|
||||||
|
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||||
|
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||||
|
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_required_scope_mapping_is_complete() {
|
||||||
|
// Sanity: every variant returns a scope. Compiler-enforced
|
||||||
|
// exhaustiveness lives in the match itself; this test guards
|
||||||
|
// against accidental drift to a default branch.
|
||||||
|
let app = AppId::new();
|
||||||
|
for cap in [
|
||||||
|
Capability::InstanceCreateApp,
|
||||||
|
Capability::InstanceManageUsers,
|
||||||
|
Capability::InstanceManageSettings,
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
] {
|
||||||
|
let _ = cap.required_scope(); // does not panic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,14 +8,18 @@ pub mod admin_session_repo;
|
|||||||
pub mod admin_user_repo;
|
pub mod admin_user_repo;
|
||||||
pub mod admin_users_api;
|
pub mod admin_users_api;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod api_key_repo;
|
||||||
|
pub mod api_keys_api;
|
||||||
pub mod app_bootstrap;
|
pub mod app_bootstrap;
|
||||||
pub mod app_domain_repo;
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_members_repo;
|
||||||
pub mod app_repo;
|
pub mod app_repo;
|
||||||
pub mod apps_api;
|
pub mod apps_api;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_api;
|
pub mod auth_api;
|
||||||
pub mod auth_bootstrap;
|
pub mod auth_bootstrap;
|
||||||
pub mod auth_middleware;
|
pub mod auth_middleware;
|
||||||
|
pub mod authz;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
@@ -34,15 +38,28 @@ pub use admin_user_repo::{
|
|||||||
};
|
};
|
||||||
pub use admin_users_api::{admins_router, AdminsState};
|
pub use admin_users_api::{admins_router, AdminsState};
|
||||||
pub use api::{admin_router, AdminState};
|
pub use api::{admin_router, AdminState};
|
||||||
|
pub use api_key_repo::{
|
||||||
|
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
||||||
|
PostgresApiKeyRepository,
|
||||||
|
};
|
||||||
|
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
|
||||||
|
};
|
||||||
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
||||||
pub use apps_api::{apps_router, AppsState};
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_api::auth_router;
|
||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||||
};
|
};
|
||||||
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
#[allow(deprecated)]
|
||||||
|
pub use auth_middleware::{
|
||||||
|
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX,
|
||||||
|
API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||||
|
};
|
||||||
|
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||||
pub use log_sink::PostgresExecutionLogSink;
|
pub use log_sink::PostgresExecutionLogSink;
|
||||||
pub use repo::{
|
pub use repo::{
|
||||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
@@ -27,6 +27,14 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
|
/// Every script in any app the user is a member of. Drives
|
||||||
|
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||||
|
/// API never returns scripts they shouldn't see — even before the
|
||||||
|
/// per-handler capability check fires.
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||||
async fn update(
|
async fn update(
|
||||||
&self,
|
&self,
|
||||||
@@ -117,6 +125,24 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
|
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||||
|
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||||
|
FROM scripts s \
|
||||||
|
JOIN app_members m ON m.app_id = s.app_id \
|
||||||
|
WHERE m.user_id = $1 \
|
||||||
|
ORDER BY s.name",
|
||||||
|
)
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{delete, get, post},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::AppDomainRepository;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||||
use crate::route_repo::{NewRoute, RouteRepository};
|
use crate::route_repo::{NewRoute, RouteRepository};
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ pub struct RouteAdminState<RR, SR> {
|
|||||||
/// declared domain claims.
|
/// declared domain claims.
|
||||||
pub domains: Arc<dyn AppDomainRepository>,
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub table: Arc<RouteTable>,
|
pub table: Arc<RouteTable>,
|
||||||
|
/// Capability gate — Phase 3.5.
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||||
@@ -39,6 +42,7 @@ impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
|||||||
scripts: self.scripts.clone(),
|
scripts: self.scripts.clone(),
|
||||||
domains: self.domains.clone(),
|
domains: self.domains.clone(),
|
||||||
table: self.table.clone(),
|
table: self.table.clone(),
|
||||||
|
authz: self.authz.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,13 +134,26 @@ pub struct MatchedRoute {
|
|||||||
|
|
||||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||||
|
let script = state
|
||||||
|
.scripts
|
||||||
|
.get(script_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(script.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(script_id): Path<ScriptId>,
|
Path(script_id): Path<ScriptId>,
|
||||||
Json(input): Json<CreateRouteRequest>,
|
Json(input): Json<CreateRouteRequest>,
|
||||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||||
@@ -154,6 +171,12 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
let app_id = script.app_id;
|
let app_id = script.app_id;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Validate the route's host is consistent with one of the app's
|
// Validate the route's host is consistent with one of the app's
|
||||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||||
@@ -196,8 +219,22 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
|
|
||||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Path(route_id): Path<Uuid>,
|
Path(route_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, RouteApiError> {
|
) -> Result<StatusCode, RouteApiError> {
|
||||||
|
// Resolve the route's app before we delete, so the capability
|
||||||
|
// binds to the actual route's app_id (not a path param).
|
||||||
|
let route = state
|
||||||
|
.routes
|
||||||
|
.get(route_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppWriteRoute(route.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
state.routes.delete(route_id).await?;
|
state.routes.delete(route_id).await?;
|
||||||
refresh_table(&state).await?;
|
refresh_table(&state).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
@@ -205,8 +242,18 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
|
|
||||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<CheckRouteRequest>,
|
Json(input): Json<CheckRouteRequest>,
|
||||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||||
|
// routes:check is read-only — peeking at a hypothetical conflict
|
||||||
|
// is bounded by AppRead on the target app (otherwise members
|
||||||
|
// could probe other apps).
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||||
|
|
||||||
@@ -235,8 +282,15 @@ async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
|
|
||||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||||
State(state): State<RouteAdminState<RR, SR>>,
|
State(state): State<RouteAdminState<RR, SR>>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
Json(input): Json<MatchRouteRequest>,
|
Json(input): Json<MatchRouteRequest>,
|
||||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||||
|
require(
|
||||||
|
state.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::AppRead(input.app_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let parsed = url::Url::parse(&input.url)
|
let parsed = url::Url::parse(&input.url)
|
||||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||||
let host = parsed.host_str().unwrap_or("").to_string();
|
let host = parsed.host_str().unwrap_or("").to_string();
|
||||||
@@ -415,16 +469,34 @@ pub enum RouteApiError {
|
|||||||
#[error("script not found: {0}")]
|
#[error("script not found: {0}")]
|
||||||
ScriptNotFound(ScriptId),
|
ScriptNotFound(ScriptId),
|
||||||
|
|
||||||
|
#[error("route not found: {0}")]
|
||||||
|
RouteNotFound(Uuid),
|
||||||
|
|
||||||
#[error("host {host:?} is not claimed by this app")]
|
#[error("host {host:?} is not claimed by this app")]
|
||||||
HostNotClaimed {
|
HostNotClaimed {
|
||||||
host: String,
|
host: String,
|
||||||
available_claims: Vec<String>,
|
available_claims: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for RouteApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for RouteApiError {
|
impl IntoResponse for RouteApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
@@ -443,10 +515,23 @@ impl IntoResponse for RouteApiError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
Self::ScriptNotFound(_)
|
||||||
|
| Self::RouteNotFound(_)
|
||||||
|
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
serde_json::json!({ "error": self.to_string() }),
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
|
Self::Forbidden => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
serde_json::json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "route authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
serde_json::json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::HostNotClaimed {
|
Self::HostNotClaimed {
|
||||||
host,
|
host,
|
||||||
available_claims,
|
available_claims,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ pub struct NewRoute {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RouteRepository: Send + Sync {
|
pub trait RouteRepository: Send + Sync {
|
||||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
|
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
|
||||||
|
/// the capability check binds to the route's actual `app_id`
|
||||||
|
/// (not a path param).
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||||
async fn list_for_script(
|
async fn list_for_script(
|
||||||
&self,
|
&self,
|
||||||
@@ -66,6 +70,18 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, RouteRow>(
|
||||||
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, created_at \
|
||||||
|
FROM routes WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(route_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, RouteRow>(
|
let rows = sqlx::query_as::<_, RouteRow>(
|
||||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ table: admin_users
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
last_login_at: timestamp with time zone NULL
|
last_login_at: timestamp with time zone NULL
|
||||||
|
instance_role: text NOT NULL default='owner'::text
|
||||||
|
email: text NULL
|
||||||
|
mfa_secret: text NULL
|
||||||
|
|
||||||
|
table: api_keys
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
hash: text NOT NULL
|
||||||
|
prefix: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
scopes: ARRAY NOT NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
expires_at: timestamp with time zone NULL
|
||||||
|
last_used_at: timestamp with time zone NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: app_domains
|
table: app_domains
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -27,6 +42,12 @@ table: app_domains
|
|||||||
shape_key: text NOT NULL
|
shape_key: text NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: app_members
|
||||||
|
app_id: uuid NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
role: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: app_slug_history
|
table: app_slug_history
|
||||||
slug: text NOT NULL
|
slug: text NOT NULL
|
||||||
current_app_id: uuid NOT NULL
|
current_app_id: uuid NOT NULL
|
||||||
@@ -88,14 +109,25 @@ indexes on admin_sessions:
|
|||||||
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||||
|
|
||||||
indexes on admin_users:
|
indexes on admin_users:
|
||||||
|
admin_users_email_key: public.admin_users USING btree (email)
|
||||||
|
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
|
||||||
admin_users_pkey: public.admin_users USING btree (id)
|
admin_users_pkey: public.admin_users USING btree (id)
|
||||||
admin_users_username_key: public.admin_users USING btree (username)
|
admin_users_username_key: public.admin_users USING btree (username)
|
||||||
|
|
||||||
|
indexes on api_keys:
|
||||||
|
api_keys_pkey: public.api_keys USING btree (id)
|
||||||
|
api_keys_prefix_idx: public.api_keys USING btree (prefix)
|
||||||
|
api_keys_user_id_idx: public.api_keys USING btree (user_id)
|
||||||
|
|
||||||
indexes on app_domains:
|
indexes on app_domains:
|
||||||
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||||
app_domains_pkey: public.app_domains USING btree (id)
|
app_domains_pkey: public.app_domains USING btree (id)
|
||||||
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||||
|
|
||||||
|
indexes on app_members:
|
||||||
|
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||||
|
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||||
|
|
||||||
indexes on app_slug_history:
|
indexes on app_slug_history:
|
||||||
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||||
|
|
||||||
@@ -127,15 +159,28 @@ constraints on admin_sessions:
|
|||||||
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||||
|
|
||||||
constraints on admin_users:
|
constraints on admin_users:
|
||||||
|
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
|
||||||
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] admin_users_email_key: UNIQUE (email)
|
||||||
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||||
|
|
||||||
|
constraints on api_keys:
|
||||||
|
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on app_domains:
|
constraints on app_domains:
|
||||||
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||||
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||||
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||||
|
|
||||||
|
constraints on app_members:
|
||||||
|
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||||
|
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||||
|
|
||||||
constraints on app_slug_history:
|
constraints on app_slug_history:
|
||||||
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||||
@@ -169,3 +214,4 @@ constraints on scripts:
|
|||||||
0003: routes
|
0003: routes
|
||||||
0004: admin auth
|
0004: admin auth
|
||||||
0005: apps
|
0005: apps
|
||||||
|
0006: users authz
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ figment.workspace = true
|
|||||||
axum-test = "17"
|
axum-test = "17"
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ use axum::middleware::from_fn_with_state;
|
|||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
|
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
||||||
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
|
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
||||||
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState,
|
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository,
|
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
|
||||||
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||||
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||||
RouteRepository, SandboxCeiling,
|
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||||
|
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
@@ -37,6 +38,7 @@ const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
|||||||
pub struct AuthDeps {
|
pub struct AuthDeps {
|
||||||
pub users: Arc<dyn AdminUserRepository>,
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||||
|
pub keys: Arc<dyn ApiKeyRepository>,
|
||||||
pub ttl: Duration,
|
pub ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +48,8 @@ impl AuthDeps {
|
|||||||
pub fn from_pool(pool: PgPool) -> Self {
|
pub fn from_pool(pool: PgPool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool.clone())),
|
||||||
|
keys: Arc::new(PostgresApiKeyRepository::new(pool)),
|
||||||
ttl: read_session_ttl(),
|
ttl: read_session_ttl(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
Arc::new(PostgresAppDomainRepository::new(pool));
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
|
// Authz: app_members repo doubles as the AuthzRepo impl for the
|
||||||
|
// per-handler capability checks introduced in Phase 3.5.
|
||||||
|
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
@@ -120,6 +126,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
validator: engine as Arc<dyn ScriptValidator>,
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
@@ -128,6 +135,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
domains: domains_repo.clone(),
|
domains: domains_repo.clone(),
|
||||||
table: route_table.clone(),
|
table: route_table.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
@@ -141,28 +149,39 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
domains: domains_repo,
|
domains: domains_repo,
|
||||||
routes: route_repo,
|
routes: route_repo,
|
||||||
domain_table: app_domain_table,
|
domain_table: app_domain_table,
|
||||||
|
authz: authz.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_state = AuthState {
|
let auth_state = AuthState {
|
||||||
users: auth.users.clone(),
|
users: auth.users.clone(),
|
||||||
sessions: auth.sessions.clone(),
|
sessions: auth.sessions.clone(),
|
||||||
|
keys: auth.keys.clone(),
|
||||||
ttl: auth.ttl,
|
ttl: auth.ttl,
|
||||||
};
|
};
|
||||||
let admins_state = AdminsState {
|
let admins_state = AdminsState {
|
||||||
users: auth.users,
|
users: auth.users,
|
||||||
sessions: auth.sessions,
|
sessions: auth.sessions,
|
||||||
|
keys: auth.keys.clone(),
|
||||||
|
authz,
|
||||||
};
|
};
|
||||||
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
|
|
||||||
// /admin/auth/login + /logout are unguarded by design (login is how
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||||
// you get in). /admin/auth/me applies the middleware internally so
|
// you get in). /admin/auth/me applies the middleware internally so
|
||||||
// the same Router::with_state machinery composes cleanly. Everything
|
// the same Router::with_state machinery composes cleanly. Everything
|
||||||
// else under /admin gets the require_admin layer.
|
// else under /admin gets the require_authenticated layer; capability
|
||||||
|
// checks live in each handler (after the resource is loaded so the
|
||||||
|
// capability binds to the resource's actual app_id).
|
||||||
let guarded_admin = Router::new()
|
let guarded_admin = Router::new()
|
||||||
.merge(admin_router(admin))
|
.merge(admin_router(admin))
|
||||||
.merge(route_admin_router(route_admin))
|
.merge(route_admin_router(route_admin))
|
||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
.merge(api_keys_router(api_keys_state))
|
||||||
|
.layer(from_fn_with_state(
|
||||||
|
auth_state.clone(),
|
||||||
|
require_authenticated,
|
||||||
|
));
|
||||||
|
|
||||||
// Silence "unused import" lint on `apps_api` — we re-export via the
|
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||||
// facade above; the bare module path is retained so it's discoverable.
|
// facade above; the bare module path is retained so it's discoverable.
|
||||||
@@ -244,6 +263,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|||||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
self.0.list_for_app(app_id).await
|
self.0.list_for_app(app_id).await
|
||||||
}
|
}
|
||||||
|
async fn list_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: picloud_shared::AdminUserId,
|
||||||
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||||
|
self.0.list_for_user(user_id).await
|
||||||
|
}
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
input: picloud_manager_core::NewScript,
|
input: picloud_manager_core::NewScript,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async fn run_server() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let auth = AuthDeps::from_pool(pool.clone());
|
let auth = AuthDeps::from_pool(pool.clone());
|
||||||
bootstrap_first_admin(&*auth.users).await?;
|
bootstrap_first_admin(&*auth.users).await?;
|
||||||
|
warn_on_multi_owner_install(&*auth.users).await;
|
||||||
|
|
||||||
// Seed Hello World into the default app when this is a fresh
|
// Seed Hello World into the default app when this is a fresh
|
||||||
// install (no scripts and no routes). Idempotent on upgrades.
|
// install (no scripts and no routes). Idempotent on upgrades.
|
||||||
@@ -79,6 +80,34 @@ async fn run_server() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multi-owner startup warning — Phase 3.5 migration upgraded every
|
||||||
|
/// pre-existing admin_users row to `Owner` via DEFAULT, which for
|
||||||
|
/// installs with several Phase 3a admins means several co-owners.
|
||||||
|
/// Surface this once at boot so the operator can demote extras via
|
||||||
|
/// `PATCH /api/v1/admin/admins/{id}` with `instance_role: "admin"`.
|
||||||
|
/// Soft-fail: a DB blip should not block startup.
|
||||||
|
async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) {
|
||||||
|
match users.list_active_owners().await {
|
||||||
|
Ok(owners) if owners.len() > 1 => {
|
||||||
|
let names: Vec<String> = owners.into_iter().map(|u| u.username).collect();
|
||||||
|
tracing::warn!(
|
||||||
|
count = names.len(),
|
||||||
|
owners = ?names,
|
||||||
|
"multiple active owners detected — Phase 3.5 promoted every \
|
||||||
|
pre-existing admin to owner. Demote extras via \
|
||||||
|
PATCH /api/v1/admin/admins/{{id}} with instance_role."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
?err,
|
||||||
|
"could not count active owners for multi-owner startup check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
let mut ticker = tokio::time::interval(Duration::from_secs(600));
|
||||||
|
|||||||
@@ -31,11 +31,12 @@ async fn server(pool: PgPool) -> TestServer {
|
|||||||
/// any test that creates scripts (every script now requires `app_id`).
|
/// any test that creates scripts (every script now requires `app_id`).
|
||||||
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
||||||
use picloud_manager_core::auth::hash_password;
|
use picloud_manager_core::auth::hash_password;
|
||||||
|
use picloud_shared::InstanceRole;
|
||||||
|
|
||||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
let hash = hash_password("test-pw").expect("hash");
|
let hash = hash_password("test-pw").expect("hash");
|
||||||
auth.users
|
auth.users
|
||||||
.create("test-admin", &hash)
|
.create("test-admin", &hash, InstanceRole::Owner, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed admin");
|
.expect("seed admin");
|
||||||
|
|
||||||
@@ -92,6 +93,68 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
assert_eq!(r.text(), "ok");
|
assert_eq!(r.text(), "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/auth/me").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert_eq!(body["username"], "test-admin");
|
||||||
|
assert_eq!(body["instance_role"], "owner");
|
||||||
|
// Seeded admin has no email — must round-trip as null, not be missing.
|
||||||
|
assert!(
|
||||||
|
body.get("email").is_some_and(Value::is_null),
|
||||||
|
"email should be present and null, got: {body}"
|
||||||
|
);
|
||||||
|
assert!(body["id"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn create_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Create with email set.
|
||||||
|
let created = s
|
||||||
|
.post("/api/v1/admin/admins")
|
||||||
|
.json(&json!({
|
||||||
|
"username": "alice",
|
||||||
|
"password": "correct-horse-battery",
|
||||||
|
"instance_role": "member",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
created.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let body: Value = created.json();
|
||||||
|
let alice_id = body["id"].as_str().expect("id").to_string();
|
||||||
|
assert_eq!(body["email"], "alice@example.com");
|
||||||
|
|
||||||
|
// Patch with email present-and-null clears it.
|
||||||
|
let cleared = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": null }))
|
||||||
|
.await;
|
||||||
|
cleared.assert_status_ok();
|
||||||
|
assert!(cleared.json::<Value>()["email"].is_null());
|
||||||
|
|
||||||
|
// Patch with email omitted is a no-op (doesn't clobber a re-set).
|
||||||
|
let reset = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": "alice2@example.com" }))
|
||||||
|
.await;
|
||||||
|
reset.assert_status_ok();
|
||||||
|
let omit = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "username": "alice" })) // no email key
|
||||||
|
.await;
|
||||||
|
omit.assert_status_ok();
|
||||||
|
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Script CRUD
|
// Script CRUD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -821,7 +884,7 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
|||||||
let v: Value = r.json();
|
let v: Value = r.json();
|
||||||
assert!(v["public_base_url"].is_string());
|
assert!(v["public_base_url"].is_string());
|
||||||
assert_eq!(v["api"], 1);
|
assert_eq!(v["api"], 1);
|
||||||
assert_eq!(v["schema"], 5);
|
assert_eq!(v["schema"], 6);
|
||||||
assert_eq!(v["sdk"], "1.1");
|
assert_eq!(v["sdk"], "1.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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, None)
|
||||||
|
.await
|
||||||
|
.expect("seed owner");
|
||||||
|
|
||||||
|
let app = picloud::build_app(pool.clone(), auth)
|
||||||
|
.await
|
||||||
|
.expect("build_app");
|
||||||
|
let server = TestServer::new(app).expect("TestServer");
|
||||||
|
|
||||||
|
// Default app id (seeded by migration 0005).
|
||||||
|
let resp = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": "owner", "password": "owner-pw" }))
|
||||||
|
.await;
|
||||||
|
resp.assert_status_ok();
|
||||||
|
let token = resp.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("login token")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let app_resp = server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
app_resp.assert_status_ok();
|
||||||
|
let app_id: uuid::Uuid = app_resp.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("app id")
|
||||||
|
.parse()
|
||||||
|
.expect("uuid");
|
||||||
|
|
||||||
|
Seeded {
|
||||||
|
server,
|
||||||
|
pool,
|
||||||
|
owner: owner.id,
|
||||||
|
default_app: app_id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a session for an existing admin via the login endpoint and
|
||||||
|
/// return the raw token. Lets tests build a per-role credential
|
||||||
|
/// without baking it into the default headers.
|
||||||
|
async fn login_token(server: &TestServer, username: &str, password: &str) -> String {
|
||||||
|
let r = server
|
||||||
|
.post("/api/v1/admin/auth/login")
|
||||||
|
.json(&json!({ "username": username, "password": password }))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
r.json::<Value>()["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("token in login response")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct-DB seed (bypassing the API) for users we want to construct
|
||||||
|
/// at arbitrary roles. The API enforces "owners only create owners"
|
||||||
|
/// which is correct production behavior but inconvenient for test
|
||||||
|
/// fixtures.
|
||||||
|
async fn seed_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
role: InstanceRole,
|
||||||
|
) -> AdminUserId {
|
||||||
|
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let hash = hash_password(password).expect("hash");
|
||||||
|
repo.create(username, &hash, role, None)
|
||||||
|
.await
|
||||||
|
.expect("seed user")
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_membership(pool: &PgPool, user: AdminUserId, app: AppId, role: AppRole) {
|
||||||
|
let repo = PostgresAppMembersRepository::new(pool.clone());
|
||||||
|
repo.upsert(app, user, role)
|
||||||
|
.await
|
||||||
|
.expect("grant membership");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_script_via_api(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Value {
|
||||||
|
let r = server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": app_id.to_string(),
|
||||||
|
"name": name,
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
r.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint an API key for the caller — wraps POST /api-keys.
|
||||||
|
async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.post("/api/v1/admin/api-keys")
|
||||||
|
.add_header("authorization", format!("Bearer {cred_token}"))
|
||||||
|
.json(&body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 1. Bootstrap admin → owner
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bootstrap_admin_is_owner(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let me = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
me.assert_status_ok();
|
||||||
|
let listing = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
listing.assert_status_ok();
|
||||||
|
let arr: Value = listing.json();
|
||||||
|
let row = arr
|
||||||
|
.as_array()
|
||||||
|
.and_then(|v| v.iter().find(|u| u["username"] == "owner"))
|
||||||
|
.expect("owner row");
|
||||||
|
assert_eq!(row["instance_role"], "owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 2 / 3 / 4. Role access matrices on a sample app
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn owner_access_matrix(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Read apps / scripts.
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Create a script — AppWriteScript.
|
||||||
|
let script = create_script_via_api(&s.server, &token, s.default_app, "owner-test").await;
|
||||||
|
let sid = script["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Read it back — AppRead.
|
||||||
|
s.server
|
||||||
|
.get(&format!("/api/v1/admin/scripts/{sid}"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Manage users — InstanceManageUsers.
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
|
let token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
|
|
||||||
|
// Allowed: list admins (InstanceManageUsers).
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: read default app (admin is implicit editor everywhere).
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: write scripts (implicit editor).
|
||||||
|
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
||||||
|
assert!(script["id"].is_string());
|
||||||
|
|
||||||
|
// Denied: delete the default app (AppAdmin only).
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.delete("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn member_can_only_touch_apps_they_belong_to(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
|
||||||
|
let token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
// Allowed: read + write inside the default app.
|
||||||
|
s.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
let script = create_script_via_api(&s.server, &token, s.default_app, "member-write").await;
|
||||||
|
let sid = script["id"].as_str().unwrap();
|
||||||
|
s.server
|
||||||
|
.get(&format!("/api/v1/admin/scripts/{sid}"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Denied: create a *new* app (member cannot InstanceCreateApp).
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({ "slug": "other", "name": "Other" }))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
// Denied: manage admins.
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/admins")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 5. Bearer pic_ + cookie produce the same Principal
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bearer_and_cookie_produce_same_principal(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let session_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Mint a no-binding owner key covering script:read.
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&session_token,
|
||||||
|
json!({
|
||||||
|
"name": "owner-readonly",
|
||||||
|
"scopes": ["script:read"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw_token = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("raw token")
|
||||||
|
.to_string();
|
||||||
|
assert!(raw_token.starts_with("pic_"));
|
||||||
|
|
||||||
|
// /me through the cookie/session path.
|
||||||
|
let via_session = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {session_token}"))
|
||||||
|
.await;
|
||||||
|
via_session.assert_status_ok();
|
||||||
|
|
||||||
|
// /me through the pic_ path — same user_id.
|
||||||
|
let via_key = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {raw_token}"))
|
||||||
|
.await;
|
||||||
|
via_key.assert_status_ok();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
via_session.json::<Value>()["id"],
|
||||||
|
via_key.json::<Value>()["id"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
via_session.json::<Value>()["username"],
|
||||||
|
via_key.json::<Value>()["username"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 6. Scope intersection — read-only key cannot write
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn read_only_key_cannot_write_scripts(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let session_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&session_token,
|
||||||
|
json!({ "name": "ro", "scopes": ["script:read"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
"name": "would-write",
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 7. Bound key cannot escape its app
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bound_key_cannot_escape_its_app(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let session_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Create a second app via the API (owner can InstanceCreateApp).
|
||||||
|
let other = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {session_token}"))
|
||||||
|
.json(&json!({ "slug": "other", "name": "Other" }))
|
||||||
|
.await;
|
||||||
|
other.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let other_id = other.json::<Value>()["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Mint a key bound to the default app with script:write.
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&session_token,
|
||||||
|
json!({
|
||||||
|
"name": "default-only",
|
||||||
|
"scopes": ["script:write"],
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Writing into the bound app: allowed.
|
||||||
|
let ok = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
"name": "bound-ok",
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
ok.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Writing into the *other* app: forbidden.
|
||||||
|
let denied = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.json(&json!({
|
||||||
|
"app_id": other_id,
|
||||||
|
"name": "escape-attempt",
|
||||||
|
"source": "fn main() { #{ statusCode: 200 } }",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 8. Member listing isolation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn member_list_endpoints_filter_at_sql(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Owner creates a second app + script-in-that-app.
|
||||||
|
let other = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.json(&json!({ "slug": "secret", "name": "Secret" }))
|
||||||
|
.await;
|
||||||
|
other.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let other_id: uuid::Uuid = other.json::<Value>()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let other_app: AppId = other_id.into();
|
||||||
|
create_script_via_api(&s.server, &owner_token, other_app, "secret-script").await;
|
||||||
|
create_script_via_api(&s.server, &owner_token, s.default_app, "default-script").await;
|
||||||
|
|
||||||
|
// Carol is a member of the default app only.
|
||||||
|
let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, carol, s.default_app, AppRole::Viewer).await;
|
||||||
|
let carol_token = login_token(&s.server, "carol", "carol-pw").await;
|
||||||
|
|
||||||
|
let apps = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {carol_token}"))
|
||||||
|
.await;
|
||||||
|
apps.assert_status_ok();
|
||||||
|
let apps_body: Value = apps.json();
|
||||||
|
let app_slugs: Vec<String> = apps_body
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a["slug"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
app_slugs,
|
||||||
|
vec!["default"],
|
||||||
|
"member must see only their apps"
|
||||||
|
);
|
||||||
|
|
||||||
|
let scripts = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/scripts")
|
||||||
|
.add_header("authorization", format!("Bearer {carol_token}"))
|
||||||
|
.await;
|
||||||
|
scripts.assert_status_ok();
|
||||||
|
let scripts_body: Value = scripts.json();
|
||||||
|
let names: Vec<String> = scripts_body
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|s| s["name"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "default-script") && !names.iter().any(|n| n == "secret-script"),
|
||||||
|
"member listing leaked another app's script: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 9. Deactivation revokes API keys
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn deactivating_user_revokes_their_api_keys(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// A second user — admin so they can mint a key for themselves.
|
||||||
|
let dave_id = seed_user(&s.pool, "dave", "dave-pw", InstanceRole::Admin).await;
|
||||||
|
let dave_token = login_token(&s.server, "dave", "dave-pw").await;
|
||||||
|
let mint = mint_key(
|
||||||
|
&s.server,
|
||||||
|
&dave_token,
|
||||||
|
json!({ "name": "dave-key", "scopes": ["script:read"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mint.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let raw = mint.json::<Value>()["raw_token"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Key works.
|
||||||
|
let before = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.await;
|
||||||
|
before.assert_status_ok();
|
||||||
|
|
||||||
|
// Owner deactivates Dave.
|
||||||
|
let patch = s
|
||||||
|
.server
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{dave_id}"))
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.json(&json!({ "is_active": false }))
|
||||||
|
.await;
|
||||||
|
patch.assert_status_ok();
|
||||||
|
|
||||||
|
// Key now rejects with 401.
|
||||||
|
let after = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/auth/me")
|
||||||
|
.add_header("authorization", format!("Bearer {raw}"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(after.status_code(), axum::http::StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
// Cross-check via the repo: the row's expires_at is set in the past.
|
||||||
|
let repo = PostgresApiKeyRepository::new(s.pool.clone());
|
||||||
|
let rows = repo.list_for_user(dave_id).await.expect("list keys");
|
||||||
|
assert!(
|
||||||
|
rows.iter().all(|r| r.expires_at.is_some()),
|
||||||
|
"every key must have an expiry after deactivation"
|
||||||
|
);
|
||||||
|
assert!(rows
|
||||||
|
.iter()
|
||||||
|
.all(|r| r.expires_at.unwrap() <= chrono::Utc::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 10. Mint rejects bound key + instance scope
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn bound_key_with_instance_scope_is_rejected(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/api-keys")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({
|
||||||
|
"name": "irreconcilable",
|
||||||
|
"scopes": ["instance:admin"],
|
||||||
|
"app_id": s.default_app.to_string(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
r.status_code(),
|
||||||
|
axum::http::StatusCode::UNPROCESSABLE_ENTITY
|
||||||
|
);
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(
|
||||||
|
body["error"].as_str().unwrap().contains("bound"),
|
||||||
|
"error body should explain the conflict, got {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 11. Multi-owner detection — data-source for the startup warning
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
|
||||||
|
let s = boot(pool.clone()).await;
|
||||||
|
|
||||||
|
// Seed a second owner directly so we exercise the
|
||||||
|
// multi-owner condition.
|
||||||
|
seed_user(&s.pool, "owner2", "pw", InstanceRole::Owner).await;
|
||||||
|
seed_user(&s.pool, "admin1", "pw", InstanceRole::Admin).await;
|
||||||
|
|
||||||
|
let users = Arc::new(PostgresAdminUserRepository::new(s.pool.clone()));
|
||||||
|
let owners = users.list_active_owners().await.expect("list owners");
|
||||||
|
let names: Vec<&str> = owners.iter().map(|o| o.username.as_str()).collect();
|
||||||
|
assert!(names.contains(&"owner"));
|
||||||
|
assert!(names.contains(&"owner2"));
|
||||||
|
assert!(!names.contains(&"admin1"));
|
||||||
|
assert_eq!(
|
||||||
|
owners.len(),
|
||||||
|
2,
|
||||||
|
"list_active_owners must filter strictly by instance_role"
|
||||||
|
);
|
||||||
|
|
||||||
|
// count_other_active_owners powers the last-owner guard.
|
||||||
|
let remaining = users
|
||||||
|
.count_other_active_owners(s.owner)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
|
||||||
|
}
|
||||||
242
crates/shared/src/auth.rs
Normal file
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,3 +52,4 @@ id_type!(ExecutionId);
|
|||||||
id_type!(RequestId);
|
id_type!(RequestId);
|
||||||
id_type!(AdminUserId);
|
id_type!(AdminUserId);
|
||||||
id_type!(AppId);
|
id_type!(AppId);
|
||||||
|
id_type!(ApiKeyId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! entity, error roots, transport DTOs).
|
//! entity, error roots, transport DTOs).
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
@@ -16,9 +17,10 @@ pub mod validator;
|
|||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
pub use app::{App, AppDomain, DomainShape};
|
pub use app::{App, AppDomain, DomainShape};
|
||||||
|
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||||
pub use ids::{AdminUserId, AppId, ExecutionId, RequestId, ScriptId};
|
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
pub use route::{HostKind, PathKind, Route};
|
pub use route::{HostKind, PathKind, Route};
|
||||||
pub use sandbox::ScriptSandbox;
|
pub use sandbox::ScriptSandbox;
|
||||||
|
|||||||
4
dashboard/package-lock.json
generated
4
dashboard/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.1",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.1",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.2",
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.5.1",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
256
dashboard/src/lib/ActionMenu.svelte
Normal file
256
dashboard/src/lib/ActionMenu.svelte
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<!--
|
||||||
|
Per-row "⋮" kebab menu. Hides secondary actions (edit, deactivate,
|
||||||
|
delete, etc.) behind a single trigger so list rows stay tidy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<ActionMenu
|
||||||
|
items={[
|
||||||
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
|
{ label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
|
onClick: () => toggleActive(row) },
|
||||||
|
{ label: 'Delete', danger: true, onClick: () => openDelete(row),
|
||||||
|
disabled: !canDelete(row) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
Closes on: item click, click outside, ESC, scroll/resize. Keyboard:
|
||||||
|
Enter/Space opens; Up/Down navigate; Enter activates; ESC closes and
|
||||||
|
re-focuses the trigger. The popover is absolutely positioned relative
|
||||||
|
to the trigger and right-anchored — the parent must allow overflow
|
||||||
|
(`overflow: visible`) for it to extend past the row.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: MenuItem[];
|
||||||
|
/** Accessible label for the trigger button. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, label = 'More actions' }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||||
|
let menuEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
|
||||||
|
let enabledIndices = $derived(
|
||||||
|
items
|
||||||
|
.map((it, i) => (it.disabled ? -1 : i))
|
||||||
|
.filter((i) => i >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open ? close() : openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
open = true;
|
||||||
|
activeIndex = enabledIndices[0] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(refocus = false) {
|
||||||
|
open = false;
|
||||||
|
activeIndex = -1;
|
||||||
|
if (refocus) triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(index: number) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item || item.disabled) return;
|
||||||
|
close();
|
||||||
|
item.onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(step: 1 | -1) {
|
||||||
|
if (enabledIndices.length === 0) return;
|
||||||
|
const cur = enabledIndices.indexOf(activeIndex);
|
||||||
|
const next =
|
||||||
|
cur === -1
|
||||||
|
? enabledIndices[0]
|
||||||
|
: enabledIndices[(cur + step + enabledIndices.length) % enabledIndices.length];
|
||||||
|
activeIndex = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTriggerKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!open) openMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMenuKeydown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(-1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0) activate(activeIndex);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
close(true);
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowMouseDown(e: MouseEvent) {
|
||||||
|
if (!open) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (menuEl?.contains(target) || triggerEl?.contains(target)) return;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on viewport changes — naive but enough; without a portal a
|
||||||
|
// scrolling list would otherwise leave the popover drifting away from
|
||||||
|
// its row.
|
||||||
|
function onViewportChange() {
|
||||||
|
if (open) close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onmousedown={onWindowMouseDown}
|
||||||
|
onscroll={onViewportChange}
|
||||||
|
onresize={onViewportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
class="trigger"
|
||||||
|
class:open
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggle}
|
||||||
|
onkeydown={onTriggerKeydown}
|
||||||
|
>
|
||||||
|
<!-- vertical ellipsis ⋮ — kept inline as text so it inherits color -->
|
||||||
|
<span aria-hidden="true">⋮</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={menuEl}
|
||||||
|
class="menu"
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={onMenuKeydown}
|
||||||
|
>
|
||||||
|
{#each items as item, i (i)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="item"
|
||||||
|
class:danger={item.danger}
|
||||||
|
class:active={i === activeIndex}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onclick={() => activate(i)}
|
||||||
|
onmouseenter={() => {
|
||||||
|
if (!item.disabled) activeIndex = i;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover,
|
||||||
|
.trigger:focus-visible,
|
||||||
|
.trigger.open {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #334155;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 9rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.active:not(:disabled) {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger.active:not(:disabled) {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
dashboard/src/lib/RoleChip.svelte
Normal file
45
dashboard/src/lib/RoleChip.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InstanceRole } from '$lib/auth';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: InstanceRole;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { role, size = 'md' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.chip.sm {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
.chip-owner {
|
||||||
|
background: #78350f;
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: #b45309;
|
||||||
|
}
|
||||||
|
.chip-admin {
|
||||||
|
background: #164e63;
|
||||||
|
color: #67e8f9;
|
||||||
|
border-color: #0e7490;
|
||||||
|
}
|
||||||
|
.chip-member {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
import { clearSession, getToken, setSession, type InstanceRole } from './auth';
|
||||||
|
|
||||||
|
export type { InstanceRole };
|
||||||
|
|
||||||
export interface ScriptSandbox {
|
export interface ScriptSandbox {
|
||||||
max_operations?: number;
|
max_operations?: number;
|
||||||
@@ -232,10 +234,42 @@ function safeJson(text: string): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserRecord {
|
export type Scope =
|
||||||
|
| 'script:read'
|
||||||
|
| 'script:write'
|
||||||
|
| 'route:write'
|
||||||
|
| 'domain:manage'
|
||||||
|
| 'log:read'
|
||||||
|
| 'app:admin'
|
||||||
|
| 'instance:admin';
|
||||||
|
|
||||||
|
export const ALL_SCOPES: readonly Scope[] = [
|
||||||
|
'script:read',
|
||||||
|
'script:write',
|
||||||
|
'route:write',
|
||||||
|
'domain:manage',
|
||||||
|
'log:read',
|
||||||
|
'app:admin',
|
||||||
|
'instance:admin'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function isInstanceScope(s: Scope): boolean {
|
||||||
|
return s.startsWith('instance:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeDto {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDto {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login_at: string | null;
|
last_login_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -243,16 +277,42 @@ export interface AdminUserRecord {
|
|||||||
export interface CreateAdminInput {
|
export interface CreateAdminInput {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
email?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchAdminInput {
|
export interface PatchAdminInput {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyDto {
|
||||||
|
id: string;
|
||||||
|
prefix: string;
|
||||||
|
name: string;
|
||||||
|
scopes: Scope[];
|
||||||
|
app_id: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintApiKeyInput {
|
||||||
|
name: string;
|
||||||
|
scopes: Scope[];
|
||||||
|
app_id?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintApiKeyResponse extends ApiKeyDto {
|
||||||
|
raw_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
user: AdminUser;
|
user: MeDto;
|
||||||
token: string;
|
token: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
}
|
}
|
||||||
@@ -263,7 +323,7 @@ export const api = {
|
|||||||
version: () => adminRequest<VersionInfo>('/version'),
|
version: () => adminRequest<VersionInfo>('/version'),
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
login: async (username: string, password: string): Promise<AdminUser> => {
|
login: async (username: string, password: string): Promise<MeDto> => {
|
||||||
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
@@ -282,19 +342,19 @@ export const api = {
|
|||||||
clearSession();
|
clearSession();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
|
||||||
},
|
},
|
||||||
|
|
||||||
admins: {
|
admins: {
|
||||||
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
|
||||||
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
|
||||||
create: (input: CreateAdminInput) =>
|
create: (input: CreateAdminInput) =>
|
||||||
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
adminRequest<AdminDto>('/api/v1/admin/admins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
update: (id: string, input: PatchAdminInput) =>
|
update: (id: string, input: PatchAdminInput) =>
|
||||||
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
@@ -302,6 +362,17 @@ export const api = {
|
|||||||
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
apiKeys: {
|
||||||
|
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
|
||||||
|
mint: (input: MintApiKeyInput) =>
|
||||||
|
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
revoke: (id: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
listForScript: (scriptId: string) =>
|
listForScript: (scriptId: string) =>
|
||||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
import { writable, get } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type InstanceRole = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'picloud.admin.token';
|
const TOKEN_KEY = 'picloud.admin.token';
|
||||||
|
|||||||
25
dashboard/src/lib/password-gen.ts
Normal file
25
dashboard/src/lib/password-gen.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Cryptographically random password generator for the user-create
|
||||||
|
// and reset-password flows. PiCloud has no email yet, so the admin
|
||||||
|
// invites a user by generating a password locally, posting it to the
|
||||||
|
// backend, and copying the cleartext out of the one-time reveal panel
|
||||||
|
// to share through whatever channel they trust.
|
||||||
|
//
|
||||||
|
// Charset is alphanumeric plus a small printable symbol set — enough
|
||||||
|
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
||||||
|
// avoidant of characters that ship awkwardly through chat clients
|
||||||
|
// (no quotes, slashes, or backticks).
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
export function generatePassword(length = 16): string {
|
||||||
|
if (length < 8) {
|
||||||
|
throw new Error('password length must be at least 8');
|
||||||
|
}
|
||||||
|
const buf = new Uint32Array(length);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
out += CHARSET[buf[i] % CHARSET.length];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { currentUser, getToken } from '$lib/auth';
|
import { currentUser, getToken } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -46,12 +47,17 @@
|
|||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/apps'}>Apps</a>
|
<a href={base + '/apps'}>Apps</a>
|
||||||
<a href={base + '/admins'}>Admins</a>
|
{#if user && user.instance_role !== 'member'}
|
||||||
|
<a href={base + '/users'}>Users</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="usermenu">
|
<div class="usermenu">
|
||||||
<span class="username">{user.username}</span>
|
<a href={base + '/profile'} class="profile-chip" title="View profile">
|
||||||
|
<RoleChip role={user.instance_role} size="sm" />
|
||||||
|
<span class="username">{user.username}</span>
|
||||||
|
</a>
|
||||||
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -121,6 +127,20 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.profile-chip:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,687 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { base } from '$app/paths';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
|
||||||
import { currentUser } from '$lib/auth';
|
|
||||||
|
|
||||||
let admins = $state<AdminUserRecord[]>([]);
|
|
||||||
let loadError = $state<string | null>(null);
|
|
||||||
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
|
||||||
|
|
||||||
const me = $derived($currentUser);
|
|
||||||
|
|
||||||
let createOpen = $state(false);
|
|
||||||
let createForm = $state({ username: '', password: '', confirm: '' });
|
|
||||||
let createPending = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
let passwordTarget = $state<AdminUserRecord | null>(null);
|
|
||||||
let passwordForm = $state({ password: '', confirm: '' });
|
|
||||||
let passwordPending = $state(false);
|
|
||||||
let passwordError = $state<string | null>(null);
|
|
||||||
|
|
||||||
let deleteTarget = $state<AdminUserRecord | null>(null);
|
|
||||||
let deletePending = $state(false);
|
|
||||||
|
|
||||||
let actionsOpenFor = $state<string | null>(null);
|
|
||||||
|
|
||||||
onMount(refresh);
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loadError = null;
|
|
||||||
try {
|
|
||||||
admins = await api.admins.list();
|
|
||||||
} catch (e) {
|
|
||||||
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash(kind: 'error' | 'info', message: string) {
|
|
||||||
banner = { kind, message };
|
|
||||||
setTimeout(() => {
|
|
||||||
if (banner?.message === message) banner = null;
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
createForm = { username: '', password: '', confirm: '' };
|
|
||||||
createError = null;
|
|
||||||
createOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
createError = null;
|
|
||||||
if (createForm.password !== createForm.confirm) {
|
|
||||||
createError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createPending = true;
|
|
||||||
try {
|
|
||||||
await api.admins.create({
|
|
||||||
username: createForm.username.trim(),
|
|
||||||
password: createForm.password
|
|
||||||
});
|
|
||||||
createOpen = false;
|
|
||||||
await refresh();
|
|
||||||
flash('info', `Created admin "${createForm.username.trim()}".`);
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
|
||||||
} finally {
|
|
||||||
createPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPassword(row: AdminUserRecord) {
|
|
||||||
passwordTarget = row;
|
|
||||||
passwordForm = { password: '', confirm: '' };
|
|
||||||
passwordError = null;
|
|
||||||
actionsOpenFor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitPassword(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!passwordTarget) return;
|
|
||||||
passwordError = null;
|
|
||||||
if (passwordForm.password !== passwordForm.confirm) {
|
|
||||||
passwordError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
passwordPending = true;
|
|
||||||
try {
|
|
||||||
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
|
||||||
const name = passwordTarget.username;
|
|
||||||
passwordTarget = null;
|
|
||||||
flash('info', `Password updated for "${name}".`);
|
|
||||||
} catch (e) {
|
|
||||||
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
|
||||||
} finally {
|
|
||||||
passwordPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleActive(row: AdminUserRecord) {
|
|
||||||
actionsOpenFor = null;
|
|
||||||
try {
|
|
||||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
|
||||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
|
||||||
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
|
||||||
} catch (e) {
|
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDelete(row: AdminUserRecord) {
|
|
||||||
deleteTarget = row;
|
|
||||||
actionsOpenFor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deletePending = true;
|
|
||||||
const target = deleteTarget;
|
|
||||||
try {
|
|
||||||
await api.admins.remove(target.id);
|
|
||||||
deleteTarget = null;
|
|
||||||
if (me && me.id === target.id) {
|
|
||||||
// Just deleted ourselves — sign out and bounce.
|
|
||||||
await api.auth.logout();
|
|
||||||
await goto(`${base}/login`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
flash('info', `Deleted "${target.username}".`);
|
|
||||||
} catch (e) {
|
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
|
||||||
} finally {
|
|
||||||
deletePending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleActions(id: string) {
|
|
||||||
actionsOpenFor = actionsOpenFor === id ? null : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relative(iso: string | null): string {
|
|
||||||
if (!iso) return 'Never';
|
|
||||||
const then = new Date(iso).getTime();
|
|
||||||
const now = Date.now();
|
|
||||||
const sec = Math.round((now - then) / 1000);
|
|
||||||
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
|
||||||
const min = Math.round(sec / 60);
|
|
||||||
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
|
||||||
const hr = Math.round(min / 60);
|
|
||||||
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
|
||||||
const day = Math.round(hr / 24);
|
|
||||||
if (day === 1) return 'Yesterday';
|
|
||||||
if (day < 7) return `${day} days ago`;
|
|
||||||
return new Date(iso).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function absolute(iso: string | null): string {
|
|
||||||
return iso ? new Date(iso).toISOString() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortDate(iso: string): string {
|
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header class="head">
|
|
||||||
<h1>Admin Users</h1>
|
|
||||||
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if banner}
|
|
||||||
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loadError}
|
|
||||||
<div class="error">
|
|
||||||
{loadError}
|
|
||||||
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else if admins.length === 0}
|
|
||||||
<p class="empty">No admin users yet. Add one to get started.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="table">
|
|
||||||
<div class="row head-row">
|
|
||||||
<div>Username</div>
|
|
||||||
<div>Status</div>
|
|
||||||
<div>Created</div>
|
|
||||||
<div>Last login</div>
|
|
||||||
<div class="actions-col"></div>
|
|
||||||
</div>
|
|
||||||
{#each admins as row (row.id)}
|
|
||||||
<div class="row">
|
|
||||||
<div class="username-cell">
|
|
||||||
<span class="name">{row.username}</span>
|
|
||||||
{#if me && me.id === row.id}
|
|
||||||
<span class="you-tag">(you)</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{#if row.is_active}
|
|
||||||
<span class="status status-active">● Active</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status status-inactive">○ Inactive</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>{shortDate(row.created_at)}</div>
|
|
||||||
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
|
||||||
<div class="actions-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="kebab"
|
|
||||||
aria-label="Actions for {row.username}"
|
|
||||||
onclick={() => toggleActions(row.id)}
|
|
||||||
>
|
|
||||||
⋮
|
|
||||||
</button>
|
|
||||||
{#if actionsOpenFor === row.id}
|
|
||||||
<div class="menu">
|
|
||||||
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
|
||||||
<button type="button" onclick={() => toggleActive(row)}>
|
|
||||||
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- New admin modal -->
|
|
||||||
{#if createOpen}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) createOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form class="modal" onsubmit={submitCreate}>
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>New admin user</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="x"
|
|
||||||
aria-label="Close"
|
|
||||||
onclick={() => (createOpen = false)}>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Username</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
bind:value={createForm.username}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>Lowercase letters, digits, . _ -</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={createForm.password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>Minimum 8 characters</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Confirm password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={createForm.confirm}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if createError}
|
|
||||||
<div class="error">{createError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
|
||||||
<button type="submit" class="primary" disabled={createPending}>
|
|
||||||
{createPending ? 'Creating…' : 'Create user'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Change password modal -->
|
|
||||||
{#if passwordTarget}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) passwordTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form class="modal" onsubmit={submitPassword}>
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Change password — {passwordTarget.username}</h2>
|
|
||||||
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>New password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={passwordForm.password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Confirm password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={passwordForm.confirm}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if passwordError}
|
|
||||||
<div class="error">{passwordError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
|
||||||
<button type="submit" class="primary" disabled={passwordPending}>
|
|
||||||
{passwordPending ? 'Updating…' : 'Update'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
|
||||||
{#if deleteTarget}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) deleteTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Delete {deleteTarget.username}?</h2>
|
|
||||||
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if me && me.id === deleteTarget.id}
|
|
||||||
<p>
|
|
||||||
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
|
||||||
and will not be able to sign back in with these credentials.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
|
||||||
This cannot be undone.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
|
||||||
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
|
||||||
{deletePending ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin: 0;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.banner-error {
|
|
||||||
background: #450a0a;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.banner-info {
|
|
||||||
background: #0c2a36;
|
|
||||||
border: 1px solid #155e75;
|
|
||||||
color: #a5f3fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: #64748b;
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0;
|
|
||||||
border: 1px dashed #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: visible;
|
|
||||||
background: #0b1220;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid #1e293b;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-row {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
background: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.you-tag {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.status-active {
|
|
||||||
color: #34d399;
|
|
||||||
}
|
|
||||||
.status-inactive {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kebab {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.kebab:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 12rem;
|
|
||||||
z-index: 10;
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #cbd5e1;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.menu button:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
.menu button.danger {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
.menu button.danger:hover {
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #450a0a;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary {
|
|
||||||
background: #38bdf8;
|
|
||||||
color: #0b1220;
|
|
||||||
border: none;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.primary:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: #94a3b8;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.ghost:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.danger {
|
|
||||||
background: #b91c1c;
|
|
||||||
color: #fef2f2;
|
|
||||||
border: none;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.danger:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(15, 23, 42, 0.7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
min-width: 24rem;
|
|
||||||
max-width: 28rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.x {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.x:hover {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal label small {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal input {
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.modal input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #38bdf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
760
dashboard/src/routes/profile/+page.svelte
Normal file
760
dashboard/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<!--
|
||||||
|
/admin/profile — every authenticated principal lands here for their
|
||||||
|
own identity + API-key management. No role gating: a member can mint
|
||||||
|
keys for the apps they belong to just like an admin can. Users-admin
|
||||||
|
actions live under /admin/users.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
ALL_SCOPES,
|
||||||
|
isInstanceScope,
|
||||||
|
type ApiKeyDto,
|
||||||
|
type App,
|
||||||
|
type MintApiKeyResponse,
|
||||||
|
type Scope
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
|
let keys = $state<ApiKeyDto[]>([]);
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let appBySlug = $derived(new Map(apps.map((a) => [a.id, a])));
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Surface the cross-page "access denied" notice when /users bounces
|
||||||
|
// a member back here. One-shot — clears as soon as the user
|
||||||
|
// navigates away or dismisses.
|
||||||
|
const deniedFromUsers = $derived(page.url.searchParams.get('denied') === 'users');
|
||||||
|
|
||||||
|
let mintOpen = $state(false);
|
||||||
|
let mintForm = $state<{
|
||||||
|
name: string;
|
||||||
|
scopes: Set<Scope>;
|
||||||
|
app_id: string | '';
|
||||||
|
expires_at: string;
|
||||||
|
}>({ name: '', scopes: new Set(), app_id: '', expires_at: '' });
|
||||||
|
let mintPending = $state(false);
|
||||||
|
let mintError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let reveal = $state<MintApiKeyResponse | null>(null);
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
let revokeTarget = $state<ApiKeyDto | null>(null);
|
||||||
|
let revokePending = $state(false);
|
||||||
|
|
||||||
|
const NAME_MAX = 64;
|
||||||
|
const scopeIsInstance = (s: Scope) => isInstanceScope(s);
|
||||||
|
const boundToApp = $derived(mintForm.app_id !== '');
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
mintForm.name.trim().length > 0 &&
|
||||||
|
mintForm.name.trim().length <= NAME_MAX &&
|
||||||
|
mintForm.scopes.size > 0 &&
|
||||||
|
!mintPending
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([refreshKeys(), loadApps()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshKeys() {
|
||||||
|
try {
|
||||||
|
keys = await api.apiKeys.list();
|
||||||
|
loadError = null;
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load API keys';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: the form falls back to "no app options" and the
|
||||||
|
// list shows the bare UUID in the binding column.
|
||||||
|
apps = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMint() {
|
||||||
|
mintForm = { name: '', scopes: new Set(), app_id: '', expires_at: '' };
|
||||||
|
mintError = null;
|
||||||
|
mintOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMint() {
|
||||||
|
mintOpen = false;
|
||||||
|
mintError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScope(s: Scope) {
|
||||||
|
const next = new Set(mintForm.scopes);
|
||||||
|
if (next.has(s)) next.delete(s);
|
||||||
|
else next.add(s);
|
||||||
|
mintForm = { ...mintForm, scopes: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the user binds the key to an app, instance:* scopes are
|
||||||
|
// mutually exclusive — drop them from the selection so submit
|
||||||
|
// doesn't 422.
|
||||||
|
$effect(() => {
|
||||||
|
if (!boundToApp) return;
|
||||||
|
const filtered = new Set<Scope>();
|
||||||
|
let dropped = false;
|
||||||
|
for (const s of mintForm.scopes) {
|
||||||
|
if (scopeIsInstance(s)) dropped = true;
|
||||||
|
else filtered.add(s);
|
||||||
|
}
|
||||||
|
if (dropped) {
|
||||||
|
mintForm = { ...mintForm, scopes: filtered };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitMint(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
mintPending = true;
|
||||||
|
mintError = null;
|
||||||
|
try {
|
||||||
|
const r = await api.apiKeys.mint({
|
||||||
|
name: mintForm.name.trim(),
|
||||||
|
scopes: Array.from(mintForm.scopes),
|
||||||
|
app_id: mintForm.app_id === '' ? null : mintForm.app_id,
|
||||||
|
expires_at: mintForm.expires_at === ''
|
||||||
|
? null
|
||||||
|
: new Date(mintForm.expires_at + 'T23:59:59Z').toISOString()
|
||||||
|
});
|
||||||
|
reveal = r;
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
mintOpen = false;
|
||||||
|
await refreshKeys();
|
||||||
|
} catch (e) {
|
||||||
|
mintError = e instanceof ApiError ? e.message : 'failed to mint API key';
|
||||||
|
} finally {
|
||||||
|
mintPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!reveal) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reveal.raw_token);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
reveal = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRevoke(key: ApiKeyDto) {
|
||||||
|
revokeTarget = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevoke() {
|
||||||
|
if (!revokeTarget) return;
|
||||||
|
revokePending = true;
|
||||||
|
const target = revokeTarget;
|
||||||
|
try {
|
||||||
|
await api.apiKeys.revoke(target.id);
|
||||||
|
revokeTarget = null;
|
||||||
|
keys = keys.filter((k) => k.id !== target.id);
|
||||||
|
flash('info', `Revoked "${target.name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to revoke key');
|
||||||
|
} finally {
|
||||||
|
revokePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appLabel(app_id: string | null): string {
|
||||||
|
if (!app_id) return 'Instance-wide';
|
||||||
|
const a = appBySlug.get(app_id);
|
||||||
|
return a ? a.slug : app_id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const sec = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return shortDate(iso);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if me}
|
||||||
|
<section class="identity">
|
||||||
|
<div class="identity-head">
|
||||||
|
<h1>{me.username}</h1>
|
||||||
|
<RoleChip role={me.instance_role} />
|
||||||
|
</div>
|
||||||
|
<dl class="identity-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{me.email ?? 'No email set'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd class="mono">{me.id}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if deniedFromUsers}
|
||||||
|
<div class="banner banner-info">
|
||||||
|
You don't have access to the Users page. Ask an admin if you need to manage users.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="keys-section">
|
||||||
|
<header class="section-head">
|
||||||
|
<h2>API keys</h2>
|
||||||
|
{#if !mintOpen && !reveal}
|
||||||
|
<button type="button" class="primary" onclick={openMint}>+ Mint API key</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if reveal}
|
||||||
|
<div class="reveal">
|
||||||
|
<h3>Save this token now — it will never be shown again.</h3>
|
||||||
|
<p class="reveal-sub">
|
||||||
|
Paste it into your CLI config or external integration. PiCloud only ever stores a hash; if
|
||||||
|
you lose it, mint a new one.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{reveal.raw_token}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyToken}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've saved this token somewhere safe.</span>
|
||||||
|
</label>
|
||||||
|
<div class="reveal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mintOpen}
|
||||||
|
<form class="mint" onsubmit={submitMint}>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={mintForm.name}
|
||||||
|
maxlength={NAME_MAX}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="e.g. ci-deploy"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>1–{NAME_MAX} chars. Only you see it.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Binding</span>
|
||||||
|
<select bind:value={mintForm.app_id}>
|
||||||
|
<option value="">Instance-wide</option>
|
||||||
|
{#each apps as a (a.id)}
|
||||||
|
<option value={a.id}>{a.slug} ({a.name})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>Pick an app to scope this key, or leave instance-wide.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Expires</span>
|
||||||
|
<input type="date" bind:value={mintForm.expires_at} />
|
||||||
|
<small>Leave blank for no expiry.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="scopes">
|
||||||
|
<legend>Scopes</legend>
|
||||||
|
<div class="scope-grid">
|
||||||
|
{#each ALL_SCOPES as scope (scope)}
|
||||||
|
{@const instanceScope = scopeIsInstance(scope)}
|
||||||
|
{@const disabled = boundToApp && instanceScope}
|
||||||
|
<label
|
||||||
|
class="scope-chip"
|
||||||
|
class:disabled
|
||||||
|
title={disabled ? "Bound keys can't carry instance scopes" : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mintForm.scopes.has(scope)}
|
||||||
|
disabled={disabled || mintPending}
|
||||||
|
onchange={() => toggleScope(scope)}
|
||||||
|
/>
|
||||||
|
<span class="scope-name">{scope}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<small class="scope-hint">
|
||||||
|
{mintForm.scopes.size === 0
|
||||||
|
? 'Pick at least one scope.'
|
||||||
|
: `${mintForm.scopes.size} scope${mintForm.scopes.size === 1 ? '' : 's'} selected.`}
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if mintError}
|
||||||
|
<div class="error">{mintError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="ghost" onclick={cancelMint}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmit}>
|
||||||
|
{mintPending ? 'Minting…' : 'Mint key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refreshKeys}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if keys.length === 0 && !reveal && !mintOpen}
|
||||||
|
<p class="empty">
|
||||||
|
No API keys yet. Mint one to authenticate the CLI or external integrations.
|
||||||
|
</p>
|
||||||
|
{:else if keys.length > 0}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Name</div>
|
||||||
|
<div>Prefix</div>
|
||||||
|
<div>Scopes</div>
|
||||||
|
<div>Binding</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last used</div>
|
||||||
|
<div>Expires</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each keys as key (key.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">{key.name}</div>
|
||||||
|
<div class="mono prefix">pic_{key.prefix}…</div>
|
||||||
|
<div class="scopes-cell">
|
||||||
|
{#each key.scopes as s (s)}
|
||||||
|
<span class="scope-pill">{s}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>{appLabel(key.app_id)}</div>
|
||||||
|
<div>{shortDate(key.created_at)}</div>
|
||||||
|
<div title={key.last_used_at ?? ''}>{relative(key.last_used_at)}</div>
|
||||||
|
<div>{key.expires_at ? shortDate(key.expires_at) : 'Never'}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger-link"
|
||||||
|
onclick={() => openRevoke(key)}
|
||||||
|
aria-label="Revoke {key.name}"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if revokeTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Revoke API key?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
busy={revokePending}
|
||||||
|
busyLabel="Revoking…"
|
||||||
|
onConfirm={confirmRevoke}
|
||||||
|
onCancel={() => (revokeTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Revoking <strong>{revokeTarget.name}</strong> (<code>{revokeTarget.prefix}</code>) takes
|
||||||
|
effect immediately. Any CLI or integration using it will start returning <code>401</code>
|
||||||
|
on the next request.
|
||||||
|
</p>
|
||||||
|
<p class="muted">This can't be undone — mint a new key if you need one again.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.identity-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.identity h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.identity-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.identity-meta div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.identity-meta dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.identity-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.reveal h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.reveal-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reveal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mint {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopes {
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.scopes legend {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
.scope-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scope-chip.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.scope-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.9fr 2fr 1fr 0.8fr 0.8fr 0.8fr 0.7fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.prefix {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.scopes-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-pill {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.danger-link {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.danger-link:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
937
dashboard/src/routes/users/+page.svelte
Normal file
937
dashboard/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
<!--
|
||||||
|
/admin/users — owner + admin only. Members get bounced to /profile
|
||||||
|
with ?denied=users. Replaces the pre-3.5 /admin/admins page; this
|
||||||
|
one knows about roles, email, and the last-owner/last-admin guards.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type AdminDto,
|
||||||
|
type InstanceRole
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||||
|
import { generatePassword } from '$lib/password-gen';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const myRole = $derived(me?.instance_role);
|
||||||
|
const isOwner = $derived(myRole === 'owner');
|
||||||
|
|
||||||
|
// Member guard. The backend already 403s the list call, but
|
||||||
|
// surfacing a friendly redirect avoids the dead-end empty page.
|
||||||
|
$effect(() => {
|
||||||
|
if (me && me.instance_role === 'member') {
|
||||||
|
void goto(`${base}/profile?denied=users`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let admins = $state<AdminDto[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
const filtered = $derived(
|
||||||
|
(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return admins;
|
||||||
|
return admins.filter(
|
||||||
|
(a) =>
|
||||||
|
a.username.toLowerCase().includes(q) ||
|
||||||
|
(a.email ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invite (create) modal --------------------------------------------------
|
||||||
|
let inviteOpen = $state(false);
|
||||||
|
let inviteForm = $state<{ username: string; email: string; instance_role: 'admin' | 'member' }>({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
instance_role: 'admin'
|
||||||
|
});
|
||||||
|
let invitePending = $state(false);
|
||||||
|
let inviteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// One-time password reveal (used by both invite + reset)
|
||||||
|
let revealPassword = $state<string | null>(null);
|
||||||
|
let revealForUsername = $state<string>('');
|
||||||
|
let revealKind = $state<'invite' | 'reset'>('invite');
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
// Edit modal -------------------------------------------------------------
|
||||||
|
let editTarget = $state<AdminDto | null>(null);
|
||||||
|
let editForm = $state<{
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
}>({ username: '', email: '', instance_role: 'admin' });
|
||||||
|
let editPending = $state(false);
|
||||||
|
let editError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Delete modal -----------------------------------------------------------
|
||||||
|
let deleteTarget = $state<AdminDto | null>(null);
|
||||||
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
||||||
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
const inviteUsernameValid = $derived(USERNAME_RE.test(inviteForm.username));
|
||||||
|
const inviteEmailValid = $derived(
|
||||||
|
inviteForm.email.trim() === '' || EMAIL_RE.test(inviteForm.email.trim())
|
||||||
|
);
|
||||||
|
const canInvite = $derived(inviteUsernameValid && inviteEmailValid && !invitePending);
|
||||||
|
|
||||||
|
const editUsernameValid = $derived(USERNAME_RE.test(editForm.username));
|
||||||
|
const editEmailValid = $derived(
|
||||||
|
editForm.email.trim() === '' || EMAIL_RE.test(editForm.email.trim())
|
||||||
|
);
|
||||||
|
const canSubmitEdit = $derived(editUsernameValid && editEmailValid && !editPending);
|
||||||
|
|
||||||
|
// Admin (non-owner) cannot touch owner rows for delete or role demote.
|
||||||
|
function canDelete(row: AdminDto): boolean {
|
||||||
|
if (isOwner) return true;
|
||||||
|
return row.instance_role !== 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRoleOptions = $derived<InstanceRole[]>(
|
||||||
|
isOwner ? ['owner', 'admin', 'member'] : ['admin', 'member']
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
admins = await api.admins.list();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load users';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInvite() {
|
||||||
|
inviteForm = { username: '', email: '', instance_role: 'admin' };
|
||||||
|
inviteError = null;
|
||||||
|
inviteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitInvite(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canInvite) return;
|
||||||
|
invitePending = true;
|
||||||
|
inviteError = null;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
try {
|
||||||
|
const created = await api.admins.create({
|
||||||
|
username: inviteForm.username,
|
||||||
|
password,
|
||||||
|
instance_role: inviteForm.instance_role,
|
||||||
|
email: inviteForm.email.trim() === '' ? null : inviteForm.email.trim()
|
||||||
|
});
|
||||||
|
admins = [...admins, created].sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
inviteOpen = false;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = created.username;
|
||||||
|
revealKind = 'invite';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
inviteError = e instanceof ApiError ? e.message : 'failed to create user';
|
||||||
|
} finally {
|
||||||
|
invitePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: AdminDto) {
|
||||||
|
editTarget = row;
|
||||||
|
editForm = {
|
||||||
|
username: row.username,
|
||||||
|
email: row.email ?? '',
|
||||||
|
instance_role: row.instance_role
|
||||||
|
};
|
||||||
|
editError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editTarget || !canSubmitEdit) return;
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
const patch: {
|
||||||
|
username?: string;
|
||||||
|
email?: string | null;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
} = {};
|
||||||
|
if (editForm.username !== editTarget.username) patch.username = editForm.username;
|
||||||
|
if ((editTarget.email ?? '') !== editForm.email.trim()) {
|
||||||
|
patch.email = editForm.email.trim() === '' ? null : editForm.email.trim();
|
||||||
|
}
|
||||||
|
if (editForm.instance_role !== editTarget.instance_role) {
|
||||||
|
patch.instance_role = editForm.instance_role;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(editTarget.id, patch);
|
||||||
|
admins = admins
|
||||||
|
.map((a) => (a.id === updated.id ? updated : a))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
const name = updated.username;
|
||||||
|
editTarget = null;
|
||||||
|
flash('info', `Updated "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to update user';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
if (!editTarget) return;
|
||||||
|
const target = editTarget;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
try {
|
||||||
|
await api.admins.update(target.id, { password });
|
||||||
|
editTarget = null;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = target.username;
|
||||||
|
revealKind = 'reset';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to reset password';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(row: AdminDto) {
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
flash(
|
||||||
|
'info',
|
||||||
|
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(row: AdminDto) {
|
||||||
|
deleteTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deletePending = true;
|
||||||
|
const target = deleteTarget;
|
||||||
|
try {
|
||||||
|
await api.admins.remove(target.id);
|
||||||
|
deleteTarget = null;
|
||||||
|
if (me && me.id === target.id) {
|
||||||
|
// Self-delete: bail out to login.
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admins = admins.filter((a) => a.id !== target.id);
|
||||||
|
flash('info', `Deleted "${target.username}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to delete user');
|
||||||
|
} finally {
|
||||||
|
deletePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPassword() {
|
||||||
|
if (!revealPassword) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(revealPassword);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
revealPassword = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const sec = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="head-controls">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by username or email…"
|
||||||
|
bind:value={search}
|
||||||
|
class="search"
|
||||||
|
/>
|
||||||
|
<button type="button" class="primary" onclick={openInvite}>+ Invite user</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if admins.length === 0}
|
||||||
|
<p class="empty">No users yet. Invite one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Username</div>
|
||||||
|
<div>Role</div>
|
||||||
|
<div>Email</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last login</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each filtered as row (row.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name">{row.username}</span>
|
||||||
|
{#if me && me.id === row.id}
|
||||||
|
<span class="you-tag">(you)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div><RoleChip role={row.instance_role} size="sm" /></div>
|
||||||
|
<div class="email-cell">{row.email ?? '—'}</div>
|
||||||
|
<div>
|
||||||
|
{#if row.is_active}
|
||||||
|
<span class="status status-active">● Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status status-inactive">○ Inactive</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>{shortDate(row.created_at)}</div>
|
||||||
|
<div title={row.last_login_at ?? ''}>{relative(row.last_login_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<ActionMenu
|
||||||
|
label="User actions for {row.username}"
|
||||||
|
items={[
|
||||||
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
|
{
|
||||||
|
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
|
onClick: () => toggleActive(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
disabled: !canDelete(row),
|
||||||
|
onClick: () => openDelete(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if filtered.length === 0 && admins.length > 0}
|
||||||
|
<div class="row empty-row">No matches for "{search}".</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Invite modal -->
|
||||||
|
{#if inviteOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !invitePending) inviteOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitInvite}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Invite user</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={invitePending}
|
||||||
|
onclick={() => (inviteOpen = false)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="modal-intro">
|
||||||
|
A random password will be generated and shown to you exactly once. PiCloud cannot send
|
||||||
|
email — copy and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>2–32 chars. Lowercase letters, digits, <code>.</code> <code>_</code> <code>-</code>.</small>
|
||||||
|
{#if inviteForm.username && !inviteUsernameValid}
|
||||||
|
<small class="invalid">Doesn't match the allowed pattern.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.email}
|
||||||
|
/>
|
||||||
|
{#if !inviteEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<fieldset class="field">
|
||||||
|
<legend>Role</legend>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="admin" />
|
||||||
|
<span>Admin — can manage users, scripts, and all apps.</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="member" />
|
||||||
|
<span>Member — only sees apps they're added to.</span>
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
Owners can't be created here — promote via Edit after creation.
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
{#if inviteError}
|
||||||
|
<div class="error">{inviteError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (inviteOpen = false)} disabled={invitePending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canInvite}>
|
||||||
|
{invitePending ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
{#if editTarget}
|
||||||
|
{@const target = editTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !editPending) editTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitEdit}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Edit {target.username}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={editPending}
|
||||||
|
onclick={() => (editTarget = null)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if editForm.username && !editUsernameValid}
|
||||||
|
<small class="invalid">2–32 chars, lowercase + digits + . _ - only.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.email}
|
||||||
|
/>
|
||||||
|
{#if !editEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Role</span>
|
||||||
|
<select bind:value={editForm.instance_role}>
|
||||||
|
{#each editRoleOptions as r (r)}
|
||||||
|
<option value={r}>{r}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>
|
||||||
|
{#if target.instance_role === 'owner' && !isOwner}
|
||||||
|
Only owners can change another owner's role.
|
||||||
|
{:else if !isOwner}
|
||||||
|
Admins can grant admin or member; only owners can grant owner.
|
||||||
|
{:else}
|
||||||
|
The last active owner can't be demoted — the request will 422 if that's the case.
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if editError}
|
||||||
|
<div class="error">{editError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button type="button" class="ghost" onclick={resetPassword} disabled={editPending}>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost"
|
||||||
|
onclick={() => (editTarget = null)}
|
||||||
|
disabled={editPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmitEdit}>
|
||||||
|
{editPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password reveal (post-invite or post-reset) -->
|
||||||
|
{#if revealPassword}
|
||||||
|
<div class="modal-backdrop" role="presentation">
|
||||||
|
<div class="modal reveal-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>
|
||||||
|
{revealKind === 'invite' ? 'User created' : 'Password reset'} — {revealForUsername}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="banner banner-warn">
|
||||||
|
Save this password now — it will never be shown again. PiCloud cannot send email yet,
|
||||||
|
so copy it and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{revealPassword}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyPassword}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've shared this with the user.</span>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
{#if deleteTarget}
|
||||||
|
{@const dt = deleteTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete user?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete user"
|
||||||
|
confirmPhrase={dt.username}
|
||||||
|
confirmPhrasePrompt="Type the username to confirm:"
|
||||||
|
busy={deletePending}
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (deleteTarget = null)}
|
||||||
|
>
|
||||||
|
{#if me && me.id === dt.id}
|
||||||
|
<p>
|
||||||
|
You're about to delete <strong>your own</strong> account. You'll be signed out
|
||||||
|
immediately and won't be able to sign back in.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This permanently removes <strong>{dt.username}</strong>, all their sessions, and all
|
||||||
|
their API keys. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="muted">
|
||||||
|
If they're the only remaining owner or active admin the server will reject the request
|
||||||
|
with a 422 — promote/activate someone else first.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.head-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
.search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
.banner-warn {
|
||||||
|
background: #2a1d04;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
color: #fde68a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.7fr 1.5fr 0.9fr 0.8fr 0.9fr 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.empty-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.you-tag {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.email-cell {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.status-active {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.status-inactive {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.reveal-modal {
|
||||||
|
border-color: #ca8a04;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.modal-intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.field legend {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='email'],
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.field small.invalid {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.field small code {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-actions.split {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -126,10 +126,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|
|||||||
|
|
||||||
| | Version |
|
| | Version |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Product | `0.5.1` |
|
| Product | `0.6.0` |
|
||||||
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
|
||||||
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` endpoints, `?app=` filter on script list) |
|
| API | `1` (additive: `Script.app_id`, `Route.app_id`, `ExecutionLog.app_id`, new `/api/v1/admin/apps/*` and `/api/v1/admin/api-keys/*` endpoints, `?app=` filter on script list, `Authorization: Bearer pic_…` credential type, 403 responses on previously-401-only admin endpoints when the caller lacks the required capability) |
|
||||||
| Schema | `5` (matches `migrations/0005_apps.sql`) |
|
| Schema | `6` (matches `migrations/0006_users_authz.sql`) |
|
||||||
| Wire | `1` (reserved; cluster mode not implemented) |
|
| Wire | `1` (reserved; cluster mode not implemented) |
|
||||||
|
|
||||||
Read live from `GET /version` on any running instance.
|
Read live from `GET /version` on any running instance.
|
||||||
|
|||||||
@@ -1022,6 +1022,152 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending
|
||||||
|
|
||||||
|
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`.
|
||||||
|
|
||||||
|
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
|
||||||
|
|
||||||
|
**Why this slot**: same logic as Phase 3b. Adding a `Principal` parameter and a capability check to surfaces that don't exist yet is free; retrofitting them onto live SDK services after v1.1 ships is a refactor of every gate.
|
||||||
|
|
||||||
|
### Principal Model
|
||||||
|
|
||||||
|
One `Principal` value represents a human admin user. Service accounts (CI bots, Rhai scripts calling out) get **schema room** in this phase but no runtime support — `users.kind` style differentiation lands when Phase 4's `users.*` SDK arrives. Until then, every authenticated request resolves to exactly one admin row, whether the credential is a session cookie or a bearer API key.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Principal {
|
||||||
|
pub user_id: UserId, // alias of AdminUserId for the transition
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub scopes: Option<Vec<Scope>>, // None = cookie session (full role authority)
|
||||||
|
// Some = API key (intersect with role)
|
||||||
|
pub app_binding: Option<AppId>, // API key bound to one app; denies other apps
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instance Roles (one per user)
|
||||||
|
|
||||||
|
| Role | Powers |
|
||||||
|
|---|---|
|
||||||
|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||||
|
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
||||||
|
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||||
|
|
||||||
|
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||||
|
|
||||||
|
### App-Scoped Roles (zero-to-many per user × app)
|
||||||
|
|
||||||
|
| Role | Grants |
|
||||||
|
|---|---|
|
||||||
|
| `app_admin` | settings, domain claims, delete app |
|
||||||
|
| `editor` | CRUD on scripts, routes, sandbox config |
|
||||||
|
| `viewer` | read scripts + execution logs |
|
||||||
|
|
||||||
|
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
||||||
|
|
||||||
|
### Auth Methods — Same Principal, Different Extractor
|
||||||
|
|
||||||
|
Two credential types feed the same middleware:
|
||||||
|
|
||||||
|
1. **Session cookie** (Phase 3a, unchanged) — `picloud_session=<token>`. Extracted by header or cookie. SHA-256 lookup against `admin_sessions.token_hash`. Sliding 24h TTL. Produces `Principal { scopes: None, app_binding: None }`.
|
||||||
|
|
||||||
|
2. **Bearer API key** (new) — `Authorization: Bearer pic_<base32(32 random bytes)>`. The `pic_` prefix is the discriminator: present → API key path; absent → session path. The 8 chars immediately after `pic_` are indexed (`api_keys.prefix`); the full body after `pic_` is Argon2id-verified against each candidate's `hash`. Last-used timestamp updates inline.
|
||||||
|
|
||||||
|
Both paths converge on the same `Principal` extension; handlers cannot tell which credential was presented unless they introspect `principal.scopes`.
|
||||||
|
|
||||||
|
### API Key Format & Storage
|
||||||
|
|
||||||
|
- Raw form: `pic_<base32(32 random bytes, no padding)>` — ~56 chars total.
|
||||||
|
- Stored: 8-char prefix + Argon2id PHC hash of the body. Raw value returned **exactly once** in the `POST /api/v1/admin/api-keys` response; never logged, never readable again.
|
||||||
|
- Optional `expires_at`. Lookup queries always filter `expires_at IS NULL OR expires_at > NOW()`.
|
||||||
|
- Optional `app_id` ("bound key") — every `App*(other_app)` capability is denied for this key, regardless of the user's role.
|
||||||
|
|
||||||
|
### Scope Set (intentionally narrow)
|
||||||
|
|
||||||
|
Exactly seven scopes; no further subdivision until a real use case appears:
|
||||||
|
|
||||||
|
`script:read`, `script:write`, `route:write`, `domain:manage`, `log:read`, `app:admin`, `instance:admin`
|
||||||
|
|
||||||
|
Mint-time validation rejects unknown values. Bound keys (`app_id` set) cannot carry `instance:*` scopes — the combination is irreconcilable (a bound credential cannot claim instance-wide authority) and is rejected with 422.
|
||||||
|
|
||||||
|
### Effective Capability — `can(principal, capability)`
|
||||||
|
|
||||||
|
```
|
||||||
|
allow = role_grants(principal.instance_role, capability)
|
||||||
|
∧ (principal.scopes.is_none() ∨ required_scope(capability) ∈ principal.scopes)
|
||||||
|
∧ (principal.app_binding.is_none() ∨ capability.app_id() == principal.app_binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
`role_grants` collapses the three tables (instance role + implicit app grants + explicit `app_members`) into a single yes/no. Each handler calls `state.authz.require(&principal, Capability::AppWrite(script.app_id))` after loading the resource (so the capability binds to the resource's actual `app_id`, not a path param the caller controls).
|
||||||
|
|
||||||
|
### Deactivation Symmetry
|
||||||
|
|
||||||
|
Phase 3a's `set_active(false)` wipes that user's `admin_sessions`. Phase 3.5 extends it to also set `expires_at = NOW()` on every row in `api_keys WHERE user_id = $1` — both credential surfaces become inert at the same moment, no enumeration window.
|
||||||
|
|
||||||
|
### CLI Auth Posture (forward note)
|
||||||
|
|
||||||
|
The eventual `picloud` CLI authenticates by **paste-the-token**, not OAuth: the user runs `picloud login`, the dashboard mints a fresh key (or the user mints one via `POST /api/v1/admin/api-keys`), and the CLI prompts for the raw token. The CLI binary itself is deferred; the dashboard surface and the bearer credential type land here so the CLI is a thin wrapper when it arrives.
|
||||||
|
|
||||||
|
### Schema (Migration 0006)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||||
|
CHECK (instance_role IN ('owner','admin','member')),
|
||||||
|
ADD COLUMN email TEXT UNIQUE,
|
||||||
|
ADD COLUMN mfa_secret TEXT; -- reserved slot, not built
|
||||||
|
|
||||||
|
CREATE TABLE app_members (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin','editor','viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
hash TEXT NOT NULL, -- Argon2id PHC
|
||||||
|
prefix TEXT NOT NULL, -- first 8 chars after `pic_`
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT[] NOT NULL, -- intersected with role at check time
|
||||||
|
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
last_used_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||||
|
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||||
|
|
||||||
|
-- Reserved (not built this phase):
|
||||||
|
-- invites (token, email, instance_role, app_id, app_role, invited_by, expires_at, consumed_at)
|
||||||
|
-- service_accounts (id, name, owning_user_id, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Endpoints (additive — no API major bump)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/admin/api-keys — { name, scopes[], app_id?, expires_at? }
|
||||||
|
→ 201 { …, raw_token } (raw returned exactly once)
|
||||||
|
GET /api/v1/admin/api-keys — list caller's own keys (no raw)
|
||||||
|
DELETE /api/v1/admin/api-keys/{id} — caller's own only
|
||||||
|
```
|
||||||
|
|
||||||
|
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
||||||
|
|
||||||
|
### Out of Scope (Phase 3.5)
|
||||||
|
|
||||||
|
Schema room only, not built:
|
||||||
|
|
||||||
|
- **Invites** — email-based join flow; `invites` table reserved in the migration comment block.
|
||||||
|
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
||||||
|
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
||||||
|
|
||||||
|
Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 12. Development Roadmap
|
## 12. Development Roadmap
|
||||||
|
|
||||||
### Phase 1: MVP ✓ (Shipped)
|
### Phase 1: MVP ✓ (Shipped)
|
||||||
@@ -1048,13 +1194,15 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
|||||||
|
|
||||||
### Phase 3: v1.0.x — Foundations (Current focus)
|
### Phase 3: v1.0.x — Foundations (Current focus)
|
||||||
|
|
||||||
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||||
|
|
||||||
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||||
|
|
||||||
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
|
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
|
||||||
|
|
||||||
**Why both before v1.1**: every v1.1 service (KV, docs, users, etc.) needs an `app_id` scoping key in its schema. Adding it now, with one small migration on existing tables, is cheap. Adding it after those services ship is several migrations on populated data.
|
**3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
|
||||||
|
|
||||||
|
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user