diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -126,4 +126,5 @@ .route("/dead-letters/{id}", get(dead_letters::detail)) .route("/dead-letters/{id}/dismiss", post(dead_letters::dismiss)) .route("/dead-letters/{id}/retry", post(dead_letters::retry)) .route("/dead-letters/{id}/reindex", post(dead_letters::reindex)) + .route("/permissions", get(users::list_permissions)) } diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -2,7 +2,15 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; -/// All 37 permissions in the system. +#[derive(Serialize)] +pub struct PermissionInfo { + pub key: &'static str, + pub name: &'static str, + pub description: &'static str, + pub category: &'static str, +} + +/// All permissions in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Permission { #[serde(rename = "lexicons:create")] @@ -150,6 +158,257 @@ Self::SpacesManageCredentials => "spaces:manage-credentials", } } + pub fn info(&self) -> PermissionInfo { + match self { + Self::LexiconsCreate => PermissionInfo { + key: "lexicons:create", + name: "Create Lexicons", + description: "Upload and register new lexicon schemas", + category: "Lexicons", + }, + Self::LexiconsRead => PermissionInfo { + key: "lexicons:read", + name: "View Lexicons", + description: "View registered lexicon schemas", + category: "Lexicons", + }, + Self::LexiconsDelete => PermissionInfo { + key: "lexicons:delete", + name: "Delete Lexicons", + description: "Remove lexicon schemas", + category: "Lexicons", + }, + Self::RecordsRead => PermissionInfo { + key: "records:read", + name: "View Records", + description: "Browse indexed AT Protocol records", + category: "Records", + }, + Self::RecordsDelete => PermissionInfo { + key: "records:delete", + name: "Delete Records", + description: "Delete individual records from the index", + category: "Records", + }, + Self::RecordsDeleteCollection => PermissionInfo { + key: "records:delete-collection", + name: "Delete Collections", + description: "Bulk-delete all records in a collection", + category: "Records", + }, + Self::ScriptVariablesCreate => PermissionInfo { + key: "script-variables:create", + name: "Create Script Variables", + description: "Add or update environment variables for Lua scripts", + category: "Script Variables", + }, + Self::ScriptVariablesRead => PermissionInfo { + key: "script-variables:read", + name: "View Script Variables", + description: "View script environment variable keys and values", + category: "Script Variables", + }, + Self::ScriptVariablesDelete => PermissionInfo { + key: "script-variables:delete", + name: "Delete Script Variables", + description: "Remove script environment variables", + category: "Script Variables", + }, + Self::UsersCreate => PermissionInfo { + key: "users:create", + name: "Create Users", + description: "Add new dashboard users", + category: "Users", + }, + Self::UsersRead => PermissionInfo { + key: "users:read", + name: "View Users", + description: "View the user list and their permissions", + category: "Users", + }, + Self::UsersUpdate => PermissionInfo { + key: "users:update", + name: "Update Users", + description: "Modify user permissions", + category: "Users", + }, + Self::UsersDelete => PermissionInfo { + key: "users:delete", + name: "Delete Users", + description: "Remove dashboard users", + category: "Users", + }, + Self::ApiKeysCreate => PermissionInfo { + key: "api-keys:create", + name: "Create API Keys", + description: "Generate new API keys for admin access", + category: "API Keys", + }, + Self::ApiKeysRead => PermissionInfo { + key: "api-keys:read", + name: "View API Keys", + description: "View existing API keys", + category: "API Keys", + }, + Self::ApiKeysDelete => PermissionInfo { + key: "api-keys:delete", + name: "Revoke API Keys", + description: "Revoke existing API keys", + category: "API Keys", + }, + Self::BackfillCreate => PermissionInfo { + key: "backfill:create", + name: "Start Backfill", + description: "Trigger historical record backfill jobs", + category: "Backfill", + }, + Self::BackfillRead => PermissionInfo { + key: "backfill:read", + name: "View Backfill", + description: "View backfill job status and progress", + category: "Backfill", + }, + Self::StatsRead => PermissionInfo { + key: "stats:read", + name: "View Stats", + description: "View collection statistics and record counts", + category: "System", + }, + Self::EventsRead => PermissionInfo { + key: "events:read", + name: "View Events", + description: "View the event log", + category: "System", + }, + Self::LabelersCreate => PermissionInfo { + key: "labelers:create", + name: "Add Labelers", + description: "Subscribe to external labeler services", + category: "Labelers", + }, + Self::LabelersRead => PermissionInfo { + key: "labelers:read", + name: "View Labelers", + description: "View subscribed labeler services", + category: "Labelers", + }, + Self::LabelersDelete => PermissionInfo { + key: "labelers:delete", + name: "Remove Labelers", + description: "Unsubscribe from labeler services", + category: "Labelers", + }, + Self::SettingsManage => PermissionInfo { + key: "settings:manage", + name: "Manage Settings", + description: "Modify instance settings, logo, and configuration", + category: "Settings", + }, + Self::PluginsRead => PermissionInfo { + key: "plugins:read", + name: "View Plugins", + description: "View installed plugins and their configuration", + category: "Plugins", + }, + Self::PluginsCreate => PermissionInfo { + key: "plugins:create", + name: "Install Plugins", + description: "Install and configure new plugins", + category: "Plugins", + }, + Self::PluginsDelete => PermissionInfo { + key: "plugins:delete", + name: "Remove Plugins", + description: "Uninstall plugins", + category: "Plugins", + }, + Self::ApiClientsView => PermissionInfo { + key: "api-clients:view", + name: "View API Clients", + description: "View registered OAuth API clients", + category: "API Clients", + }, + Self::ApiClientsCreate => PermissionInfo { + key: "api-clients:create", + name: "Create API Clients", + description: "Register new OAuth API clients", + category: "API Clients", + }, + Self::ApiClientsEdit => PermissionInfo { + key: "api-clients:edit", + name: "Edit API Clients", + description: "Modify API client settings and credentials", + category: "API Clients", + }, + Self::ApiClientsDelete => PermissionInfo { + key: "api-clients:delete", + name: "Delete API Clients", + description: "Remove registered API clients", + category: "API Clients", + }, + Self::DeadLettersRead => PermissionInfo { + key: "dead-letters:read", + name: "View Dead Letters", + description: "View failed hook executions", + category: "Dead Letters", + }, + Self::DeadLettersManage => PermissionInfo { + key: "dead-letters:manage", + name: "Manage Dead Letters", + description: "Retry, re-index, or dismiss dead letters", + category: "Dead Letters", + }, + Self::SpacesCreate => PermissionInfo { + key: "spaces:create", + name: "Create Spaces", + description: "Create new permissioned data spaces", + category: "Spaces", + }, + Self::SpacesRead => PermissionInfo { + key: "spaces:read", + name: "View Spaces", + description: "View space details and metadata", + category: "Spaces", + }, + Self::SpacesUpdate => PermissionInfo { + key: "spaces:update", + name: "Update Spaces", + description: "Modify space settings", + category: "Spaces", + }, + Self::SpacesDelete => PermissionInfo { + key: "spaces:delete", + name: "Delete Spaces", + description: "Remove spaces and their data", + category: "Spaces", + }, + Self::SpacesManageMembers => PermissionInfo { + key: "spaces:manage-members", + name: "Manage Members", + description: "Add or remove space members and roles", + category: "Spaces", + }, + Self::SpacesManageInvites => PermissionInfo { + key: "spaces:manage-invites", + name: "Manage Invites", + description: "Create and revoke space invitations", + category: "Spaces", + }, + Self::SpacesManageRecords => PermissionInfo { + key: "spaces:manage-records", + name: "Manage Records", + description: "Read and write records within spaces", + category: "Spaces", + }, + Self::SpacesManageCredentials => PermissionInfo { + key: "spaces:manage-credentials", + name: "Manage Credentials", + description: "Issue and revoke space access credentials", + category: "Spaces", + }, + } + } + /// All permissions. pub fn all() -> HashSet { HashSet::from([ @@ -198,8 +457,65 @@ ]) } } +/// Ordered list of all permissions with metadata. +pub fn catalog() -> Vec { + use Permission::*; + [ + LexiconsCreate, + LexiconsRead, + LexiconsDelete, + RecordsRead, + RecordsDelete, + RecordsDeleteCollection, + ScriptVariablesCreate, + ScriptVariablesRead, + ScriptVariablesDelete, + UsersCreate, + UsersRead, + UsersUpdate, + UsersDelete, + ApiKeysCreate, + ApiKeysRead, + ApiKeysDelete, + BackfillCreate, + BackfillRead, + StatsRead, + EventsRead, + LabelersCreate, + LabelersRead, + LabelersDelete, + SettingsManage, + PluginsRead, + PluginsCreate, + PluginsDelete, + ApiClientsView, + ApiClientsCreate, + ApiClientsEdit, + ApiClientsDelete, + DeadLettersRead, + DeadLettersManage, + SpacesCreate, + SpacesRead, + SpacesUpdate, + SpacesDelete, + SpacesManageMembers, + SpacesManageInvites, + SpacesManageRecords, + SpacesManageCredentials, + ] + .iter() + .map(|p| p.info()) + .collect() +} + +/// Check whether a permission string is recognized. +#[allow(dead_code)] +pub fn is_valid(key: &str) -> bool { + serde_json::from_value::(serde_json::Value::String(key.to_string())).is_ok() +} + /// Predefined permission templates. -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum Template { Viewer, @@ -208,7 +524,47 @@ Manager, FullAccess, } +#[derive(Serialize)] +pub struct TemplateInfo { + pub key: String, + pub label: &'static str, + pub permissions: Vec<&'static str>, +} + impl Template { + pub const ALL: &[Template] = &[ + Template::Viewer, + Template::Operator, + Template::Manager, + Template::FullAccess, + ]; + + pub fn key(&self) -> &'static str { + match self { + Self::Viewer => "viewer", + Self::Operator => "operator", + Self::Manager => "manager", + Self::FullAccess => "full_access", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Viewer => "Viewer", + Self::Operator => "Operator", + Self::Manager => "Manager", + Self::FullAccess => "Full Access", + } + } + + pub fn info(&self) -> TemplateInfo { + TemplateInfo { + key: self.key().to_string(), + label: self.label(), + permissions: self.permissions().iter().map(|p| p.as_str()).collect(), + } + } + pub fn permissions(&self) -> HashSet { match self { Self::Viewer => HashSet::from([ @@ -262,3 +618,108 @@ Self::FullAccess => Permission::all(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_covers_all_permissions() { + let catalog_keys: Vec<&str> = catalog().iter().map(|p| p.key).collect(); + for perm in Permission::all() { + assert!( + catalog_keys.contains(&perm.as_str()), + "Permission {} missing from catalog()", + perm.as_str() + ); + } + } + + #[test] + fn catalog_has_no_duplicates() { + let entries = catalog(); + let mut seen = std::collections::HashSet::new(); + for entry in &entries { + assert!( + seen.insert(entry.key), + "Duplicate key in catalog: {}", + entry.key + ); + } + } + + #[test] + fn info_key_matches_as_str() { + for perm in Permission::all() { + assert_eq!(perm.info().key, perm.as_str()); + } + } + + #[test] + fn info_fields_are_nonempty() { + for perm in Permission::all() { + let info = perm.info(); + assert!(!info.name.is_empty(), "{} has empty name", info.key); + assert!( + !info.description.is_empty(), + "{} has empty description", + info.key + ); + assert!(!info.category.is_empty(), "{} has empty category", info.key); + } + } + + #[test] + fn is_valid_accepts_known_permissions() { + assert!(is_valid("lexicons:create")); + assert!(is_valid("spaces:manage-members")); + } + + #[test] + fn is_valid_rejects_unknown_permissions() { + assert!(!is_valid("fake:permission")); + assert!(!is_valid("")); + } + + #[test] + fn template_full_access_covers_all() { + assert_eq!(Template::FullAccess.permissions(), Permission::all()); + } + + #[test] + fn template_viewer_is_subset_of_operator() { + let viewer = Template::Viewer.permissions(); + let operator = Template::Operator.permissions(); + assert!(viewer.is_subset(&operator)); + } + + #[test] + fn template_operator_is_subset_of_manager() { + let operator = Template::Operator.permissions(); + let manager = Template::Manager.permissions(); + assert!(operator.is_subset(&manager)); + } + + #[test] + fn template_info_permissions_match_template_permissions() { + for t in Template::ALL { + let info = t.info(); + let expected: HashSet<&str> = t.permissions().iter().map(|p| p.as_str()).collect(); + let actual: HashSet<&str> = info.permissions.into_iter().collect(); + assert_eq!(expected, actual, "Template {:?} info mismatch", t); + } + } + + #[test] + fn spaces_permissions_are_in_spaces_category() { + for entry in catalog() { + if entry.key.starts_with("spaces:") { + assert_eq!( + entry.category, "Spaces", + "{} should be in Spaces category", + entry.key + ); + } + } + } +} diff --git a/src/admin/users.rs b/src/admin/users.rs --- a/src/admin/users.rs +++ b/src/admin/users.rs @@ -10,7 +10,7 @@ use crate::error::AppError; use crate::event_log::{EventLog, Severity, log_event}; use super::auth::UserAuth; -use super::permissions::Permission; +use super::permissions::{self, Permission}; use super::types::{CreateUserBody, TransferSuperBody, UpdatePermissionsBody, UserSummary}; /// POST /admin/users — create a new user with template or explicit permissions. @@ -480,3 +480,53 @@ .await; Ok(StatusCode::NO_CONTENT) } + +/// GET /admin/permissions — list all available permissions and templates. +pub(super) async fn list_permissions( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::UsersRead).await?; + + let spaces_enabled = crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await; + + let all_permissions: Vec = permissions::catalog() + .into_iter() + .filter(|p| spaces_enabled || p.category != "Spaces") + .map(|p| { + serde_json::json!({ + "key": p.key, + "name": p.name, + "description": p.description, + "category": p.category, + }) + }) + .collect(); + + let templates: Vec = permissions::Template::ALL + .iter() + .map(|t| { + let info = t.info(); + let perms: Vec<&str> = info + .permissions + .into_iter() + .filter(|p| spaces_enabled || !p.starts_with("spaces:")) + .collect(); + serde_json::json!({ + "key": info.key, + "label": info.label, + "permissions": perms, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "permissions": all_permissions, + "templates": templates, + }))) +} diff --git a/tests/e2e_permissions.rs b/tests/e2e_permissions.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_permissions.rs @@ -0,0 +1,258 @@ +mod common; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_put( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method(Method::PUT) + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .method(Method::DELETE) + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +#[serial] +async fn permissions_requires_auth() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/permissions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn permissions_returns_catalog() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().expect("permissions array"); + assert!(!permissions.is_empty()); + + let first = &permissions[0]; + assert!(first["key"].is_string()); + assert!(first["name"].is_string()); + assert!(first["description"].is_string()); + assert!(first["category"].is_string()); + + let templates = body["templates"].as_array().expect("templates array"); + assert!(!templates.is_empty()); + + let first_template = &templates[0]; + assert!(first_template["key"].is_string()); + assert!(first_template["label"].is_string()); + assert!(first_template["permissions"].is_array()); +} + +#[tokio::test] +#[serial] +async fn permissions_excludes_spaces_when_flag_disabled() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().unwrap(); + let has_spaces = permissions + .iter() + .any(|p| p["key"].as_str().unwrap_or("").starts_with("spaces:")); + assert!( + !has_spaces, + "spaces permissions should be excluded when flag is disabled" + ); + + let has_spaces_category = permissions + .iter() + .any(|p| p["category"].as_str().unwrap_or("") == "Spaces"); + assert!( + !has_spaces_category, + "Spaces category should not appear when flag is disabled" + ); + + let templates = body["templates"].as_array().unwrap(); + for template in templates { + let perms = template["permissions"].as_array().unwrap(); + let has_spaces_perm = perms + .iter() + .any(|p| p.as_str().unwrap_or("").starts_with("spaces:")); + assert!( + !has_spaces_perm, + "template {:?} should not contain spaces permissions when flag is disabled", + template["key"] + ); + } +} + +#[tokio::test] +#[serial] +async fn permissions_includes_spaces_when_flag_enabled() { + common::require_db!(); + let app = TestApp::new().await; + + // Enable the spaces feature flag + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().unwrap(); + let has_spaces = permissions + .iter() + .any(|p| p["key"].as_str().unwrap_or("").starts_with("spaces:")); + assert!( + has_spaces, + "spaces permissions should be included when flag is enabled" + ); + + let templates = body["templates"].as_array().unwrap(); + let manager = templates + .iter() + .find(|t| t["key"] == "manager") + .expect("manager template"); + let manager_perms = manager["permissions"].as_array().unwrap(); + let has_spaces_perm = manager_perms + .iter() + .any(|p| p.as_str().unwrap_or("").starts_with("spaces:")); + assert!( + has_spaces_perm, + "manager template should include spaces permissions when flag is enabled" + ); +} + +#[tokio::test] +#[serial] +async fn permissions_spaces_removed_after_disabling_flag() { + common::require_db!(); + let app = TestApp::new().await; + + // Enable + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Disable + let resp = app + .router + .clone() + .oneshot(admin_delete( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/permissions", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + + let permissions = body["permissions"].as_array().unwrap(); + let has_spaces = permissions + .iter() + .any(|p| p["key"].as_str().unwrap_or("").starts_with("spaces:")); + assert!( + !has_spaces, + "spaces permissions should be gone after disabling flag" + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -220,6 +220,29 @@ body: JSON.stringify(body), }); } +// Permissions catalog +export type PermissionEntry = { + key: string; + name: string; + description: string; + category: string; +}; + +export type PermissionTemplate = { + key: string; + label: string; + permissions: string[]; +}; + +export type PermissionsCatalog = { + permissions: PermissionEntry[]; + templates: PermissionTemplate[]; +}; + +export function getPermissions() { + return apiFetch("/admin/permissions"); +} + // API Keys export function getApiKeys() { return apiFetch("/admin/api-keys");