From e5f389b0719eb4b77343b961d5919acc75aa2ed8 Mon Sep 17 00:00:00 2001 From: Trezy Date: Sat, 14 Mar 2026 00:54:36 -0500 Subject: [PATCH] feat: add user permissions --- .../20260314000000_rename_admins_to_users.sql | 9 + ...20260314000001_create_user_permissions.sql | 23 + ...000002_rename_api_keys_add_permissions.sql | 51 ++ ...003_remove_network_lexicon_permissions.sql | 11 + src/admin/admins.rs | 106 ---- src/admin/api_keys.rs | 77 ++- src/admin/auth.rs | 241 ++++++-- src/admin/backfill.rs | 9 +- src/admin/events.rs | 6 +- src/admin/lexicons.rs | 15 +- src/admin/mod.rs | 13 +- src/admin/network_lexicons.rs | 12 +- src/admin/permissions.rs | 153 ++++++ src/admin/records.rs | 13 +- src/admin/script_variables.rs | 12 +- src/admin/stats.rs | 6 +- src/admin/tap_stats.rs | 6 +- src/admin/types.rs | 26 +- src/admin/users.rs | 470 ++++++++++++++++ src/error.rs | 10 + web/package-lock.json | 28 +- web/src/app/(dashboard)/admins/page.tsx | 177 ------ web/src/app/(dashboard)/backfill/page.tsx | 70 +-- .../lexicons/[id]/lexicon-detail.tsx | 30 +- web/src/app/(dashboard)/lexicons/new/page.tsx | 9 + web/src/app/(dashboard)/lexicons/page.tsx | 40 +- web/src/app/(dashboard)/page.tsx | 92 ++-- web/src/app/(dashboard)/records/page.tsx | 66 ++- web/src/app/(dashboard)/settings/page.tsx | 147 ++++- web/src/app/(dashboard)/users/page.tsx | 513 ++++++++++++++++++ web/src/components/app-sidebar.tsx | 17 +- web/src/components/ui/switch.tsx | 2 +- web/src/hooks/use-current-user.ts | 30 + web/src/lib/api.ts | 47 +- web/src/types/api-keys.ts | 2 + web/src/types/{admins.ts => users.ts} | 4 +- 36 files changed, 1949 insertions(+), 594 deletions(-) create mode 100644 migrations/20260314000000_rename_admins_to_users.sql create mode 100644 migrations/20260314000001_create_user_permissions.sql create mode 100644 migrations/20260314000002_rename_api_keys_add_permissions.sql create mode 100644 migrations/20260314000003_remove_network_lexicon_permissions.sql delete mode 100644 src/admin/admins.rs create mode 100644 src/admin/permissions.rs create mode 100644 src/admin/users.rs delete mode 100644 web/src/app/(dashboard)/admins/page.tsx create mode 100644 web/src/app/(dashboard)/users/page.tsx create mode 100644 web/src/hooks/use-current-user.ts rename web/src/types/{admins.ts => users.ts} (51%) diff --git a/migrations/20260314000000_rename_admins_to_users.sql b/migrations/20260314000000_rename_admins_to_users.sql new file mode 100644 index 0000000..71e64d9 --- /dev/null +++ b/migrations/20260314000000_rename_admins_to_users.sql @@ -0,0 +1,9 @@ +-- Rename table +ALTER TABLE admins RENAME TO users; + +-- Add is_super column +ALTER TABLE users ADD COLUMN is_super BOOLEAN NOT NULL DEFAULT FALSE; + +-- Set earliest admin as super user +UPDATE users SET is_super = TRUE +WHERE created_at = (SELECT MIN(created_at) FROM users); diff --git a/migrations/20260314000001_create_user_permissions.sql b/migrations/20260314000001_create_user_permissions.sql new file mode 100644 index 0000000..9450433 --- /dev/null +++ b/migrations/20260314000001_create_user_permissions.sql @@ -0,0 +1,23 @@ +CREATE TABLE user_permissions ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + granted_by UUID REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (user_id, permission) +); + +-- Backfill: grant all 23 permissions to every existing user +INSERT INTO user_permissions (user_id, permission) +SELECT u.id, p.permission +FROM users u +CROSS JOIN (VALUES + ('lexicons:create'), ('lexicons:read'), ('lexicons:delete'), + ('network-lexicons:create'), ('network-lexicons:read'), ('network-lexicons:delete'), + ('records:read'), ('records:delete'), ('records:delete-collection'), + ('script-variables:create'), ('script-variables:read'), ('script-variables:delete'), + ('users:create'), ('users:read'), ('users:update'), ('users:delete'), + ('api-keys:create'), ('api-keys:read'), ('api-keys:delete'), + ('backfill:create'), ('backfill:read'), + ('stats:read'), + ('events:read') +) AS p(permission); diff --git a/migrations/20260314000002_rename_api_keys_add_permissions.sql b/migrations/20260314000002_rename_api_keys_add_permissions.sql new file mode 100644 index 0000000..7e77f86 --- /dev/null +++ b/migrations/20260314000002_rename_api_keys_add_permissions.sql @@ -0,0 +1,51 @@ +-- Rename table +ALTER TABLE admin_api_keys RENAME TO api_keys; + +-- Rename column +ALTER TABLE api_keys RENAME COLUMN admin_id TO user_id; + +-- Drop old FK constraint and recreate pointing to users. +-- After migration 1 renamed `admins` to `users`, the FK's confrelid OID +-- follows the rename, so confrelid = 'users'::regclass matches. +-- We also try dropping by expected name as a safety net. +DO $$ +DECLARE + fk_name TEXT; +BEGIN + SELECT conname INTO fk_name + FROM pg_constraint + WHERE conrelid = 'api_keys'::regclass + AND contype = 'f' + AND confrelid = 'users'::regclass; + + IF fk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE api_keys DROP CONSTRAINT %I', fk_name); + ELSE + -- Fallback: try the default naming convention + BEGIN + ALTER TABLE api_keys DROP CONSTRAINT IF EXISTS admin_api_keys_admin_id_fkey; + EXCEPTION WHEN undefined_object THEN + NULL; + END; + END IF; +END $$; + +ALTER TABLE api_keys + ADD CONSTRAINT api_keys_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +-- Add permissions column +ALTER TABLE api_keys ADD COLUMN permissions TEXT[] NOT NULL DEFAULT '{}'; + +-- Backfill: existing keys get all 23 permissions +UPDATE api_keys SET permissions = ARRAY[ + 'lexicons:create', 'lexicons:read', 'lexicons:delete', + 'network-lexicons:create', 'network-lexicons:read', 'network-lexicons:delete', + 'records:read', 'records:delete', 'records:delete-collection', + 'script-variables:create', 'script-variables:read', 'script-variables:delete', + 'users:create', 'users:read', 'users:update', 'users:delete', + 'api-keys:create', 'api-keys:read', 'api-keys:delete', + 'backfill:create', 'backfill:read', + 'stats:read', + 'events:read' +]; diff --git a/migrations/20260314000003_remove_network_lexicon_permissions.sql b/migrations/20260314000003_remove_network_lexicon_permissions.sql new file mode 100644 index 0000000..25acad0 --- /dev/null +++ b/migrations/20260314000003_remove_network_lexicon_permissions.sql @@ -0,0 +1,11 @@ +-- Remove network-lexicons permissions from all users; the network-lexicons +-- endpoints now reuse the regular lexicons:* permissions. +DELETE FROM user_permissions +WHERE permission IN ('network-lexicons:create', 'network-lexicons:read', 'network-lexicons:delete'); + +-- Remove from api_keys permissions array +UPDATE api_keys SET permissions = array_remove(array_remove(array_remove( + permissions, + 'network-lexicons:create'), + 'network-lexicons:read'), + 'network-lexicons:delete'); diff --git a/src/admin/admins.rs b/src/admin/admins.rs deleted file mode 100644 index 8b9bbf3..0000000 --- a/src/admin/admins.rs +++ /dev/null @@ -1,106 +0,0 @@ -use axum::Json; -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use serde_json::Value; - -use crate::AppState; -use crate::error::AppError; -use crate::event_log::{EventLog, Severity, log_event}; - -use super::auth::AdminAuth; -use super::types::{AdminSummary, CreateAdminBody}; - -/// POST /admin/admins — add a new admin by DID. -pub(super) async fn create_admin( - State(state): State, - auth: AdminAuth, - Json(body): Json, -) -> Result<(StatusCode, Json), AppError> { - let row: (String,) = sqlx::query_as("INSERT INTO admins (did) VALUES ($1) RETURNING id::text") - .bind(&body.did) - .fetch_one(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to create admin: {e}")))?; - - log_event( - &state.db, - EventLog { - event_type: "admin.created".to_string(), - severity: Severity::Info, - actor_did: Some(auth.did.clone()), - subject: Some(body.did.clone()), - detail: serde_json::json!({}), - }, - ) - .await; - - Ok(( - StatusCode::CREATED, - Json(serde_json::json!({ - "id": row.0, - "did": body.did, - })), - )) -} - -/// GET /admin/admins — list all admins. -pub(super) async fn list_admins( - State(state): State, - _admin: AdminAuth, -) -> Result>, AppError> { - #[allow(clippy::type_complexity)] - let rows: Vec<( - String, - String, - chrono::DateTime, - Option>, - )> = sqlx::query_as( - "SELECT id::text, did, created_at, last_used_at FROM admins ORDER BY created_at", - ) - .fetch_all(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to list admins: {e}")))?; - - let admins: Vec = rows - .into_iter() - .map(|(id, did, created_at, last_used_at)| AdminSummary { - id, - did, - created_at, - last_used_at, - }) - .collect(); - - Ok(Json(admins)) -} - -/// DELETE /admin/admins/:id — remove an admin. -pub(super) async fn delete_admin( - State(state): State, - auth: AdminAuth, - Path(id): Path, -) -> Result { - let result = sqlx::query("DELETE FROM admins WHERE id::text = $1") - .bind(&id) - .execute(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to delete admin: {e}")))?; - - if result.rows_affected() == 0 { - return Err(AppError::NotFound(format!("admin '{id}' not found"))); - } - - log_event( - &state.db, - EventLog { - event_type: "admin.deleted".to_string(), - severity: Severity::Info, - actor_did: Some(auth.did.clone()), - subject: Some(id.to_string()), - detail: serde_json::json!({}), - }, - ) - .await; - - Ok(StatusCode::NO_CONTENT) -} diff --git a/src/admin/api_keys.rs b/src/admin/api_keys.rs index 2a6ef81..5150477 100644 --- a/src/admin/api_keys.rs +++ b/src/admin/api_keys.rs @@ -9,22 +9,33 @@ use crate::AppState; use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use super::types::{ApiKeySummary, CreateApiKeyBody, CreateApiKeyResponse}; /// POST /admin/api-keys — create a new API key for the authenticated admin. pub(super) async fn create_api_key( State(state): State, - auth: AdminAuth, + auth: UserAuth, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { - // Look up the admin's UUID from their DID. - let admin_row: (String,) = sqlx::query_as("SELECT id::text FROM admins WHERE did = $1") - .bind(&auth.did) - .fetch_one(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to find admin: {e}")))?; - let admin_id = admin_row.0; + auth.require(Permission::ApiKeysCreate).await?; + + // Validate requested permissions are a subset of the user's permissions + if !auth.is_super { + for perm_str in &body.permissions { + #[allow(clippy::collapsible_if)] + if let Ok(p) = + serde_json::from_value::(serde_json::Value::String(perm_str.clone())) + { + if !auth.permissions.contains(&p) { + return Err(AppError::Forbidden(format!( + "Cannot grant API key permission you don't have: {perm_str}" + ))); + } + } + } + } // Generate the raw key: "hv_" + 32 random hex chars. let mut random_bytes = [0u8; 16]; @@ -38,14 +49,15 @@ pub(super) async fn create_api_key( let key_prefix = raw_key[..11].to_string(); // "hv_" + 8 hex chars let row: (String,) = sqlx::query_as( - "INSERT INTO admin_api_keys (admin_id, name, key_hash, key_prefix) - VALUES ($1::uuid, $2, $3, $4) + "INSERT INTO api_keys (user_id, name, key_hash, key_prefix, permissions) + VALUES ($1::uuid, $2, $3, $4, $5) RETURNING id::text", ) - .bind(&admin_id) + .bind(&auth.user_id) .bind(&body.name) .bind(&hash) .bind(&key_prefix) + .bind(&body.permissions) .fetch_one(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to create api key: {e}")))?; @@ -57,7 +69,7 @@ pub(super) async fn create_api_key( severity: Severity::Info, actor_did: Some(auth.did.clone()), subject: Some(body.name.clone()), - detail: serde_json::json!({ "key_prefix": key_prefix }), + detail: serde_json::json!({ "key_prefix": key_prefix, "permissions": &body.permissions }), }, ) .await; @@ -69,6 +81,7 @@ pub(super) async fn create_api_key( name: body.name, key: raw_key, key_prefix, + permissions: body.permissions, }), )) } @@ -76,21 +89,24 @@ pub(super) async fn create_api_key( /// GET /admin/api-keys — list API keys for the authenticated admin. pub(super) async fn list_api_keys( State(state): State, - auth: AdminAuth, + auth: UserAuth, ) -> Result>, AppError> { + auth.require(Permission::ApiKeysRead).await?; + #[allow(clippy::type_complexity)] let rows: Vec<( String, String, String, + Vec, chrono::DateTime, Option>, Option>, )> = sqlx::query_as( - "SELECT k.id::text, k.name, k.key_prefix, k.created_at, k.last_used_at, k.revoked_at - FROM admin_api_keys k - JOIN admins a ON a.id = k.admin_id - WHERE a.did = $1 + "SELECT k.id::text, k.name, k.key_prefix, k.permissions, k.created_at, k.last_used_at, k.revoked_at + FROM api_keys k + JOIN users u ON u.id = k.user_id + WHERE u.did = $1 ORDER BY k.created_at DESC", ) .bind(&auth.did) @@ -101,13 +117,16 @@ pub(super) async fn list_api_keys( let keys: Vec = rows .into_iter() .map( - |(id, name, key_prefix, created_at, last_used_at, revoked_at)| ApiKeySummary { - id, - name, - key_prefix, - created_at, - last_used_at, - revoked_at, + |(id, name, key_prefix, permissions, created_at, last_used_at, revoked_at)| { + ApiKeySummary { + id, + name, + key_prefix, + permissions, + created_at, + last_used_at, + revoked_at, + } }, ) .collect(); @@ -118,13 +137,15 @@ pub(super) async fn list_api_keys( /// DELETE /admin/api-keys/:id — revoke an API key (soft delete). pub(super) async fn revoke_api_key( State(state): State, - auth: AdminAuth, + auth: UserAuth, Path(id): Path, ) -> Result { + auth.require(Permission::ApiKeysDelete).await?; + let result = sqlx::query( - "UPDATE admin_api_keys SET revoked_at = NOW() + "UPDATE api_keys SET revoked_at = NOW() WHERE id::text = $1 - AND admin_id = (SELECT id FROM admins WHERE did = $2) + AND user_id = (SELECT id FROM users WHERE did = $2) AND revoked_at IS NULL", ) .bind(&id) diff --git a/src/admin/auth.rs b/src/admin/auth.rs index 6ade4f9..efd23b6 100644 --- a/src/admin/auth.rs +++ b/src/admin/auth.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use axum::extract::FromRequestParts; use axum::http::request::Parts; use sha2::{Digest, Sha256}; @@ -7,91 +9,202 @@ use crate::auth::middleware::Claims; use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; -/// Axum extractor for admin auth. Validates the Bearer token via AIP OAuth -/// (same as `Claims`), then checks if the returned DID exists in the `admins` -/// table. If no admins exist yet, the first authenticated user is -/// auto-bootstrapped as the initial admin. -/// -/// Also supports `hv_`-prefixed API keys: the token is SHA-256 hashed and -/// looked up in `admin_api_keys`. If found, the admin's DID is returned. -pub struct AdminAuth { +use super::permissions::Permission; + +pub struct UserAuth { pub did: String, + pub user_id: String, + pub is_super: bool, + pub permissions: HashSet, + pub db: sqlx::PgPool, +} + +impl UserAuth { + pub async fn require(&self, permission: Permission) -> Result<(), AppError> { + if self.is_super || self.permissions.contains(&permission) { + Ok(()) + } else { + log_event( + &self.db, + EventLog { + event_type: "auth.permission_denied".to_string(), + severity: Severity::Warn, + actor_did: Some(self.did.clone()), + subject: Some(permission.as_str().to_string()), + detail: serde_json::json!({ + "user_id": self.user_id, + "required_permission": permission.as_str(), + }), + }, + ) + .await; + + Err(AppError::InsufficientPermissions( + permission.as_str().to_string(), + )) + } + } + + async fn load_permissions( + db: &sqlx::PgPool, + user_id: &str, + ) -> Result, AppError> { + let rows: Vec<(String,)> = + sqlx::query_as("SELECT permission FROM user_permissions WHERE user_id = $1::uuid") + .bind(user_id) + .fetch_all(db) + .await + .map_err(|e| AppError::Internal(format!("permission query failed: {e}")))?; + + let mut perms = HashSet::new(); + for (perm_str,) in rows { + if let Ok(p) = serde_json::from_value::(serde_json::Value::String(perm_str)) + { + perms.insert(p); + } + } + Ok(perms) + } + + async fn load_api_key_permissions( + db: &sqlx::PgPool, + user_id: &str, + key_permissions: &[String], + ) -> Result, AppError> { + let user_perms = Self::load_permissions(db, user_id).await?; + let mut effective = HashSet::new(); + for perm_str in key_permissions { + #[allow(clippy::collapsible_if)] + if let Ok(p) = + serde_json::from_value::(serde_json::Value::String(perm_str.clone())) + { + if user_perms.contains(&p) { + effective.insert(p); + } + } + } + Ok(effective) + } } -impl FromRequestParts for AdminAuth { +impl FromRequestParts for UserAuth { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { - // Check for API key auth first (Bearer hv_...). if let Some(auth) = Self::try_api_key_auth(parts, state).await? { return Ok(auth); } - // Validate the Bearer token via AIP userinfo (reuse Claims extractor). let claims = Claims::from_request_parts(parts, state).await?; let did = claims.did().to_string(); - // Check whether the admins table is empty (auto-bootstrap case). - let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM admins") + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&state.db) .await - .map_err(|e| AppError::Internal(format!("admin count query failed: {e}")))?; + .map_err(|e| AppError::Internal(format!("user count query failed: {e}")))?; if count.0 == 0 { - // First authenticated user becomes the initial admin. - sqlx::query("INSERT INTO admins (did) VALUES ($1) ON CONFLICT DO NOTHING") - .bind(&did) - .execute(&state.db) + let mut tx = state + .db + .begin() .await - .map_err(|e| AppError::Internal(format!("auto-bootstrap admin failed: {e}")))?; + .map_err(|e| AppError::Internal(format!("transaction start failed: {e}")))?; - tracing::info!(did = %did, "auto-bootstrapped first admin"); + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("set isolation failed: {e}")))?; - log_event( - &state.db, - EventLog { - event_type: "admin.bootstrapped".to_string(), - severity: Severity::Info, - actor_did: None, - subject: Some(did.clone()), - detail: serde_json::json!({}), - }, + let row: Option<(String,)> = sqlx::query_as( + "INSERT INTO users (did, is_super) VALUES ($1, TRUE) + ON CONFLICT (did) DO NOTHING + RETURNING id::text", ) - .await; - } - - // Look up the DID in the admins table. - let found: Option<(String,)> = sqlx::query_as("SELECT id::text FROM admins WHERE did = $1") .bind(&did) - .fetch_optional(&state.db) + .fetch_optional(&mut *tx) .await - .map_err(|e| AppError::Internal(format!("admin auth query failed: {e}")))?; + .map_err(|e| AppError::Internal(format!("auto-bootstrap user failed: {e}")))?; - let Some((admin_id,)) = found else { - return Err(AppError::Forbidden("not an admin".into())); + if let Some((user_id,)) = row { + for perm in Permission::all() { + sqlx::query( + "INSERT INTO user_permissions (user_id, permission) + VALUES ($1::uuid, $2) + ON CONFLICT DO NOTHING", + ) + .bind(&user_id) + .bind(perm.as_str()) + .execute(&mut *tx) + .await + .map_err(|e| { + AppError::Internal(format!("bootstrap permissions failed: {e}")) + })?; + } + + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("transaction commit failed: {e}")))?; + + tracing::info!(did = %did, "auto-bootstrapped first super user"); + + log_event( + &state.db, + EventLog { + event_type: "user.bootstrapped".to_string(), + severity: Severity::Info, + actor_did: None, + subject: Some(did.clone()), + detail: serde_json::json!({}), + }, + ) + .await; + } else { + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("transaction commit failed: {e}")))?; + } + } + + let found: Option<(String, bool)> = + sqlx::query_as("SELECT id::text, is_super FROM users WHERE did = $1") + .bind(&did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("user auth query failed: {e}")))?; + + let Some((user_id, is_super)) = found else { + return Err(AppError::Forbidden("not a user".into())); + }; + + let permissions = if is_super { + HashSet::new() + } else { + Self::load_permissions(&state.db, &user_id).await? }; - // Update last_used_at in the background. let db = state.db.clone(); + let uid = user_id.clone(); tokio::spawn(async move { - let _ = sqlx::query("UPDATE admins SET last_used_at = NOW() WHERE id::text = $1") - .bind(&admin_id) + let _ = sqlx::query("UPDATE users SET last_used_at = NOW() WHERE id::text = $1") + .bind(&uid) .execute(&db) .await; }); - Ok(AdminAuth { did }) + Ok(UserAuth { + did, + user_id, + is_super, + permissions, + db: state.db.clone(), + }) } } -impl AdminAuth { - /// If the Authorization header contains a `hv_`-prefixed API key, validate - /// it against the `admin_api_keys` table and return the owning admin's DID. - /// Returns `Ok(None)` if the token doesn't start with `hv_`, allowing - /// fallthrough to the normal OAuth flow. +impl UserAuth { async fn try_api_key_auth(parts: &Parts, state: &AppState) -> Result, AppError> { let header = match parts .headers @@ -113,10 +226,10 @@ impl AdminAuth { let hash = hex::encode(Sha256::digest(token.as_bytes())); - let row: Option<(String, String)> = sqlx::query_as( - "SELECT k.id::text, a.did - FROM admin_api_keys k - JOIN admins a ON a.id = k.admin_id + let row: Option<(String, String, String, bool, Vec)> = sqlx::query_as( + "SELECT k.id::text, u.id::text, u.did, u.is_super, k.permissions + FROM api_keys k + JOIN users u ON u.id = k.user_id WHERE k.key_hash = $1 AND k.revoked_at IS NULL", ) .bind(&hash) @@ -124,20 +237,30 @@ impl AdminAuth { .await .map_err(|e| AppError::Internal(format!("api key lookup failed: {e}")))?; - let Some((key_id, did)) = row else { + let Some((key_id, user_id, did, is_super, key_permissions)) = row else { return Err(AppError::Auth("invalid or revoked API key".into())); }; - // Update last_used_at in the background. + let permissions = if is_super { + HashSet::new() + } else { + Self::load_api_key_permissions(&state.db, &user_id, &key_permissions).await? + }; + let db = state.db.clone(); tokio::spawn(async move { - let _ = - sqlx::query("UPDATE admin_api_keys SET last_used_at = NOW() WHERE id::text = $1") - .bind(&key_id) - .execute(&db) - .await; + let _ = sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id::text = $1") + .bind(&key_id) + .execute(&db) + .await; }); - Ok(Some(AdminAuth { did })) + Ok(Some(UserAuth { + did, + user_id, + is_super, + permissions, + db: state.db.clone(), + })) } } diff --git a/src/admin/backfill.rs b/src/admin/backfill.rs index 7d1768c..80b7b57 100644 --- a/src/admin/backfill.rs +++ b/src/admin/backfill.rs @@ -9,7 +9,8 @@ use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; use crate::tap; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use super::types::{BackfillJob, CreateBackfillBody}; // --------------------------------------------------------------------------- @@ -82,9 +83,10 @@ async fn list_repos_by_collection( /// POST /admin/backfill — create a backfill job, discover repos, and add them to Tap. pub(super) async fn create_backfill( State(state): State, - admin: AdminAuth, + admin: UserAuth, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + admin.require(Permission::BackfillCreate).await?; // Create a backfill_jobs record for tracking/audit. let row: (String,) = sqlx::query_as( "INSERT INTO backfill_jobs (collection, did) VALUES ($1, $2) RETURNING id::text", @@ -306,8 +308,9 @@ pub(super) async fn create_backfill( /// GET /admin/backfill/status — list all backfill jobs. pub(super) async fn backfill_status( State(state): State, - _admin: AdminAuth, + auth: UserAuth, ) -> Result>, AppError> { + auth.require(Permission::BackfillRead).await?; #[allow(clippy::type_complexity)] let rows: Vec<( String, diff --git a/src/admin/events.rs b/src/admin/events.rs index 33f3008..9da8d70 100644 --- a/src/admin/events.rs +++ b/src/admin/events.rs @@ -5,7 +5,8 @@ use axum::{ use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use crate::AppState; use crate::error::AppError; @@ -39,10 +40,11 @@ pub struct EventsListResponse { /// GET /admin/events — list event logs with optional filters and pagination. pub(super) async fn list_events( - _auth: AdminAuth, + auth: UserAuth, State(state): State, Query(query): Query, ) -> Result, AppError> { + auth.require(Permission::EventsRead).await?; let limit = query.limit.unwrap_or(50).clamp(1, 100); let mut sql = String::from( diff --git a/src/admin/lexicons.rs b/src/admin/lexicons.rs index 9c926aa..6aea853 100644 --- a/src/admin/lexicons.rs +++ b/src/admin/lexicons.rs @@ -8,7 +8,8 @@ use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; use crate::lexicon::{LexiconType, ParsedLexicon, ProcedureAction}; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use super::types::{LexiconSummary, UploadLexiconBody}; /// Send the current record collection list to the Tap task so it @@ -21,9 +22,10 @@ async fn notify_collections(state: &AppState) { /// POST /admin/lexicons — upload (upsert) a lexicon. pub(super) async fn upload_lexicon( State(state): State, - auth: AdminAuth, + auth: UserAuth, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + auth.require(Permission::LexiconsCreate).await?; // Validate basic structure let lexicon_version = body .lexicon_json @@ -162,8 +164,9 @@ pub(super) async fn upload_lexicon( /// GET /admin/lexicons — list all lexicons. pub(super) async fn list_lexicons( State(state): State, - _admin: AdminAuth, + auth: UserAuth, ) -> Result>, AppError> { + auth.require(Permission::LexiconsRead).await?; #[allow(clippy::type_complexity)] let rows: Vec<(String, i32, Value, bool, Option, Option, Option, Option, String, Option, Option>, chrono::DateTime, chrono::DateTime)> = sqlx::query_as( @@ -228,9 +231,10 @@ pub(super) async fn list_lexicons( /// GET /admin/lexicons/:id — get a single lexicon. pub(super) async fn get_lexicon( State(state): State, - _admin: AdminAuth, + auth: UserAuth, Path(id): Path, ) -> Result, AppError> { + auth.require(Permission::LexiconsRead).await?; #[allow(clippy::type_complexity)] let row: Option<(String, i32, Value, bool, Option, Option, Option, Option, String, Option, Option>, chrono::DateTime, chrono::DateTime)> = sqlx::query_as( @@ -293,9 +297,10 @@ pub(super) async fn get_lexicon( /// DELETE /admin/lexicons/:id — remove a lexicon. pub(super) async fn delete_lexicon( State(state): State, - auth: AdminAuth, + auth: UserAuth, Path(id): Path, ) -> Result { + auth.require(Permission::LexiconsDelete).await?; let result = sqlx::query("DELETE FROM lexicons WHERE id = $1") .bind(&id) .execute(&state.db) diff --git a/src/admin/mod.rs b/src/admin/mod.rs index f1ac093..34dd5dc 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,18 +1,19 @@ -mod admins; mod api_keys; pub(crate) mod auth; mod backfill; mod events; mod lexicons; mod network_lexicons; +pub(crate) mod permissions; mod records; mod script_variables; mod stats; mod tap_stats; mod types; +mod users; use axum::Router; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, patch, post}; use crate::AppState; @@ -30,11 +31,13 @@ pub fn admin_routes(_state: AppState) -> Router { .route("/backfill", post(backfill::create_backfill)) .route("/backfill/status", get(backfill::backfill_status)) .route("/events", get(events::list_events)) + .route("/users", post(users::create_user).get(users::list_users)) + .route("/users/transfer-super", post(users::transfer_super)) .route( - "/admins", - post(admins::create_admin).get(admins::list_admins), + "/users/{id}", + get(users::get_user).delete(users::delete_user), ) - .route("/admins/{id}", delete(admins::delete_admin)) + .route("/users/{id}/permissions", patch(users::update_permissions)) .route( "/api-keys", post(api_keys::create_api_key).get(api_keys::list_api_keys), diff --git a/src/admin/network_lexicons.rs b/src/admin/network_lexicons.rs index 2cf6d77..7bebe4a 100644 --- a/src/admin/network_lexicons.rs +++ b/src/admin/network_lexicons.rs @@ -8,7 +8,8 @@ use crate::error::AppError; use crate::lexicon::{LexiconType, ParsedLexicon, ProcedureAction}; use crate::resolve::{fetch_lexicon_from_pds, resolve_nsid_authority}; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use super::types::{AddNetworkLexiconBody, NetworkLexiconSummary}; /// Send the current record collection list to the Tap task so it @@ -21,9 +22,10 @@ async fn notify_collections(state: &AppState) { /// POST /admin/network-lexicons — add a network lexicon to watch. pub(super) async fn add( State(state): State, - _admin: AdminAuth, + auth: UserAuth, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + auth.require(Permission::LexiconsCreate).await?; let nsid = &body.nsid; // Resolve NSID authority via DNS TXT lookup. @@ -101,8 +103,9 @@ pub(super) async fn add( /// GET /admin/network-lexicons — list tracked network lexicons. pub(super) async fn list( State(state): State, - _admin: AdminAuth, + auth: UserAuth, ) -> Result>, AppError> { + auth.require(Permission::LexiconsRead).await?; #[allow(clippy::type_complexity)] let rows: Vec<(String, Option, Option, Option>, chrono::DateTime)> = sqlx::query_as( @@ -133,9 +136,10 @@ pub(super) async fn list( /// DELETE /admin/network-lexicons/{nsid} — stop watching a network lexicon. pub(super) async fn remove( State(state): State, - _admin: AdminAuth, + auth: UserAuth, Path(nsid): Path, ) -> Result { + auth.require(Permission::LexiconsDelete).await?; let result = sqlx::query("DELETE FROM lexicons WHERE id = $1 AND source = 'network'") .bind(&nsid) .execute(&state.db) diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs new file mode 100644 index 0000000..cb09a57 --- /dev/null +++ b/src/admin/permissions.rs @@ -0,0 +1,153 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +/// All 20 permissions in the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Permission { + #[serde(rename = "lexicons:create")] + LexiconsCreate, + #[serde(rename = "lexicons:read")] + LexiconsRead, + #[serde(rename = "lexicons:delete")] + LexiconsDelete, + + #[serde(rename = "records:read")] + RecordsRead, + #[serde(rename = "records:delete")] + RecordsDelete, + #[serde(rename = "records:delete-collection")] + RecordsDeleteCollection, + + #[serde(rename = "script-variables:create")] + ScriptVariablesCreate, + #[serde(rename = "script-variables:read")] + ScriptVariablesRead, + #[serde(rename = "script-variables:delete")] + ScriptVariablesDelete, + + #[serde(rename = "users:create")] + UsersCreate, + #[serde(rename = "users:read")] + UsersRead, + #[serde(rename = "users:update")] + UsersUpdate, + #[serde(rename = "users:delete")] + UsersDelete, + + #[serde(rename = "api-keys:create")] + ApiKeysCreate, + #[serde(rename = "api-keys:read")] + ApiKeysRead, + #[serde(rename = "api-keys:delete")] + ApiKeysDelete, + + #[serde(rename = "backfill:create")] + BackfillCreate, + #[serde(rename = "backfill:read")] + BackfillRead, + + #[serde(rename = "stats:read")] + StatsRead, + + #[serde(rename = "events:read")] + EventsRead, +} + +impl Permission { + /// String representation matching the DB values. + pub fn as_str(&self) -> &'static str { + match self { + Self::LexiconsCreate => "lexicons:create", + Self::LexiconsRead => "lexicons:read", + Self::LexiconsDelete => "lexicons:delete", + Self::RecordsRead => "records:read", + Self::RecordsDelete => "records:delete", + Self::RecordsDeleteCollection => "records:delete-collection", + Self::ScriptVariablesCreate => "script-variables:create", + Self::ScriptVariablesRead => "script-variables:read", + Self::ScriptVariablesDelete => "script-variables:delete", + Self::UsersCreate => "users:create", + Self::UsersRead => "users:read", + Self::UsersUpdate => "users:update", + Self::UsersDelete => "users:delete", + Self::ApiKeysCreate => "api-keys:create", + Self::ApiKeysRead => "api-keys:read", + Self::ApiKeysDelete => "api-keys:delete", + Self::BackfillCreate => "backfill:create", + Self::BackfillRead => "backfill:read", + Self::StatsRead => "stats:read", + Self::EventsRead => "events:read", + } + } + + /// All 20 permissions. + pub fn all() -> HashSet { + HashSet::from([ + Self::LexiconsCreate, + Self::LexiconsRead, + Self::LexiconsDelete, + Self::RecordsRead, + Self::RecordsDelete, + Self::RecordsDeleteCollection, + Self::ScriptVariablesCreate, + Self::ScriptVariablesRead, + Self::ScriptVariablesDelete, + Self::UsersCreate, + Self::UsersRead, + Self::UsersUpdate, + Self::UsersDelete, + Self::ApiKeysCreate, + Self::ApiKeysRead, + Self::ApiKeysDelete, + Self::BackfillCreate, + Self::BackfillRead, + Self::StatsRead, + Self::EventsRead, + ]) + } +} + +/// Predefined permission templates. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Template { + Viewer, + Operator, + Manager, + FullAccess, +} + +impl Template { + pub fn permissions(&self) -> HashSet { + match self { + Self::Viewer => HashSet::from([ + Permission::LexiconsRead, + Permission::RecordsRead, + Permission::ScriptVariablesRead, + Permission::UsersRead, + Permission::ApiKeysRead, + Permission::BackfillRead, + Permission::StatsRead, + Permission::EventsRead, + ]), + Self::Operator => { + let mut perms = Self::Viewer.permissions(); + perms.insert(Permission::BackfillCreate); + perms.insert(Permission::ApiKeysCreate); + perms.insert(Permission::ApiKeysDelete); + perms + } + Self::Manager => { + let mut perms = Self::Operator.permissions(); + perms.insert(Permission::LexiconsCreate); + perms.insert(Permission::LexiconsDelete); + perms.insert(Permission::ScriptVariablesCreate); + perms.insert(Permission::ScriptVariablesDelete); + perms.insert(Permission::RecordsDelete); + perms + } + Self::FullAccess => Permission::all(), + } + } +} diff --git a/src/admin/records.rs b/src/admin/records.rs index 9af4768..d245b9e 100644 --- a/src/admin/records.rs +++ b/src/admin/records.rs @@ -7,7 +7,8 @@ use serde_json::Value; use crate::AppState; use crate::error::AppError; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; #[derive(Deserialize)] pub(super) struct ListRecordsParams { @@ -38,9 +39,10 @@ pub(super) struct ListRecordsResponse { /// GET /admin/records?collection=X&limit=N&cursor=C — browse records by collection. pub(super) async fn list_records( State(state): State, - _admin: AdminAuth, + auth: UserAuth, Query(params): Query, ) -> Result, AppError> { + auth.require(Permission::RecordsRead).await?; let limit = params.limit.unwrap_or(20).min(100); let offset: i64 = params .cursor @@ -82,9 +84,11 @@ pub(super) struct DeleteCollectionParams { /// DELETE /admin/records/collection?collection=X — delete all records in a collection. pub(super) async fn delete_collection_records( State(state): State, - _admin: AdminAuth, + auth: UserAuth, Query(params): Query, ) -> Result, AppError> { + auth.require(Permission::RecordsDeleteCollection).await?; + auth.require(Permission::RecordsDelete).await?; let result = sqlx::query("DELETE FROM records WHERE collection = $1") .bind(¶ms.collection) .execute(&state.db) @@ -99,9 +103,10 @@ pub(super) async fn delete_collection_records( /// DELETE /admin/records?uri=at://... — delete a single record by URI. pub(super) async fn delete_record( State(state): State, - _admin: AdminAuth, + auth: UserAuth, Query(params): Query, ) -> Result { + auth.require(Permission::RecordsDelete).await?; let result = sqlx::query("DELETE FROM records WHERE uri = $1") .bind(¶ms.uri) .execute(&state.db) diff --git a/src/admin/script_variables.rs b/src/admin/script_variables.rs index 17685a3..b94d2df 100644 --- a/src/admin/script_variables.rs +++ b/src/admin/script_variables.rs @@ -6,14 +6,16 @@ use crate::AppState; use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use super::types::{ScriptVariableSummary, UpsertScriptVariableBody}; /// GET /admin/script-variables — list all variables with masked preview. pub(super) async fn list( State(state): State, - _admin: AdminAuth, + auth: UserAuth, ) -> Result>, AppError> { + auth.require(Permission::ScriptVariablesRead).await?; let rows: Vec<( String, String, @@ -45,9 +47,10 @@ pub(super) async fn list( /// POST /admin/script-variables — create or update a variable. pub(super) async fn upsert( State(state): State, - auth: AdminAuth, + auth: UserAuth, Json(body): Json, ) -> Result { + auth.require(Permission::ScriptVariablesCreate).await?; sqlx::query( r#" INSERT INTO script_variables (key, value) @@ -79,9 +82,10 @@ pub(super) async fn upsert( /// DELETE /admin/script-variables/{key} — delete a variable. pub(super) async fn delete( State(state): State, - auth: AdminAuth, + auth: UserAuth, Path(key): Path, ) -> Result { + auth.require(Permission::ScriptVariablesDelete).await?; let result = sqlx::query("DELETE FROM script_variables WHERE key = $1") .bind(&key) .execute(&state.db) diff --git a/src/admin/stats.rs b/src/admin/stats.rs index 7320483..f75d790 100644 --- a/src/admin/stats.rs +++ b/src/admin/stats.rs @@ -4,14 +4,16 @@ use axum::extract::State; use crate::AppState; use crate::error::AppError; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; use super::types::{CollectionStat, StatsResponse}; /// GET /admin/stats — system statistics. pub(super) async fn stats( State(state): State, - _admin: AdminAuth, + auth: UserAuth, ) -> Result, AppError> { + auth.require(Permission::StatsRead).await?; let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM records") .fetch_one(&state.db) .await diff --git a/src/admin/tap_stats.rs b/src/admin/tap_stats.rs index 7afcb20..48f12ab 100644 --- a/src/admin/tap_stats.rs +++ b/src/admin/tap_stats.rs @@ -5,13 +5,15 @@ use crate::AppState; use crate::error::AppError; use crate::tap; -use super::auth::AdminAuth; +use super::auth::UserAuth; +use super::permissions::Permission; /// GET /admin/tap/stats — aggregate stats from Tap. pub(super) async fn tap_stats( State(state): State, - _admin: AdminAuth, + auth: UserAuth, ) -> Result, AppError> { + auth.require(Permission::StatsRead).await?; let stats = tap::get_stats( &state.http, &state.config.tap_url, diff --git a/src/admin/types.rs b/src/admin/types.rs index a9c2256..87b1a33 100644 --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -101,22 +101,39 @@ pub(super) struct NetworkLexiconSummary { } // --------------------------------------------------------------------------- -// Admin management types +// User management types // --------------------------------------------------------------------------- #[derive(Deserialize)] -pub(super) struct CreateAdminBody { +pub(super) struct CreateUserBody { pub(super) did: String, + pub(super) template: Option, + pub(super) permissions: Option>, } #[derive(Serialize)] -pub(super) struct AdminSummary { +pub(super) struct UserSummary { pub(super) id: String, pub(super) did: String, + pub(super) is_super: bool, + pub(super) permissions: Vec, pub(super) created_at: chrono::DateTime, pub(super) last_used_at: Option>, } +#[derive(Deserialize)] +pub(super) struct UpdatePermissionsBody { + #[serde(default)] + pub(super) grant: Vec, + #[serde(default)] + pub(super) revoke: Vec, +} + +#[derive(Deserialize)] +pub(super) struct TransferSuperBody { + pub(super) target_user_id: String, +} + // --------------------------------------------------------------------------- // API key types // --------------------------------------------------------------------------- @@ -124,6 +141,7 @@ pub(super) struct AdminSummary { #[derive(Deserialize)] pub(super) struct CreateApiKeyBody { pub(super) name: String, + pub(super) permissions: Vec, } #[derive(Serialize)] @@ -131,6 +149,7 @@ pub(super) struct ApiKeySummary { pub(super) id: String, pub(super) name: String, pub(super) key_prefix: String, + pub(super) permissions: Vec, pub(super) created_at: chrono::DateTime, pub(super) last_used_at: Option>, pub(super) revoked_at: Option>, @@ -142,6 +161,7 @@ pub(super) struct CreateApiKeyResponse { pub(super) name: String, pub(super) key: String, pub(super) key_prefix: String, + pub(super) permissions: Vec, } // --------------------------------------------------------------------------- diff --git a/src/admin/users.rs b/src/admin/users.rs new file mode 100644 index 0000000..d646c97 --- /dev/null +++ b/src/admin/users.rs @@ -0,0 +1,470 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use serde_json::Value; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::auth::UserAuth; +use super::permissions::Permission; +use super::types::{CreateUserBody, TransferSuperBody, UpdatePermissionsBody, UserSummary}; + +/// POST /admin/users — create a new user with template or explicit permissions. +pub(super) async fn create_user( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + auth.require(Permission::UsersCreate).await?; + + // Determine permissions to grant + let perms_to_grant: Vec = if let Some(explicit) = &body.permissions { + explicit.clone() + } else if let Some(template) = &body.template { + template + .permissions() + .iter() + .map(|p| p.as_str().to_string()) + .collect() + } else { + // Default to viewer + super::permissions::Template::Viewer + .permissions() + .iter() + .map(|p| p.as_str().to_string()) + .collect() + }; + + // Escalation guard: actor can only grant permissions they hold + if !auth.is_super { + for perm_str in &perms_to_grant { + #[allow(clippy::collapsible_if)] + if let Ok(p) = + serde_json::from_value::(serde_json::Value::String(perm_str.clone())) + { + if !auth.permissions.contains(&p) { + return Err(AppError::Forbidden(format!( + "Cannot grant permission you don't have: {perm_str}" + ))); + } + } + } + } + + let mut tx = state + .db + .begin() + .await + .map_err(|e| AppError::Internal(format!("transaction start failed: {e}")))?; + + let row: (String,) = sqlx::query_as("INSERT INTO users (did) VALUES ($1) RETURNING id::text") + .bind(&body.did) + .fetch_one(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to create user: {e}")))?; + + let user_id = &row.0; + + for perm_str in &perms_to_grant { + sqlx::query( + "INSERT INTO user_permissions (user_id, permission, granted_by) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT DO NOTHING", + ) + .bind(user_id) + .bind(perm_str) + .bind(&auth.user_id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to grant permission: {e}")))?; + } + + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("transaction commit failed: {e}")))?; + + let template_name = body + .template + .as_ref() + .map(|t| format!("{t:?}").to_lowercase()); + + log_event( + &state.db, + EventLog { + event_type: "user.created".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(body.did.clone()), + detail: serde_json::json!({ + "template": template_name, + "permissions": perms_to_grant, + }), + }, + ) + .await; + + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({ + "id": user_id, + "did": body.did, + })), + )) +} + +/// GET /admin/users — list all users with their permissions. +pub(super) async fn list_users( + State(state): State, + auth: UserAuth, +) -> Result>, AppError> { + auth.require(Permission::UsersRead).await?; + + #[allow(clippy::type_complexity)] + let rows: Vec<( + String, + String, + bool, + chrono::DateTime, + Option>, + )> = sqlx::query_as( + "SELECT id::text, did, is_super, created_at, last_used_at + FROM users ORDER BY created_at", + ) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list users: {e}")))?; + + let mut users = Vec::new(); + for (id, did, is_super, created_at, last_used_at) in rows { + let perm_rows: Vec<(String,)> = sqlx::query_as( + "SELECT permission FROM user_permissions WHERE user_id = $1::uuid ORDER BY permission", + ) + .bind(&id) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to load permissions: {e}")))?; + + users.push(UserSummary { + id, + did, + is_super, + permissions: perm_rows.into_iter().map(|(p,)| p).collect(), + created_at, + last_used_at, + }); + } + + Ok(Json(users)) +} + +/// GET /admin/users/:id — get a single user with permissions. +pub(super) async fn get_user( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::UsersRead).await?; + + #[allow(clippy::type_complexity)] + let found: Option<( + String, + String, + bool, + chrono::DateTime, + Option>, + )> = sqlx::query_as( + "SELECT id::text, did, is_super, created_at, last_used_at + FROM users WHERE id::text = $1", + ) + .bind(&id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to get user: {e}")))?; + + let Some((uid, did, is_super, created_at, last_used_at)) = found else { + return Err(AppError::NotFound(format!("user '{id}' not found"))); + }; + + let perm_rows: Vec<(String,)> = sqlx::query_as( + "SELECT permission FROM user_permissions WHERE user_id = $1::uuid ORDER BY permission", + ) + .bind(&uid) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to load permissions: {e}")))?; + + Ok(Json(UserSummary { + id: uid, + did, + is_super, + permissions: perm_rows.into_iter().map(|(p,)| p).collect(), + created_at, + last_used_at, + })) +} + +/// PATCH /admin/users/:id/permissions — grant/revoke permissions. +pub(super) async fn update_permissions( + State(state): State, + auth: UserAuth, + Path(id): Path, + Json(body): Json, +) -> Result { + auth.require(Permission::UsersUpdate).await?; + + // Self-modification guard + if auth.user_id == id { + return Err(AppError::Forbidden( + "Cannot modify your own permissions".into(), + )); + } + + // Cannot modify super user's permissions + let target: Option<(bool,)> = sqlx::query_as("SELECT is_super FROM users WHERE id::text = $1") + .bind(&id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; + + let Some((target_is_super,)) = target else { + return Err(AppError::NotFound(format!("user '{id}' not found"))); + }; + + if target_is_super { + return Err(AppError::Forbidden( + "Cannot modify super user's permissions".into(), + )); + } + + // Validate all permission strings are recognized + for perm_str in body.grant.iter().chain(body.revoke.iter()) { + if serde_json::from_value::(serde_json::Value::String(perm_str.clone())) + .is_err() + { + return Err(AppError::BadRequest(format!( + "Unrecognized permission: {perm_str}" + ))); + } + } + + // Escalation guard: can only grant permissions you hold + if !auth.is_super { + for perm_str in &body.grant { + #[allow(clippy::collapsible_if)] + if let Ok(p) = + serde_json::from_value::(serde_json::Value::String(perm_str.clone())) + { + if !auth.permissions.contains(&p) { + return Err(AppError::Forbidden(format!( + "Cannot grant permission you don't have: {perm_str}" + ))); + } + } + } + } + + let mut tx = state + .db + .begin() + .await + .map_err(|e| AppError::Internal(format!("transaction start failed: {e}")))?; + + for perm_str in &body.grant { + sqlx::query( + "INSERT INTO user_permissions (user_id, permission, granted_by) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT DO NOTHING", + ) + .bind(&id) + .bind(perm_str) + .bind(&auth.user_id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to grant permission: {e}")))?; + } + + for perm_str in &body.revoke { + sqlx::query( + "DELETE FROM user_permissions + WHERE user_id = $1::uuid AND permission = $2", + ) + .bind(&id) + .bind(perm_str) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to revoke permission: {e}")))?; + } + + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("transaction commit failed: {e}")))?; + + log_event( + &state.db, + EventLog { + event_type: "user.permissions_updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(id.clone()), + detail: serde_json::json!({ + "granted": body.grant, + "revoked": body.revoke, + }), + }, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /admin/users/:id — remove a user. +pub(super) async fn delete_user( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result { + auth.require(Permission::UsersDelete).await?; + + // Self-deletion guard + if auth.user_id == id { + return Err(AppError::Forbidden("Cannot delete yourself".into())); + } + + // Cannot delete super user + let target: Option<(bool,)> = sqlx::query_as("SELECT is_super FROM users WHERE id::text = $1") + .bind(&id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; + + let Some((is_super,)) = target else { + return Err(AppError::NotFound(format!("user '{id}' not found"))); + }; + + if is_super { + return Err(AppError::Forbidden("Cannot delete the super user".into())); + } + + // Delete cascades to user_permissions; also revoke their API keys. + // Use a transaction for atomicity. + let mut tx = state + .db + .begin() + .await + .map_err(|e| AppError::Internal(format!("transaction start failed: {e}")))?; + + sqlx::query( + "UPDATE api_keys SET revoked_at = NOW() WHERE user_id = $1::uuid AND revoked_at IS NULL", + ) + .bind(&id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to revoke api keys: {e}")))?; + + let result = sqlx::query("DELETE FROM users WHERE id::text = $1") + .bind(&id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to delete user: {e}")))?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("user '{id}' not found"))); + } + + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("transaction commit failed: {e}")))?; + + log_event( + &state.db, + EventLog { + event_type: "user.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(id), + detail: serde_json::json!({}), + }, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// POST /admin/users/transfer-super — transfer super user status. +pub(super) async fn transfer_super( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result { + if !auth.is_super { + return Err(AppError::Forbidden( + "Only the super user can transfer super status".into(), + )); + } + + let mut tx = state + .db + .begin() + .await + .map_err(|e| AppError::Internal(format!("transaction start failed: {e}")))?; + + // Remove super from current user + sqlx::query("UPDATE users SET is_super = FALSE WHERE id::text = $1") + .bind(&auth.user_id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to remove super: {e}")))?; + + // Set super on target user + let result = sqlx::query("UPDATE users SET is_super = TRUE WHERE id::text = $1") + .bind(&body.target_user_id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to set super: {e}")))?; + + if result.rows_affected() == 0 { + // Rollback by not committing + return Err(AppError::NotFound(format!( + "user '{}' not found", + body.target_user_id + ))); + } + + // Ensure target has all permissions + for perm in Permission::all() { + sqlx::query( + "INSERT INTO user_permissions (user_id, permission, granted_by) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT DO NOTHING", + ) + .bind(&body.target_user_id) + .bind(perm.as_str()) + .bind(&auth.user_id) + .execute(&mut *tx) + .await + .map_err(|e| AppError::Internal(format!("failed to grant permission: {e}")))?; + } + + tx.commit() + .await + .map_err(|e| AppError::Internal(format!("transaction commit failed: {e}")))?; + + log_event( + &state.db, + EventLog { + event_type: "user.super_transferred".to_string(), + severity: Severity::Warn, + actor_did: Some(auth.did.clone()), + subject: Some(body.target_user_id.clone()), + detail: serde_json::json!({ + "from_user_id": auth.user_id, + "to_user_id": body.target_user_id, + }), + }, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/error.rs b/src/error.rs index 178c1f3..87fe42d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,6 +52,7 @@ pub enum AppError { BadGateway(String), BadRequest(String), Forbidden(String), + InsufficientPermissions(String), Internal(String), NotFound(String), PdsError(StatusCode, Bytes), @@ -71,6 +72,7 @@ impl std::fmt::Display for AppError { AppError::BadGateway(msg) => write!(f, "bad gateway: {msg}"), AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), + AppError::InsufficientPermissions(perm) => write!(f, "Missing permission: {perm}"), AppError::Internal(msg) => write!(f, "internal error: {msg}"), AppError::NotFound(msg) => write!(f, "not found: {msg}"), AppError::PdsError(status, _) => write!(f, "PDS error: {status}"), @@ -130,6 +132,13 @@ impl IntoResponse for AppError { }); (status, axum::Json(body)).into_response() } + AppError::InsufficientPermissions(perm) => { + let body = serde_json::json!({ + "error": "InsufficientPermissions", + "message": format!("Missing permission: {perm}"), + }); + (StatusCode::FORBIDDEN, axum::Json(body)).into_response() + } other => { let (status, message) = match &other { AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), @@ -144,6 +153,7 @@ impl IntoResponse for AppError { AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::PdsError(..) | AppError::AuthDpopNonce(..) + | AppError::InsufficientPermissions(..) | AppError::ScriptError { .. } => unreachable!(), }; diff --git a/web/package-lock.json b/web/package-lock.json index 72c0774..39895ed 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -111,7 +111,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -739,7 +738,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2010,7 +2008,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4283,7 +4280,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4294,7 +4290,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4305,7 +4300,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4322,7 +4316,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4388,7 +4383,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4915,7 +4909,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5302,7 +5295,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5397,7 +5389,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6361,6 +6352,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6699,7 +6691,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6840,7 +6831,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7309,7 +7299,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8102,7 +8091,6 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9522,6 +9510,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11418,7 +11407,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11449,7 +11437,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11469,7 +11456,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11608,8 +11594,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12823,7 +12808,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13091,7 +13075,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13823,7 +13806,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/app/(dashboard)/admins/page.tsx b/web/src/app/(dashboard)/admins/page.tsx deleted file mode 100644 index abee1b8..0000000 --- a/web/src/app/(dashboard)/admins/page.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; - -import { useAuth } from "@/lib/auth-context"; -import { addAdmin, deleteAdmin, getAdmins } from "@/lib/api"; -import type { AdminSummary } from "@/types/admins"; -import { SiteHeader } from "@/components/site-header"; -import { Button } from "@/components/ui/button"; -import { Trash2 } from "lucide-react"; -import { - ResponsiveDialog, - ResponsiveDialogClose, - ResponsiveDialogContent, - ResponsiveDialogDescription, - ResponsiveDialogFooter, - ResponsiveDialogHeader, - ResponsiveDialogTitle, - ResponsiveDialogTrigger, -} from "@/components/ui/responsive-dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -export default function AdminsPage() { - const { getToken } = useAuth(); - const [admins, setAdmins] = useState([]); - const [error, setError] = useState(null); - - const load = useCallback(() => { - getAdmins(getToken) - .then(setAdmins) - .catch((e) => setError(e.message)); - }, [getToken]); - - useEffect(() => { - load(); - }, [load]); - - async function handleDelete(id: string) { - try { - await deleteAdmin(getToken, id); - load(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } - - return ( - <> - -
- {error &&

{error}

} - -
-

Admin Users

- -
- -
- - - - DID - Created - Last Used - - - - - {admins.length === 0 && ( - - - No admins yet. - - - )} - {admins.map((admin) => ( - - - {admin.did} - - - {new Date(admin.created_at).toLocaleString()} - - - {admin.last_used_at - ? new Date(admin.last_used_at).toLocaleString() - : "Never"} - - - - - - ))} - -
-
-
- - ); -} - -function AddAdminDialog({ - getToken, - onSuccess, -}: { - getToken: () => Promise; - onSuccess: () => void; -}) { - const [did, setDid] = useState(""); - const [error, setError] = useState(null); - const [open, setOpen] = useState(false); - - async function handleAdd() { - setError(null); - try { - await addAdmin(getToken, { did }); - setDid(""); - setOpen(false); - onSuccess(); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } - } - - return ( - - - - - - - Add Admin - Add a new admin by their DID. - -
- {error &&

{error}

} -
- - setDid(e.target.value)} - placeholder="did:plc:..." - /> -
-
- - - - - - -
-
- ); -} diff --git a/web/src/app/(dashboard)/backfill/page.tsx b/web/src/app/(dashboard)/backfill/page.tsx index d950f6d..f723a49 100644 --- a/web/src/app/(dashboard)/backfill/page.tsx +++ b/web/src/app/(dashboard)/backfill/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { createBackfillJob, getBackfillJobs, @@ -50,6 +51,7 @@ import { export default function BackfillPage() { const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const [jobs, setJobs] = useState([]); const [tapStats, setTapStats] = useState(null); const [error, setError] = useState(null); @@ -58,10 +60,12 @@ export default function BackfillPage() { getBackfillJobs(getToken) .then(setJobs) .catch((e) => setError(e.message)); - getTapStats(getToken) - .then(setTapStats) - .catch(() => setTapStats(null)); - }, [getToken]); + if (hasPermission("stats:read")) { + getTapStats(getToken) + .then(setTapStats) + .catch(() => setTapStats(null)); + } + }, [getToken, hasPermission]); useEffect(() => { load(); @@ -79,36 +83,40 @@ export default function BackfillPage() {
{error &&

{error}

} -
- - - Tap Repos - - {tapStats ? tapStats.repo_count.toLocaleString() : "--"} - - - - - - Tap Records - - {tapStats ? tapStats.record_count.toLocaleString() : "--"} - - - - - - Outbox Buffer - - {tapStats ? tapStats.outbox_buffer.toLocaleString() : "--"} - - - -
+ {hasPermission("stats:read") && ( +
+ + + Tap Repos + + {tapStats ? tapStats.repo_count.toLocaleString() : "--"} + + + + + + Tap Records + + {tapStats ? tapStats.record_count.toLocaleString() : "--"} + + + + + + Outbox Buffer + + {tapStats ? tapStats.outbox_buffer.toLocaleString() : "--"} + + + +
+ )}

Backfill Jobs

- + {hasPermission("backfill:create") && ( + + )}
diff --git a/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx b/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx index 08b4bbc..5ce49e0 100644 --- a/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx +++ b/web/src/app/(dashboard)/lexicons/[id]/lexicon-detail.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { CodePanels } from "@/components/code-panels"; import { deleteLexicon, @@ -35,6 +36,7 @@ export default function LexiconDetailPage() { pathname.split("/").filter(Boolean).pop() ?? "", ); const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const router = useRouter(); const [lexicon, setLexicon] = useState(null); const [error, setError] = useState(null); @@ -237,16 +239,18 @@ export default function LexiconDetailPage() { {/* Actions */}
- + {hasPermission("lexicons:delete") && ( + + )}
- {isRecord && !showHook && ( + {hasPermission("lexicons:create") && isRecord && !showHook && ( @@ -268,7 +272,7 @@ export default function LexiconDetailPage() { )} - {isRecord && showHook && ( + {hasPermission("lexicons:create") && isRecord && showHook && ( )} - + {hasPermission("lexicons:create") && ( + + )}
diff --git a/web/src/app/(dashboard)/lexicons/new/page.tsx b/web/src/app/(dashboard)/lexicons/new/page.tsx index 348bb76..c7f77a3 100644 --- a/web/src/app/(dashboard)/lexicons/new/page.tsx +++ b/web/src/app/(dashboard)/lexicons/new/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { addNetworkLexicon, uploadLexicon, @@ -21,10 +22,18 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; export default function AddLexiconPage() { const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const router = useRouter(); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + // Redirect if the user cannot create lexicons + useEffect(() => { + if (!hasPermission("lexicons:create")) { + router.replace("/lexicons"); + } + }, [hasPermission, router]); + // Local state const [json, setJson] = useState(LEXICON_TEMPLATE); const [localTargetCollection, setLocalTargetCollection] = useState(""); diff --git a/web/src/app/(dashboard)/lexicons/page.tsx b/web/src/app/(dashboard)/lexicons/page.tsx index c8e6fce..c68005f 100644 --- a/web/src/app/(dashboard)/lexicons/page.tsx +++ b/web/src/app/(dashboard)/lexicons/page.tsx @@ -19,6 +19,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { deleteLexicon, deleteNetworkLexicon, @@ -35,6 +36,7 @@ import { Eye, Rows3, Trash2 } from "lucide-react"; export default function LexiconsPage() { const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const router = useRouter(); const [lexicons, setLexicons] = useState([]); const [error, setError] = useState(null); @@ -222,19 +224,21 @@ export default function LexiconsPage() { - + {hasPermission("lexicons:delete") && ( + + )}
), enableSorting: false, @@ -242,7 +246,7 @@ export default function LexiconsPage() { }, ], // eslint-disable-next-line react-hooks/exhaustive-deps - [getToken], + [getToken, hasPermission], ); const [sorting, setSorting] = useState([ @@ -294,9 +298,11 @@ export default function LexiconsPage() { } > - + {hasPermission("lexicons:create") && ( + + )} diff --git a/web/src/app/(dashboard)/page.tsx b/web/src/app/(dashboard)/page.tsx index c74752a..f808ff3 100644 --- a/web/src/app/(dashboard)/page.tsx +++ b/web/src/app/(dashboard)/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { getStats } from "@/lib/api"; import type { StatsResponse } from "@/types/stats"; import { SiteHeader } from "@/components/site-header"; @@ -23,62 +24,69 @@ import { export default function DashboardPage() { const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const [stats, setStats] = useState(null); const [error, setError] = useState(null); + const canReadStats = hasPermission("stats:read"); useEffect(() => { + if (!canReadStats) return; getStats(getToken) .then(setStats) .catch((e) => setError(e.message)); - }, [getToken]); + }, [getToken, canReadStats]); return ( <>
{error &&

{error}

} -
- - - Total Records - - {stats ? stats.total_records.toLocaleString() : "--"} - - - - - - Collections - - {stats ? stats.collections.length : "--"} - - - -
+ {canReadStats && ( + <> +
+ + + Total Records + + {stats ? stats.total_records.toLocaleString() : "--"} + + + + + + Collections + + {stats ? stats.collections.length : "--"} + + + +
- {stats && stats.collections.length > 0 && ( -
- - - - Collection - Records - - - - {stats.collections.map((col) => ( - - - {col.collection} - - - {col.count.toLocaleString()} - - - ))} - -
-
+ {stats && stats.collections.length > 0 && ( +
+ + + + Collection + Records + + + + {stats.collections.map((col) => ( + + + {col.collection} + + + {col.count.toLocaleString()} + + + ))} + +
+
+ )} + )}
diff --git a/web/src/app/(dashboard)/records/page.tsx b/web/src/app/(dashboard)/records/page.tsx index 7612b7f..583db7e 100644 --- a/web/src/app/(dashboard)/records/page.tsx +++ b/web/src/app/(dashboard)/records/page.tsx @@ -11,6 +11,7 @@ import { import { useSearchParams } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { getStats, getAdminRecords, @@ -73,6 +74,7 @@ function formatCellValue(value: unknown): string { export default function RecordsPage() { const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const searchParams = useSearchParams(); const initialCollection = searchParams.get("collection") ?? ""; const appliedInitial = useRef(false); @@ -356,27 +358,29 @@ export default function RecordsPage() {
- - - - - - setBulkDeleteOpen(true)} - > - - Delete - - - + {hasPermission("records:delete") && ( + + + + + + setBulkDeleteOpen(true)} + > + + Delete + + + + )}
@@ -423,15 +427,17 @@ export default function RecordsPage() { -
- -
+ {hasPermission("records:delete") && ( +
+ +
+ )} )} diff --git a/web/src/app/(dashboard)/settings/page.tsx b/web/src/app/(dashboard)/settings/page.tsx index 78ebd63..01db8c9 100644 --- a/web/src/app/(dashboard)/settings/page.tsx +++ b/web/src/app/(dashboard)/settings/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { Copy, Check, Trash2, Pencil } from "lucide-react"; import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; import { getScriptVariables, upsertScriptVariable, @@ -26,6 +27,8 @@ import { ResponsiveDialogTitle, ResponsiveDialogTrigger, } from "@/components/ui/responsive-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -44,8 +47,25 @@ import { TabsTrigger, } from "@/components/ui/tabs"; +const PERMISSION_CATEGORIES: Record = { + Lexicons: ["lexicons:create", "lexicons:read", "lexicons:delete"], + Records: ["records:read", "records:delete", "records:delete-collection"], + "Script Variables": [ + "script-variables:create", + "script-variables:read", + "script-variables:delete", + ], + Users: ["users:create", "users:read", "users:update", "users:delete"], + "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], + Backfill: ["backfill:create", "backfill:read"], + System: ["stats:read", "events:read"], +}; + +const ALL_PERMISSIONS = Object.values(PERMISSION_CATEGORIES).flat(); + export default function SettingsPage() { const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); const [vars, setVars] = useState([]); const [keys, setKeys] = useState([]); const [error, setError] = useState(null); @@ -91,9 +111,11 @@ export default function SettingsPage() {
{error &&

{error}

} - + - ENV Variables + {hasPermission("script-variables:read") && ( + ENV Variables + )} API Keys @@ -106,7 +128,9 @@ export default function SettingsPage() { env global table.

- + {hasPermission("script-variables:create") && ( + + )}
@@ -148,16 +172,18 @@ export default function SettingsPage() { onSuccess={loadVars} editKey={v.key} /> - + {hasPermission("script-variables:delete") && ( + + )}
@@ -170,7 +196,9 @@ export default function SettingsPage() {

API Keys

- + {hasPermission("api-keys:create") && ( + + )}
@@ -179,6 +207,7 @@ export default function SettingsPage() { Name Key + Permissions Created Last Used @@ -188,7 +217,7 @@ export default function SettingsPage() { {keys.length === 0 && ( No API keys yet. @@ -208,6 +237,11 @@ export default function SettingsPage() { {key.key_prefix}... + + + {key.permissions?.length ?? 0} perms + + {new Date(key.created_at).toLocaleString()} @@ -217,7 +251,7 @@ export default function SettingsPage() { : "Never"} - {!key.revoked_at && ( + {!key.revoked_at && hasPermission("api-keys:delete") && (
+
+ +
+ {Object.entries(PERMISSION_CATEGORIES).map( + ([category, perms]) => { + const allSelected = perms.every((p) => + selectedPermissions.includes(p) + ); + const someSelected = perms.some((p) => + selectedPermissions.includes(p) + ); + return ( +
+ +
+ {perms.map((perm) => ( + + ))} +
+
+ ); + } + )} +
+

+ {selectedPermissions.length} of {ALL_PERMISSIONS.length}{" "} + permissions selected +

+
)} diff --git a/web/src/app/(dashboard)/users/page.tsx b/web/src/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..72d4eed --- /dev/null +++ b/web/src/app/(dashboard)/users/page.tsx @@ -0,0 +1,513 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { ChevronDown, ChevronRight, Shield, Trash2 } from "lucide-react"; + +import { useAuth } from "@/lib/auth-context"; +import { + getUsers, + addUser, + deleteUser, + updateUserPermissions, + transferSuper, +} from "@/lib/api"; +import type { UserSummary } from "@/types/users"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/responsive-dialog"; + +const PERMISSION_CATEGORIES: Record = { + Lexicons: ["lexicons:create", "lexicons:read", "lexicons:delete"], + Records: ["records:read", "records:delete", "records:delete-collection"], + "Script Variables": [ + "script-variables:create", + "script-variables:read", + "script-variables:delete", + ], + Users: ["users:create", "users:read", "users:update", "users:delete"], + "API Keys": ["api-keys:create", "api-keys:read", "api-keys:delete"], + Backfill: ["backfill:create", "backfill:read"], + System: ["stats:read", "events:read"], +}; + +const ALL_PERMISSIONS = Object.values(PERMISSION_CATEGORIES).flat(); + +const TEMPLATES = [ + { value: "viewer", label: "Viewer" }, + { value: "operator", label: "Operator" }, + { value: "manager", label: "Manager" }, + { value: "full_access", label: "Full Access" }, +] as const; + +const TEMPLATE_PERMISSIONS: Record = { + viewer: ["lexicons:read", "records:read", "script-variables:read", "users:read", "api-keys:read", "backfill:read", "stats:read", "events:read"], + operator: ["lexicons:read", "records:read", "records:delete", "script-variables:read", "script-variables:create", "users:read", "api-keys:read", "backfill:read", "backfill:create", "stats:read", "events:read"], + manager: ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:read", "api-keys:read", "backfill:create", "backfill:read", "stats:read", "events:read"], + full_access: ALL_PERMISSIONS, +}; + +export default function UsersPage() { + const { getToken, did: currentDid } = useAuth(); + const [users, setUsers] = useState([]); + const [error, setError] = useState(null); + const [expandedUserId, setExpandedUserId] = useState(null); + + const currentUser = users.find((u) => u.did === currentDid); + const isCurrentUserSuper = currentUser?.is_super ?? false; + + const load = useCallback(() => { + getUsers(getToken) + .then(setUsers) + .catch((e) => setError(e instanceof Error ? e.message : String(e))); + }, [getToken]); + + useEffect(() => { + load(); + }, [load]); + + async function handleDelete(id: string) { + try { + await deleteUser(getToken, id); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + async function handleTogglePermission( + user: UserSummary, + permission: string, + enabled: boolean + ) { + const grant: string[] = []; + const revoke: string[] = []; + + const [ns, action] = permission.split(":"); + + if (enabled) { + grant.push(permission); + // Adding a write permission also enables its read counterpart + if (action === "create" || action === "update" || action === "delete") { + const readPerm = `${ns}:read`; + if (!user.permissions.includes(readPerm)) { + grant.push(readPerm); + } + } + // Adding records:delete-collection also enables records:delete + if (permission === "records:delete-collection" && !user.permissions.includes("records:delete")) { + grant.push("records:delete"); + } + } else { + revoke.push(permission); + // Removing read also removes all write permissions in the same namespace + if (action === "read") { + for (const p of user.permissions) { + if (p.startsWith(`${ns}:`) && p !== permission) { + revoke.push(p); + } + } + } + // Removing records:delete also removes records:delete-collection + if (permission === "records:delete" && user.permissions.includes("records:delete-collection")) { + revoke.push("records:delete-collection"); + } + } + + try { + const body: { grant?: string[]; revoke?: string[] } = {}; + if (grant.length > 0) body.grant = grant; + if (revoke.length > 0) body.revoke = revoke; + await updateUserPermissions(getToken, user.id, body); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + async function handleTransferSuper(targetUserId: string) { + try { + await transferSuper(getToken, { target_user_id: targetUserId }); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + <> + +
+ {error &&

{error}

} + +
+

Users

+ {(isCurrentUserSuper || currentUser?.permissions.includes("users:create")) && ( + + )} +
+ +
+ + + + + DID + Permissions + Created + Last Used + + + + + {users.length === 0 && ( + + + No users yet. + + + )} + {users.map((user) => ( + + + + + + + setExpandedUserId( + expandedUserId === user.id ? null : user.id + ) + } + > +
+ {user.did} + {user.is_super && ( + + Owner + + )} +
+
+ + setExpandedUserId( + expandedUserId === user.id ? null : user.id + ) + } + > + {user.is_super + ? `${ALL_PERMISSIONS.length}/${ALL_PERMISSIONS.length}` + : `${user.permissions.length}/${ALL_PERMISSIONS.length}`} + + + setExpandedUserId( + expandedUserId === user.id ? null : user.id + ) + } + > + {new Date(user.created_at).toLocaleString()} + + + setExpandedUserId( + expandedUserId === user.id ? null : user.id + ) + } + > + {user.last_used_at + ? new Date(user.last_used_at).toLocaleString() + : "Never"} + + +
+ {isCurrentUserSuper && ( + handleTransferSuper(user.id)} + /> + )} + +
+
+
+ {expandedUserId === user.id && ( + + + + + + )} +
+ ))} +
+
+
+
+ + ); +} + +function PermissionsPanel({ + user, + isSelf, + currentUserPermissions, + isCurrentUserSuper, + onToggle, +}: { + user: UserSummary; + isSelf: boolean; + currentUserPermissions: string[]; + isCurrentUserSuper: boolean; + onToggle: (user: UserSummary, permission: string, enabled: boolean) => void; +}) { + const canUpdate = isCurrentUserSuper || currentUserPermissions.includes("users:update"); + + return ( +
+ {Object.entries(PERMISSION_CATEGORIES).map(([category, permissions]) => ( +
+

+ {category} +

+
+ {permissions.map((perm) => { + const enabled = user.is_super || user.permissions.includes(perm); + return ( +
+ + onToggle(user, perm, checked) + } + className="scale-75" + /> + +
+ ); + })} +
+
+ ))} +
+ ); +} + +function TransferOwnershipDialog({ + user, + disabled, + onConfirm, +}: { + user: UserSummary; + disabled?: boolean; + onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + + async function handleConfirm() { + onConfirm(); + setOpen(false); + } + + return ( + + + + + + + Transfer Ownership + + Are you sure you want to transfer ownership to{" "} + {user.did}? You will + lose your owner privileges and cannot undo this action without their + cooperation. + + + + + + + + + + + ); +} + +function AddUserDialog({ + getToken, + onSuccess, +}: { + getToken: () => Promise; + onSuccess: () => void; +}) { + const [did, setDid] = useState(""); + const [template, setTemplate] = useState(""); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + + async function handleAdd() { + setError(null); + try { + const body: { did: string; template?: string } = { did }; + if (template) body.template = template; + await addUser(getToken, body); + setDid(""); + setTemplate(""); + setOpen(false); + onSuccess(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + + + + + + + Add User + + Add a new user by their DID and optionally assign a permission + template. + + +
+ {error &&

{error}

} +
+ + setDid(e.target.value)} + placeholder="did:plc:..." + /> +
+
+ + +
+ {template && ( +

+ Grants {TEMPLATE_PERMISSIONS[template]?.length ?? 0} permissions. +

+ )} +
+ + + + + + +
+
+ ); +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 0181163..1858c61 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -15,6 +15,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { useAuth } from "@/lib/auth-context" +import { useCurrentUser } from "@/hooks/use-current-user" import { Sidebar, SidebarContent, @@ -32,16 +33,22 @@ const navItems = [ { title: "Lexicons", url: "/lexicons", icon: IconFileDescription }, { title: "Backfill", url: "/backfill", icon: IconDatabase }, { title: "Records", url: "/records", icon: IconTable }, - { title: "Event Logs", url: "/events", icon: IconClipboardList }, - { title: "Admins", url: "/admins", icon: IconUsers }, - { title: "Settings", url: "/settings", icon: IconSettings }, -] + { title: "Event Logs", url: "/events", icon: IconClipboardList, requiredPermissions: ["events:read"] }, + { title: "Users", url: "/users", icon: IconUsers }, + { title: "Settings", url: "/settings", icon: IconSettings, requiredPermissions: ["api-keys:read", "script-variables:read"] }, +] as const export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname() const { logout } = useAuth() + const { hasPermission } = useCurrentUser() + + const visibleNavItems = navItems.filter((item) => { + if (!("requiredPermissions" in item)) return true + return item.requiredPermissions.some((perm) => hasPermission(perm)) + }) return ( @@ -65,7 +72,7 @@ export function AppSidebar({ - {navItems.map((item) => ( + {visibleNavItems.map((item) => ( (null); + + const load = useCallback(() => { + getUsers(getToken) + .then((users) => setCurrentUser(users.find((u) => u.did === did) ?? null)) + .catch(() => setCurrentUser(null)); + }, [getToken, did]); + + useEffect(() => { + load(); + }, [load]); + + const isSuper = currentUser?.is_super ?? false; + + const hasPermission = useCallback( + (permission: string) => + isSuper || (currentUser?.permissions.includes(permission) ?? false), + [currentUser, isSuper], + ); + + return { currentUser, isSuper, hasPermission, reload: load }; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9675a93..15000bd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,7 +6,7 @@ import type { LexiconSummary, LexiconDetail } from "@/types/lexicons" import type { NetworkLexiconSummary } from "@/types/network-lexicons" import type { TapStatsResponse } from "@/types/tap" import type { BackfillJob } from "@/types/backfill" -import type { AdminSummary } from "@/types/admins" +import type { UserSummary } from "@/types/users" import type { AdminListRecordsResponse } from "@/types/records" import type { EventsListResponse } from "@/types/events" import type { ScriptVariableSummary } from "@/types/script-variables" @@ -17,7 +17,7 @@ export type { LexiconSummary, LexiconDetail } from "@/types/lexicons" export type { NetworkLexiconSummary } from "@/types/network-lexicons" export type { TapStatsResponse } from "@/types/tap" export type { BackfillJob } from "@/types/backfill" -export type { AdminSummary } from "@/types/admins" +export type { UserSummary } from "@/types/users" export type { AdminRecord, AdminListRecordsResponse } from "@/types/records" export type { EventLogEntry, EventsListResponse } from "@/types/events" export type { ScriptVariableSummary } from "@/types/script-variables" @@ -176,27 +176,52 @@ export function createBackfillJob( }) } -// Admins -export function getAdmins(getToken: () => Promise) { - return apiFetch("/admin/admins", getToken) +// Users +export function getUsers(getToken: () => Promise) { + return apiFetch("/admin/users", getToken) } -export function addAdmin( +export function getUser(getToken: () => Promise, id: string) { + return apiFetch(`/admin/users/${encodeURIComponent(id)}`, getToken) +} + +export function addUser( getToken: () => Promise, - body: { did: string } + body: { did: string; template?: string; permissions?: string[] } ) { - return apiFetch<{ id: string; did: string }>("/admin/admins", getToken, { + return apiFetch<{ id: string; did: string }>("/admin/users", getToken, { method: "POST", body: JSON.stringify(body), }) } -export function deleteAdmin(getToken: () => Promise, id: string) { - return apiFetch(`/admin/admins/${encodeURIComponent(id)}`, getToken, { +export function deleteUser(getToken: () => Promise, id: string) { + return apiFetch(`/admin/users/${encodeURIComponent(id)}`, getToken, { method: "DELETE", }) } +export function updateUserPermissions( + getToken: () => Promise, + id: string, + body: { grant?: string[]; revoke?: string[] } +) { + return apiFetch(`/admin/users/${encodeURIComponent(id)}/permissions`, getToken, { + method: "PATCH", + body: JSON.stringify(body), + }) +} + +export function transferSuper( + getToken: () => Promise, + body: { target_user_id: string } +) { + return apiFetch("/admin/users/transfer-super", getToken, { + method: "POST", + body: JSON.stringify(body), + }) +} + // API Keys export function getApiKeys(getToken: () => Promise) { return apiFetch("/admin/api-keys", getToken) @@ -204,7 +229,7 @@ export function getApiKeys(getToken: () => Promise) { export function createApiKey( getToken: () => Promise, - body: { name: string } + body: { name: string; permissions: string[] } ) { return apiFetch("/admin/api-keys", getToken, { method: "POST", diff --git a/web/src/types/api-keys.ts b/web/src/types/api-keys.ts index f943773..eb8fc44 100644 --- a/web/src/types/api-keys.ts +++ b/web/src/types/api-keys.ts @@ -2,6 +2,7 @@ export interface ApiKeySummary { id: string name: string key_prefix: string + permissions: string[] created_at: string last_used_at: string | null revoked_at: string | null @@ -12,4 +13,5 @@ export interface CreateApiKeyResponse { name: string key: string key_prefix: string + permissions: string[] } diff --git a/web/src/types/admins.ts b/web/src/types/users.ts similarity index 51% rename from web/src/types/admins.ts rename to web/src/types/users.ts index e60d854..75a1b35 100644 --- a/web/src/types/admins.ts +++ b/web/src/types/users.ts @@ -1,6 +1,8 @@ -export interface AdminSummary { +export interface UserSummary { id: string did: string + is_super: boolean + permissions: string[] created_at: string last_used_at: string | null } -- 2.51.2