diff --git a/backend/migrations/202511010003_capability_sets/up.sql b/backend/migrations/202511010003_capability_sets/up.sql index ba8c82e..f3bb977 100644 --- a/backend/migrations/202511010003_capability_sets/up.sql +++ b/backend/migrations/202511010003_capability_sets/up.sql @@ -55,6 +55,12 @@ user_sets AS ( FROM shared.tenants RETURNING id, tenant_id ), +readonly_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'readonly', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), webdav_sets AS ( INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) SELECT id, 'webdav', TRUE @@ -109,6 +115,16 @@ FROM ( ]) AS capability FROM user_sets us UNION ALL + SELECT rs.id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'folders:read'::api_capability, + 'tags:read'::api_capability, + 'correspondents:read'::api_capability, + 'webdav:read'::api_capability + ]) AS capability + FROM readonly_sets rs + UNION ALL SELECT ws.id, UNNEST(ARRAY['webdav:read'::api_capability]) AS capability FROM webdav_sets ws diff --git a/backend/src/auth/capability_sets.rs b/backend/src/auth/capability_sets.rs index 36b03c2..f1c1983 100644 --- a/backend/src/auth/capability_sets.rs +++ b/backend/src/auth/capability_sets.rs @@ -53,6 +53,14 @@ const USER_CAPABILITIES: [ApiCapability; 16] = [ ApiCapability::WebdavRead, ]; +const READONLY_CAPABILITIES: [ApiCapability; 5] = [ + ApiCapability::CorrespondentsRead, + ApiCapability::DocumentsRead, + ApiCapability::FoldersRead, + ApiCapability::TagsRead, + ApiCapability::WebdavRead, +]; + const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead]; pub fn owner_capabilities() -> &'static [ApiCapability] { @@ -63,12 +71,16 @@ pub fn user_capabilities() -> &'static [ApiCapability] { &USER_CAPABILITIES } +pub fn readonly_capabilities() -> &'static [ApiCapability] { + &READONLY_CAPABILITIES +} + pub fn webdav_capabilities() -> &'static [ApiCapability] { &WEBDAV_CAPABILITIES } pub fn is_system_slug(slug: &str) -> bool { - matches!(slug, "owner" | "user" | "webdav") + matches!(slug, "owner" | "user" | "readonly" | "webdav") } pub fn create_capability_set( @@ -175,7 +187,7 @@ where tenant_id, slug: slug.clone(), cap_version: 1, - is_system: slug == "owner" || slug == "user" || slug == "webdav", + is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"), }; diesel::insert_into(capability_sets::table) @@ -243,6 +255,10 @@ pub fn compute_slug(capabilities: &[ApiCapability]) -> String { return "user".to_string(); } + if capabilities == readonly_capabilities() { + return "readonly".to_string(); + } + if capabilities == webdav_capabilities() { return "webdav".to_string(); } diff --git a/backend/src/schema.rs b/backend/src/schema.rs index cd8d215..963a61e 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -10,8 +10,8 @@ pub mod sql_types { pub struct TenantStatus; #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "api_token_capability"))] - pub struct ApiTokenCapability; + #[diesel(postgres_type(name = "api_capability"))] + pub struct ApiCapability; } diesel::table! { @@ -204,6 +204,7 @@ diesel::table! { tenant_id -> Uuid, created_at -> Timestamptz, updated_at -> Timestamptz, + capability_set_id -> Nullable, } } @@ -251,8 +252,29 @@ diesel::table! { diesel::table! { use diesel::sql_types::*; - use super::sql_types::ApiTokenCapability; + capability_sets (id) { + id -> Uuid, + tenant_id -> Uuid, + slug -> Text, + cap_version -> Int4, + is_system -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ApiCapability; + + capability_set_capabilities (capability_set_id, capability) { + capability_set_id -> Uuid, + capability -> ApiCapability, + } +} + +diesel::table! { api_tokens (id) { id -> Uuid, user_id -> Uuid, @@ -264,11 +286,13 @@ diesel::table! { last_used_at -> Nullable, expires_at -> Nullable, revoked_at -> Nullable, - capabilities -> Array, + capability_set_id -> Uuid, } } diesel::joinable!(correspondents -> tenants (tenant_id)); +diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id)); +diesel::joinable!(capability_sets -> tenants (tenant_id)); diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(document_asset_objects -> tenants (tenant_id)); diesel::joinable!(document_assets -> document_versions (document_version_id)); @@ -289,15 +313,19 @@ diesel::joinable!(jobs -> tenants (tenant_id)); diesel::joinable!(user_sessions -> tenants (tenant_id)); diesel::joinable!(user_sessions -> users (user_id)); diesel::joinable!(tags -> tenants (tenant_id)); +diesel::joinable!(user_memberships -> capability_sets (capability_set_id)); diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> users (user_id)); diesel::joinable!(user_passkeys -> users (user_id)); diesel::joinable!(webauthn_challenges -> users (user_id)); diesel::joinable!(api_tokens -> tenants (tenant_id)); +diesel::joinable!(api_tokens -> capability_sets (capability_set_id)); diesel::joinable!(api_tokens -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( correspondents, + capability_set_capabilities, + capability_sets, document_asset_objects, document_assets, document_correspondents, diff --git a/backend/src/workers/tenants.rs b/backend/src/workers/tenants.rs index 59529d8..d8b542b 100644 --- a/backend/src/workers/tenants.rs +++ b/backend/src/workers/tenants.rs @@ -9,7 +9,8 @@ use tracing::warn; use uuid::Uuid; use crate::auth::capability_sets::{ - ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities, + ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities, + webdav_capabilities, }; use crate::documents::search::ensure_quickwit_index; use crate::jobs::JOB_PROVISION_TENANT; @@ -161,6 +162,19 @@ impl JobHandler for ProvisionTenantJob { }; } + if let Err(err) = ensure_capability_set(&mut conn, tenant.id, readonly_capabilities()) { + warn!( + job_id = %job.id, + tenant_id = %tenant.id, + error = ?err, + "failed to ensure readonly capability set during provisioning" + ); + return JobExecution::Retry { + delay: std::time::Duration::from_secs(30), + error: "readonly capability set unavailable".into(), + }; + } + if let Err(err) = ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()) { warn!( job_id = %job.id, diff --git a/backend/tests/capability_sets_flow.rs b/backend/tests/capability_sets_flow.rs index 5a70762..43a0f91 100644 --- a/backend/tests/capability_sets_flow.rs +++ b/backend/tests/capability_sets_flow.rs @@ -37,6 +37,7 @@ async fn capability_set_crud_flow() -> Result<()> { .map(|set| set.id) .expect("owner set present"); assert!(sets.iter().any(|set| set.slug == "user")); + assert!(sets.iter().any(|set| set.slug == "readonly")); assert!(sets.iter().any(|set| set.slug == "webdav")); // Create a new capability set. diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index fe9ec8f..0aee45c 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -17,7 +17,8 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use http_body_util::BodyExt; use once_cell::sync::Lazy; use papercrate::auth::capability_sets::{ - ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities, + ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities, + webdav_capabilities, }; use papercrate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind}; use papercrate::config::AppConfig; @@ -360,6 +361,8 @@ impl TestApp { .map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?; ensure_capability_set(&mut conn, tenant_id, user_capabilities()) .map_err(|err| anyhow!("ensure user capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, readonly_capabilities()) + .map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?; ensure_capability_set(&mut conn, tenant_id, webdav_capabilities()) .map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?; diff --git a/docs/api.txt b/docs/api.txt index c844778..94ca71b 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -25,7 +25,8 @@ Documents - POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents. - GET /api/documents/:id - Retrieve metadata and current version details for a document. - PATCH /api/documents/:id - Update document metadata (currently title). -- DELETE /api/documents/:id - Soft-delete a document. +- POST /api/documents/:id/trash - Move a document to trash (soft delete, reversible). +- DELETE /api/documents/:id - Permanently erase a trashed document. Returns 202 Accepted, queues a purge job, and fails with 409 if the document is still active. - PATCH /api/documents/:id/folder - Move a document to another folder. - POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": }` to send it to a specific folder; defaults to the original folder or root if missing. - GET /api/documents/:id/versions - List version history for a document. @@ -67,4 +68,4 @@ Correspondents - GET /api/correspondents - List correspondents with usage totals. - POST /api/correspondents - Create a correspondent (name + optional metadata JSON). - PATCH /api/correspondents/:id - Update name and/or metadata. -- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document. +- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document. \ No newline at end of file diff --git a/docs/capability_sets.md b/docs/capability_sets.md index 7ed6c0a..1481c67 100644 --- a/docs/capability_sets.md +++ b/docs/capability_sets.md @@ -28,10 +28,11 @@ All capabilities live in the `ApiCapability` enum. The current list is: ## Default Sets -Provisioning (and the test harness) seed three system capability sets per tenant: +Provisioning (and the test harness) seed four system capability sets per tenant: - `owner` — contains the full set above. Tenant owners, admin users, and freshly minted API tokens effectively get unrestricted access. - `user` — the default interactive role: full document/tag/correspondent/profile access, but no capability-set or WebDAV write privileges. +- `readonly` — interactive but read-only: document/folder/tag/correspondent reads plus WebDAV downloads, but no modifying routes. - `webdav` — contains only `webdav:read`. WebDAV backup scripts can bind to this set for read-only access. System sets are flagged with `is_system = true` and cannot be modified or deleted via the API.