From 480bc20ae7e3cb875504864fbb8b40b3f67d7c5a Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 5 Nov 2025 12:32:10 +0100 Subject: [PATCH] caps and delete --- .../202511010003_capability_sets/down.sql | 28 ++ .../202511010003_capability_sets/up.sql | 142 +++++++ .../202511010004_purge_document_jobs/down.sql | 1 + .../202511010004_purge_document_jobs/up.sql | 8 + backend/src/auth/api_tokens.rs | 63 ++-- backend/src/auth/capability_guard.rs | 120 ++++++ backend/src/auth/capability_sets.rs | 304 +++++++++++++++ backend/src/auth/jwt.rs | 34 +- backend/src/auth/mod.rs | 27 ++ backend/src/bin/admin.rs | 6 + backend/src/jobs.rs | 1 + backend/src/models.rs | 203 ++++++++-- backend/src/openapi.rs | 10 +- backend/src/routes/auth.rs | 64 +++- backend/src/routes/capability_sets.rs | 349 ++++++++++++++++++ backend/src/routes/documents.rs | 55 ++- backend/src/routes/mod.rs | 276 ++++++++++++-- backend/src/routes/profile.rs | 52 ++- backend/src/routes/webdav/mod.rs | 4 +- backend/src/workers/mod.rs | 2 + backend/src/workers/purge.rs | 239 ++++++++++++ backend/src/workers/tenants.rs | 47 +++ backend/tests/api_tokens_flow.rs | 116 +++++- backend/tests/auth_flow.rs | 32 +- backend/tests/capability_guards_flow.rs | 106 ++++++ backend/tests/capability_sets_flow.rs | 104 ++++++ backend/tests/common/mod.rs | 207 +++++++---- backend/tests/documents_flow.rs | 256 ++++++++++--- backend/tests/tags_flow.rs | 9 +- docs/capability_sets.md | 99 +++++ 30 files changed, 2702 insertions(+), 262 deletions(-) create mode 100644 backend/migrations/202511010003_capability_sets/down.sql create mode 100644 backend/migrations/202511010003_capability_sets/up.sql create mode 100644 backend/migrations/202511010004_purge_document_jobs/down.sql create mode 100644 backend/migrations/202511010004_purge_document_jobs/up.sql create mode 100644 backend/src/auth/capability_guard.rs create mode 100644 backend/src/auth/capability_sets.rs create mode 100644 backend/src/routes/capability_sets.rs create mode 100644 backend/src/workers/purge.rs create mode 100644 backend/tests/capability_guards_flow.rs create mode 100644 backend/tests/capability_sets_flow.rs create mode 100644 docs/capability_sets.md diff --git a/backend/migrations/202511010003_capability_sets/down.sql b/backend/migrations/202511010003_capability_sets/down.sql new file mode 100644 index 0000000..d20033c --- /dev/null +++ b/backend/migrations/202511010003_capability_sets/down.sql @@ -0,0 +1,28 @@ +CREATE TYPE api_token_capability AS ENUM ('api', 'webdav'); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[]; + +UPDATE tenant.api_tokens t +SET capabilities = ARRAY['api']::api_token_capability[] +FROM tenant.capability_sets cs +WHERE t.capability_set_id = cs.id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capabilities = ARRAY['webdav']::api_token_capability[] +FROM tenant.capability_sets cs +WHERE t.capability_set_id = cs.id + AND cs.slug = 'webdav' + AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0); + +ALTER TABLE tenant.api_tokens + DROP COLUMN capability_set_id; + +ALTER TABLE tenant.user_memberships + DROP COLUMN capability_set_id; + +DROP TABLE IF EXISTS tenant.capability_set_capabilities; +DROP TABLE IF EXISTS tenant.capability_sets; + +DROP TYPE IF EXISTS api_capability; diff --git a/backend/migrations/202511010003_capability_sets/up.sql b/backend/migrations/202511010003_capability_sets/up.sql new file mode 100644 index 0000000..ba8c82e --- /dev/null +++ b/backend/migrations/202511010003_capability_sets/up.sql @@ -0,0 +1,142 @@ +CREATE TYPE api_capability AS ENUM ( + 'documents:read', + 'documents:edit', + 'documents:write', + 'documents:upload', + 'folders:read', + 'folders:edit', + 'folders:write', + 'tags:read', + 'tags:edit', + 'tags:write', + 'correspondents:read', + 'correspondents:edit', + 'correspondents:write', + 'profile:read', + 'profile:write', + 'webdav:read', + 'webdav:write', + 'capability_sets:read', + 'capability_sets:write' +); + +CREATE TABLE tenant.capability_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + cap_version INT NOT NULL DEFAULT 1, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, slug) +); + +CREATE TABLE tenant.capability_set_capabilities ( + capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE, + capability api_capability NOT NULL, + PRIMARY KEY (capability_set_id, capability) +); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id); + +ALTER TABLE tenant.user_memberships + ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id); + +WITH owner_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'owner', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +user_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'user', 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 + FROM shared.tenants + RETURNING id, tenant_id +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT set_id, + capability +FROM ( + SELECT os.id AS set_id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'documents:edit'::api_capability, + 'documents:write'::api_capability, + 'documents:upload'::api_capability, + 'folders:read'::api_capability, + 'folders:edit'::api_capability, + 'folders:write'::api_capability, + 'tags:read'::api_capability, + 'tags:edit'::api_capability, + 'tags:write'::api_capability, + 'correspondents:read'::api_capability, + 'correspondents:edit'::api_capability, + 'correspondents:write'::api_capability, + 'profile:read'::api_capability, + 'profile:write'::api_capability, + 'webdav:read'::api_capability, + 'webdav:write'::api_capability, + 'capability_sets:read'::api_capability, + 'capability_sets:write'::api_capability + ]) AS capability + FROM owner_sets os + UNION ALL + SELECT us.id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'documents:edit'::api_capability, + 'documents:write'::api_capability, + 'documents:upload'::api_capability, + 'folders:read'::api_capability, + 'folders:edit'::api_capability, + 'folders:write'::api_capability, + 'tags:read'::api_capability, + 'tags:edit'::api_capability, + 'tags:write'::api_capability, + 'correspondents:read'::api_capability, + 'correspondents:edit'::api_capability, + 'correspondents:write'::api_capability, + 'profile:read'::api_capability, + 'profile:write'::api_capability + ]) AS capability + FROM user_sets us + UNION ALL + SELECT ws.id, + UNNEST(ARRAY['webdav:read'::api_capability]) AS capability + FROM webdav_sets ws +) seeded; + +UPDATE tenant.user_memberships um +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = um.tenant_id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = t.tenant_id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = t.tenant_id + AND cs.slug = 'webdav' + AND t.capability_set_id IS NULL; + +ALTER TABLE tenant.api_tokens + ALTER COLUMN capability_set_id SET NOT NULL; + +ALTER TABLE tenant.api_tokens + DROP COLUMN capabilities; + +DROP TYPE IF EXISTS api_token_capability; diff --git a/backend/migrations/202511010004_purge_document_jobs/down.sql b/backend/migrations/202511010004_purge_document_jobs/down.sql new file mode 100644 index 0000000..0bba65c --- /dev/null +++ b/backend/migrations/202511010004_purge_document_jobs/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique; diff --git a/backend/migrations/202511010004_purge_document_jobs/up.sql b/backend/migrations/202511010004_purge_document_jobs/up.sql new file mode 100644 index 0000000..240b72c --- /dev/null +++ b/backend/migrations/202511010004_purge_document_jobs/up.sql @@ -0,0 +1,8 @@ +CREATE UNIQUE INDEX jobs_purge_document_pending_unique + ON shared.jobs ( + tenant_id, + ((payload ->> 'document_id')::uuid) + ) + WHERE job_type = 'purge-document' + AND payload ? 'document_id' + AND status IN ('queued', 'processing'); diff --git a/backend/src/auth/api_tokens.rs b/backend/src/auth/api_tokens.rs index 4de54dc..e69d265 100644 --- a/backend/src/auth/api_tokens.rs +++ b/backend/src/auth/api_tokens.rs @@ -9,8 +9,11 @@ use rand::RngCore; use uuid::Uuid; use crate::{ + auth::capability_sets::{ + ensure_capability_set, load_capabilities_for_set, normalize_capabilities, + }, error::AppError, - models::{ApiToken, ApiTokenCapability, NewApiToken}, + models::{ApiCapability, ApiToken, NewApiToken}, schema::api_tokens, state::PgPooledConnection, tenants::{apply_api_token_prefix, clear_api_token_prefix}, @@ -34,9 +37,10 @@ pub fn create_api_token( tenant_id: Uuid, label: Option, expires_at: Option, - capabilities: Vec, + capabilities: Vec, ) -> Result { let capabilities = normalize_capabilities(capabilities)?; + let capability_set = ensure_capability_set(conn, tenant_id, &capabilities)?; let raw_secret = generate_secret()?; let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); @@ -49,7 +53,7 @@ pub fn create_api_token( token_hash, label, expires_at, - capabilities, + capability_set_id: capability_set.id, }; let record = diesel::insert_into(api_tokens::table) @@ -122,7 +126,7 @@ pub fn update_api_token_capabilities( token_id: Uuid, user_id: Uuid, tenant_id: Option, - capabilities: Vec, + capabilities: Vec, ) -> Result { let capabilities = normalize_capabilities(capabilities)?; @@ -134,8 +138,10 @@ pub fn update_api_token_capabilities( )); } + let capability_set = ensure_capability_set(conn, token.tenant_id, &capabilities)?; + let updated = diesel::update(api_tokens::table.find(token.id)) - .set(api_tokens::capabilities.eq(capabilities)) + .set(api_tokens::capability_set_id.eq(capability_set.id)) .get_result::(conn)?; Ok(updated) @@ -147,7 +153,7 @@ pub fn find_active_token_by_secret( conn: &mut PgPooledConnection, tenant_id: Option, secret: &str, - required_capability: ApiTokenCapability, + required_capability: ApiCapability, ) -> Result, AppError> { if secret.len() < TOKEN_PREFIX_LENGTH { return Ok(None); @@ -175,7 +181,8 @@ pub fn find_active_token_by_secret( })?; for token in candidates { - if !token.capabilities.contains(&required_capability) { + let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?; + if !capabilities.contains(&required_capability) { continue; } @@ -218,23 +225,6 @@ pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result, -) -> Result, AppError> { - if capabilities.is_empty() { - return Err(AppError::bad_request("at least one capability is required")); - } - - let mut unique = Vec::new(); - for capability in capabilities { - if !unique.contains(&capability) { - unique.push(capability); - } - } - - Ok(unique) -} - fn find_user_token( conn: &mut PgPooledConnection, token_id: Uuid, @@ -299,6 +289,9 @@ fn hash_secret(secret: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::auth::capability_sets::{ + compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities, + }; #[test] fn generated_secret_has_expected_length() { @@ -316,15 +309,10 @@ mod tests { #[test] fn normalize_capabilities_deduplicates() { - let caps = normalize_capabilities(vec![ - ApiTokenCapability::Api, - ApiTokenCapability::Webdav, - ApiTokenCapability::Api, - ]) - .unwrap(); - assert_eq!(caps.len(), 2); - assert!(caps.contains(&ApiTokenCapability::Api)); - assert!(caps.contains(&ApiTokenCapability::Webdav)); + let mut caps = owner_capabilities().to_vec(); + caps.push(ApiCapability::DocumentsRead); + let normalized = normalize_capabilities(caps).unwrap(); + assert_eq!(normalized.len(), owner_capabilities().len()); } #[test] @@ -332,6 +320,15 @@ mod tests { assert!(normalize_capabilities(Vec::new()).is_err()); } + #[test] + fn compute_slug_matches_system_sets() { + let owner_slug = compute_slug(owner_capabilities()); + assert_eq!(owner_slug, "owner"); + + let webdav_slug = compute_slug(webdav_capabilities()); + assert_eq!(webdav_slug, "webdav"); + } + #[test] fn prefix_length_is_less_than_secret_length() { assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); diff --git a/backend/src/auth/capability_guard.rs b/backend/src/auth/capability_guard.rs new file mode 100644 index 0000000..9abea57 --- /dev/null +++ b/backend/src/auth/capability_guard.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use axum::{ + http::{Request, StatusCode}, + response::IntoResponse, +}; +use tower::{Layer, Service}; + +use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability}; + +#[derive(Clone, Copy)] +pub enum CapabilityStrategy { + All, + Any, +} + +#[derive(Clone)] +pub struct RequireCapabilitiesLayer { + required: Arc>, + strategy: CapabilityStrategy, +} + +impl RequireCapabilitiesLayer { + pub fn all(caps: I) -> Self + where + I: IntoIterator, + { + Self { + required: Arc::new(caps.into_iter().collect()), + strategy: CapabilityStrategy::All, + } + } + + pub fn any(caps: I) -> Self + where + I: IntoIterator, + { + Self { + required: Arc::new(caps.into_iter().collect()), + strategy: CapabilityStrategy::Any, + } + } +} + +impl Layer for RequireCapabilitiesLayer { + type Service = RequireCapabilities; + + fn layer(&self, inner: S) -> Self::Service { + RequireCapabilities { + inner, + required: Arc::clone(&self.required), + strategy: self.strategy, + } + } +} + +#[derive(Clone)] +pub struct RequireCapabilities { + inner: S, + required: Arc>, + strategy: CapabilityStrategy, +} + +impl Service> for RequireCapabilities +where + S: Service, Response = axum::response::Response> + Send, + S::Future: Send + 'static, + B: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + if self.required.is_empty() { + let fut = self.inner.call(req); + return Box::pin(async move { fut.await }); + } + + let (parts, body) = req.into_parts(); + let user = match parts.extensions.get::() { + Some(user) => user, + None => { + let response = AppError::unauthorized().into_response(); + return Box::pin(async move { Ok(response) }); + } + }; + + let allowed = match self.strategy { + CapabilityStrategy::All => self + .required + .iter() + .all(|cap| user.capabilities.contains(cap)), + CapabilityStrategy::Any => self + .required + .iter() + .any(|cap| user.capabilities.contains(cap)), + }; + + if !allowed { + let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability") + .with_code("missing_capability") + .into_response(); + return Box::pin(async move { Ok(response) }); + } + + let req = Request::from_parts(parts, body); + let fut = self.inner.call(req); + Box::pin(async move { fut.await }) + } +} diff --git a/backend/src/auth/capability_sets.rs b/backend/src/auth/capability_sets.rs new file mode 100644 index 0000000..36b03c2 --- /dev/null +++ b/backend/src/auth/capability_sets.rs @@ -0,0 +1,304 @@ +use chrono::Utc; +use diesel::{pg::Pg, prelude::*, Connection}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + error::AppError, + models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability}, + schema::{ + capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets, + capability_sets::dsl as cs_dsl, + }, +}; + +const OWNER_CAPABILITIES: [ApiCapability; 19] = [ + ApiCapability::CorrespondentsEdit, + ApiCapability::CorrespondentsRead, + ApiCapability::CorrespondentsWrite, + ApiCapability::DocumentsEdit, + ApiCapability::DocumentsRead, + ApiCapability::DocumentsUpload, + ApiCapability::DocumentsWrite, + ApiCapability::FoldersEdit, + ApiCapability::FoldersRead, + ApiCapability::FoldersWrite, + ApiCapability::ProfileRead, + ApiCapability::ProfileWrite, + ApiCapability::TagsEdit, + ApiCapability::TagsRead, + ApiCapability::TagsWrite, + ApiCapability::WebdavRead, + ApiCapability::WebdavWrite, + ApiCapability::CapabilitySetsRead, + ApiCapability::CapabilitySetsWrite, +]; + +const USER_CAPABILITIES: [ApiCapability; 16] = [ + ApiCapability::CorrespondentsEdit, + ApiCapability::CorrespondentsRead, + ApiCapability::CorrespondentsWrite, + ApiCapability::DocumentsEdit, + ApiCapability::DocumentsRead, + ApiCapability::DocumentsUpload, + ApiCapability::DocumentsWrite, + ApiCapability::FoldersEdit, + ApiCapability::FoldersRead, + ApiCapability::FoldersWrite, + ApiCapability::ProfileRead, + ApiCapability::ProfileWrite, + ApiCapability::TagsEdit, + ApiCapability::TagsRead, + ApiCapability::TagsWrite, + ApiCapability::WebdavRead, +]; + +const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead]; + +pub fn owner_capabilities() -> &'static [ApiCapability] { + &OWNER_CAPABILITIES +} + +pub fn user_capabilities() -> &'static [ApiCapability] { + &USER_CAPABILITIES +} + +pub fn webdav_capabilities() -> &'static [ApiCapability] { + &WEBDAV_CAPABILITIES +} + +pub fn is_system_slug(slug: &str) -> bool { + matches!(slug, "owner" | "user" | "webdav") +} + +pub fn create_capability_set( + conn: &mut C, + tenant_id: Uuid, + slug: &str, + capabilities: Vec, +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + let normalized = normalize_capabilities(capabilities)?; + + conn.transaction::(|conn| { + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(slug)) + .first::(conn) + .optional() + .map_err(AppError::from)? + .is_some() + { + return Err(AppError::conflict("capability set slug already exists")); + } + + let set = NewCapabilitySet { + id: Uuid::new_v4(), + tenant_id, + slug: slug.to_owned(), + cap_version: 1, + is_system: false, + }; + + diesel::insert_into(capability_sets::table) + .values(&set) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, &normalized)?; + + capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from) + }) +} + +pub fn normalize_capabilities( + mut capabilities: Vec, +) -> Result, AppError> { + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str())); + capabilities.dedup(); + Ok(capabilities) +} + +pub fn load_capabilities_for_set( + conn: &mut C, + capability_set_id: Uuid, +) -> Result, AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + let mut capabilities: Vec = csc_dsl::capability_set_capabilities + .filter(csc_dsl::capability_set_id.eq(capability_set_id)) + .select(csc_dsl::capability) + .load(conn) + .map_err(AppError::from)?; + + capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str())); + Ok(capabilities) +} + +pub fn ensure_capability_set( + conn: &mut C, + tenant_id: Uuid, + capabilities: &[ApiCapability], +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = compute_slug(capabilities); + conn.transaction::(|conn| { + if let Some(existing) = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&slug)) + .first::(conn) + .optional() + .map_err(AppError::from)? + { + ensure_capability_membership(conn, &existing, capabilities)?; + return Ok(existing); + } + + let set = NewCapabilitySet { + id: Uuid::new_v4(), + tenant_id, + slug: slug.clone(), + cap_version: 1, + is_system: slug == "owner" || slug == "user" || slug == "webdav", + }; + + diesel::insert_into(capability_sets::table) + .values(&set) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, capabilities)?; + + Ok(capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from)?) + }) +} + +pub fn refresh_capability_set( + conn: &mut C, + set: &CapabilitySet, + capabilities: &[ApiCapability], +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + conn.transaction::(|conn| { + diesel::delete( + csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)), + ) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, capabilities)?; + + diesel::update(capability_sets::table.find(set.id)) + .set(( + cs_dsl::cap_version.eq(set.cap_version + 1), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_err(AppError::from)?; + + capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from) + }) +} + +pub fn load_capability_set(conn: &mut C, id: Uuid) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + capability_sets::table + .find(id) + .first::(conn) + .map_err(AppError::from) +} + +pub fn compute_slug(capabilities: &[ApiCapability]) -> String { + if capabilities == owner_capabilities() { + return "owner".to_string(); + } + + if capabilities == user_capabilities() { + return "user".to_string(); + } + + if capabilities == webdav_capabilities() { + return "webdav".to_string(); + } + + let joined = capabilities + .iter() + .map(|cap| cap.as_str()) + .collect::>() + .join(","); + + let digest = Sha256::digest(joined.as_bytes()); + let hex = hex::encode(digest); + format!("caps-{}", &hex[..12]) +} + +fn ensure_capability_membership( + conn: &mut C, + set: &CapabilitySet, + desired: &[ApiCapability], +) -> Result<(), AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + let current = load_capabilities_for_set(conn, set.id)?; + if current == desired { + return Ok(()); + } + + let _ = refresh_capability_set(conn, set, desired)?; + Ok(()) +} + +fn persist_capabilities( + conn: &mut C, + set_id: Uuid, + capabilities: &[ApiCapability], +) -> Result<(), AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let records: Vec = capabilities + .iter() + .map(|cap| NewCapabilitySetCapability { + capability_set_id: set_id, + capability: *cap, + }) + .collect(); + + diesel::insert_into(capability_set_capabilities::table) + .values(&records) + .execute(conn) + .map_err(AppError::from)?; + + Ok(()) +} diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index 86812ae..34aad4e 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -6,6 +6,24 @@ use uuid::Uuid; use crate::config::AppConfig; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalKind { + UserSession, + ApiToken, +} + +#[derive(Debug, Clone)] +pub struct AccessTokenContext { + pub user_id: Uuid, + pub tenant_id: Uuid, + pub username: String, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, +} + #[derive(Clone)] pub struct JwtService { encoding: EncodingKey, @@ -38,13 +56,17 @@ impl JwtService { }) } - pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result { + pub fn generate_token(&self, context: AccessTokenContext) -> Result { let now = Utc::now(); let exp = now + self.expiry; let claims = Claims { - sub: user_id, - tenant_id, - username: username.to_owned(), + sub: context.user_id, + tenant_id: context.tenant_id, + username: context.username, + principal_kind: context.principal_kind, + principal_id: context.principal_id, + capability_set_id: context.capability_set_id, + cap_version: context.cap_version, iss: self.issuer.clone(), aud: self.audience.clone(), iat: now.timestamp() as usize, @@ -148,6 +170,10 @@ pub struct Claims { pub sub: Uuid, pub tenant_id: Uuid, pub username: String, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, pub iss: String, pub aud: String, pub iat: usize, diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 15c2080..c5b6270 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,4 +1,6 @@ pub mod api_tokens; +pub mod capability_guard; +pub mod capability_sets; pub mod jwt; pub mod passkeys; pub mod password; @@ -10,16 +12,25 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::{ + auth::capability_sets::{load_capabilities_for_set, load_capability_set}, error::AppError, + models::ApiCapability, state::{AppState, PgPooledConnection}, }; use uuid::Uuid; +use crate::auth::jwt::PrincipalKind; + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AuthenticatedUser { pub user_id: uuid::Uuid, pub username: String, pub tenant_id: uuid::Uuid, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, + pub capabilities: Vec, } #[async_trait] @@ -44,10 +55,26 @@ impl FromRequestParts for AuthenticatedUser { .verify_token(bearer.token()) .map_err(|_| AppError::unauthorized())?; + let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?; + let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id) + .map_err(|_| AppError::unauthorized())?; + + if capability_set.cap_version != claims.cap_version { + return Err(AppError::unauthorized()); + } + + let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id) + .map_err(|_| AppError::unauthorized())?; + let user = AuthenticatedUser { user_id: claims.sub, username: claims.username, tenant_id: claims.tenant_id, + principal_kind: claims.principal_kind, + principal_id: claims.principal_id, + capability_set_id: claims.capability_set_id, + cap_version: claims.cap_version, + capabilities, }; parts.extensions.insert(user.clone()); diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index 75861e5..1e02fe9 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -10,6 +10,7 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use papercrate::{ + auth::capability_sets::{ensure_capability_set, owner_capabilities}, config::AppConfig, db::{self, PgPool}, documents::search::ensure_quickwit_index, @@ -363,10 +364,15 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result< .optional()? .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + let owner_capability_set_id = ensure_capability_set(&mut conn, tenant.id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + let membership = NewUserMembership { id: Uuid::new_v4(), user_id: user.id, tenant_id: tenant.id, + capability_set_id: Some(owner_capability_set_id), }; diesel::insert_into(user_memberships::table) diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index 14c3a4d..b6c21d4 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -20,6 +20,7 @@ pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text"; pub const JOB_PROVISION_TENANT: &str = "provision-tenant"; +pub const JOB_PURGE_DOCUMENT: &str = "purge-document"; #[derive(Debug, Error)] pub enum JobQueueError { diff --git a/backend/src/models.rs b/backend/src/models.rs index 7dddb83..9b57f20 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use utoipa::ToSchema; use crate::schema::sql_types::{ - ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql, + ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql, }; use crate::schema::*; @@ -29,6 +29,7 @@ pub struct UserMembership { pub tenant_id: Uuid, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, + pub capability_set_id: Option, } #[derive(Debug, Insertable)] @@ -37,6 +38,7 @@ pub struct NewUserMembership { pub id: Uuid, pub user_id: Uuid, pub tenant_id: Uuid, + pub capability_set_id: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] @@ -57,13 +59,58 @@ pub enum MagicTokenKind { } #[derive( - Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, + ToSchema, )] -#[diesel(sql_type = ApiTokenCapabilitySql)] -#[serde(rename_all = "snake_case")] -pub enum ApiTokenCapability { - Api, - Webdav, +#[diesel(sql_type = ApiCapabilitySql)] +pub enum ApiCapability { + #[serde(rename = "documents:read")] + DocumentsRead, + #[serde(rename = "documents:edit")] + DocumentsEdit, + #[serde(rename = "documents:write")] + DocumentsWrite, + #[serde(rename = "documents:upload")] + DocumentsUpload, + #[serde(rename = "folders:read")] + FoldersRead, + #[serde(rename = "folders:edit")] + FoldersEdit, + #[serde(rename = "folders:write")] + FoldersWrite, + #[serde(rename = "tags:read")] + TagsRead, + #[serde(rename = "tags:edit")] + TagsEdit, + #[serde(rename = "tags:write")] + TagsWrite, + #[serde(rename = "correspondents:read")] + CorrespondentsRead, + #[serde(rename = "correspondents:edit")] + CorrespondentsEdit, + #[serde(rename = "correspondents:write")] + CorrespondentsWrite, + #[serde(rename = "profile:read")] + ProfileRead, + #[serde(rename = "profile:write")] + ProfileWrite, + #[serde(rename = "webdav:read")] + WebdavRead, + #[serde(rename = "webdav:write")] + WebdavWrite, + #[serde(rename = "capability_sets:read")] + CapabilitySetsRead, + #[serde(rename = "capability_sets:write")] + CapabilitySetsWrite, } impl MagicTokenKind { @@ -79,16 +126,53 @@ impl MagicTokenKind { } } -impl ApiTokenCapability { +impl ApiCapability { pub fn as_str(&self) -> &'static str { match self { - ApiTokenCapability::Api => "api", - ApiTokenCapability::Webdav => "webdav", + ApiCapability::DocumentsRead => "documents:read", + ApiCapability::DocumentsEdit => "documents:edit", + ApiCapability::DocumentsWrite => "documents:write", + ApiCapability::DocumentsUpload => "documents:upload", + ApiCapability::FoldersRead => "folders:read", + ApiCapability::FoldersEdit => "folders:edit", + ApiCapability::FoldersWrite => "folders:write", + ApiCapability::TagsRead => "tags:read", + ApiCapability::TagsEdit => "tags:edit", + ApiCapability::TagsWrite => "tags:write", + ApiCapability::CorrespondentsRead => "correspondents:read", + ApiCapability::CorrespondentsEdit => "correspondents:edit", + ApiCapability::CorrespondentsWrite => "correspondents:write", + ApiCapability::ProfileRead => "profile:read", + ApiCapability::ProfileWrite => "profile:write", + ApiCapability::WebdavRead => "webdav:read", + ApiCapability::WebdavWrite => "webdav:write", + ApiCapability::CapabilitySetsRead => "capability_sets:read", + ApiCapability::CapabilitySetsWrite => "capability_sets:write", } } pub fn variants() -> &'static [&'static str] { - &["api", "webdav"] + &[ + "documents:read", + "documents:edit", + "documents:write", + "documents:upload", + "folders:read", + "folders:edit", + "folders:write", + "tags:read", + "tags:edit", + "tags:write", + "correspondents:read", + "correspondents:edit", + "correspondents:write", + "profile:read", + "profile:write", + "webdav:read", + "webdav:write", + "capability_sets:read", + "capability_sets:write", + ] } } @@ -98,7 +182,7 @@ impl fmt::Display for MagicTokenKind { } } -impl fmt::Display for ApiTokenCapability { +impl fmt::Display for ApiCapability { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } @@ -111,7 +195,7 @@ impl ToSql for MagicTokenKind { } } -impl ToSql for ApiTokenCapability { +impl ToSql for ApiCapability { fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { out.write_all(self.as_str().as_bytes())?; Ok(IsNull::No) @@ -131,14 +215,31 @@ impl FromSql for MagicTokenKind { } } -impl FromSql for ApiTokenCapability { +impl FromSql for ApiCapability { fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { match std::str::from_utf8(bytes.as_bytes())? { - "api" => Ok(ApiTokenCapability::Api), - "webdav" => Ok(ApiTokenCapability::Webdav), + "documents:read" => Ok(ApiCapability::DocumentsRead), + "documents:edit" => Ok(ApiCapability::DocumentsEdit), + "documents:write" => Ok(ApiCapability::DocumentsWrite), + "documents:upload" => Ok(ApiCapability::DocumentsUpload), + "folders:read" => Ok(ApiCapability::FoldersRead), + "folders:edit" => Ok(ApiCapability::FoldersEdit), + "folders:write" => Ok(ApiCapability::FoldersWrite), + "tags:read" => Ok(ApiCapability::TagsRead), + "tags:edit" => Ok(ApiCapability::TagsEdit), + "tags:write" => Ok(ApiCapability::TagsWrite), + "correspondents:read" => Ok(ApiCapability::CorrespondentsRead), + "correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit), + "correspondents:write" => Ok(ApiCapability::CorrespondentsWrite), + "profile:read" => Ok(ApiCapability::ProfileRead), + "profile:write" => Ok(ApiCapability::ProfileWrite), + "webdav:read" => Ok(ApiCapability::WebdavRead), + "webdav:write" => Ok(ApiCapability::WebdavWrite), + "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), + "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), other => Err(Box::new(std::io::Error::new( std::io::ErrorKind::InvalidData, - format!("invalid api_token_capability '{other}'"), + format!("invalid api_capability '{other}'"), ))), } } @@ -156,14 +257,31 @@ impl str::FromStr for MagicTokenKind { } } -impl str::FromStr for ApiTokenCapability { +impl str::FromStr for ApiCapability { type Err = &'static str; fn from_str(value: &str) -> Result { match value { - "api" => Ok(ApiTokenCapability::Api), - "webdav" => Ok(ApiTokenCapability::Webdav), - _ => Err("unsupported api token capability"), + "documents:read" => Ok(ApiCapability::DocumentsRead), + "documents:edit" => Ok(ApiCapability::DocumentsEdit), + "documents:write" => Ok(ApiCapability::DocumentsWrite), + "documents:upload" => Ok(ApiCapability::DocumentsUpload), + "folders:read" => Ok(ApiCapability::FoldersRead), + "folders:edit" => Ok(ApiCapability::FoldersEdit), + "folders:write" => Ok(ApiCapability::FoldersWrite), + "tags:read" => Ok(ApiCapability::TagsRead), + "tags:edit" => Ok(ApiCapability::TagsEdit), + "tags:write" => Ok(ApiCapability::TagsWrite), + "correspondents:read" => Ok(ApiCapability::CorrespondentsRead), + "correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit), + "correspondents:write" => Ok(ApiCapability::CorrespondentsWrite), + "profile:read" => Ok(ApiCapability::ProfileRead), + "profile:write" => Ok(ApiCapability::ProfileWrite), + "webdav:read" => Ok(ApiCapability::WebdavRead), + "webdav:write" => Ok(ApiCapability::WebdavWrite), + "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), + "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + _ => Err("unsupported api capability"), } } } @@ -322,7 +440,46 @@ pub struct ApiToken { pub last_used_at: Option, pub expires_at: Option, pub revoked_at: Option, - pub capabilities: Vec, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = capability_sets)] +#[diesel(belongs_to(Tenant))] +pub struct CapabilitySet { + pub id: Uuid, + pub tenant_id: Uuid, + pub slug: String, + pub cap_version: i32, + pub is_system: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = capability_sets)] +pub struct NewCapabilitySet { + pub id: Uuid, + pub tenant_id: Uuid, + pub slug: String, + pub cap_version: i32, + pub is_system: bool, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = capability_set_capabilities)] +#[diesel(primary_key(capability_set_id, capability))] +#[diesel(belongs_to(CapabilitySet, foreign_key = capability_set_id))] +pub struct CapabilitySetCapability { + pub capability_set_id: Uuid, + pub capability: ApiCapability, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = capability_set_capabilities)] +pub struct NewCapabilitySetCapability { + pub capability_set_id: Uuid, + pub capability: ApiCapability, } #[derive(Debug, Insertable)] @@ -335,7 +492,7 @@ pub struct NewApiToken { pub token_hash: String, pub label: Option, pub expires_at: Option, - pub capabilities: Vec, + pub capability_set_id: Uuid, } #[derive(Debug, Clone, Queryable, Identifiable)] diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 5ae4b49..f244a48 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -12,6 +12,7 @@ impl OpenApi for ApiDoc { doc.merge(crate::routes::tags::TagsApiDoc::openapi()); doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi()); doc.merge(crate::routes::profile::ProfileApiDoc::openapi()); + doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi()); doc.info = InfoBuilder::new() .title("Papercrate API") @@ -51,6 +52,10 @@ impl OpenApi for ApiDoc { .name("Profile") .description(Some("User profile and WebDAV tokens")) .build(), + TagBuilder::new() + .name("Capability Sets") + .description(Some("Capability set management")) + .build(), ]); doc @@ -68,12 +73,15 @@ pub mod schemas { DocumentVersionDetailResponse, DocumentVersionResponse, }; pub use crate::documents::correspondents::DocumentCorrespondentResponse; - pub use crate::models::ApiTokenCapability; + pub use crate::models::ApiCapability; pub use crate::routes::auth::{ ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, }; + pub use crate::routes::capability_sets::{ + CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest, + }; pub use crate::routes::correspondents::{ CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest, UpdateCorrespondentRequest, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index ef9ee93..feecf1b 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -19,6 +19,8 @@ use uuid::Uuid; use crate::{ auth::{ api_tokens::{find_active_token_by_secret, touch_api_token}, + capability_sets::load_capability_set, + jwt::{AccessTokenContext, PrincipalKind}, passkeys::{ AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, @@ -27,8 +29,8 @@ use crate::{ }, error::{AppError, AppResult}, models::{ - ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, - User, UserSession, + ApiCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, + UserMembership, UserSession, }, schema::{ magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl, @@ -160,7 +162,7 @@ pub enum LoginResponseVariants { crate::auth::passkeys::PasskeyRegistrationFinishPayload, crate::auth::passkeys::PasskeyLoginStartPayload, crate::auth::passkeys::PasskeyLoginFinishPayload, - crate::models::ApiTokenCapability, + crate::models::ApiCapability, )) )] pub struct AuthApiDoc; @@ -230,7 +232,7 @@ pub async fn api_token_exchange( let mut conn = state.db_unscoped()?; - let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)? + let token = find_active_token_by_secret(&mut conn, None, secret, ApiCapability::ProfileRead)? .ok_or_else(AppError::unauthorized)?; let user: User = dsl::users.find(token.user_id).first(&mut conn)?; @@ -239,21 +241,36 @@ pub async fn api_token_exchange( let membership = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) - .select(memberships_dsl::tenant_id) - .first::(&mut conn) + .first::(&mut conn) .optional()?; clear_user_guc(&mut conn)?; - if membership.is_none() { - return Err(AppError::unauthorized()); - } + let membership = membership.ok_or_else(AppError::unauthorized)?; + + let membership_capability_set = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?; + let _membership_set = load_capability_set(&mut conn, membership_capability_set)?; apply_tenant_guc(&mut conn, token.tenant_id)?; touch_api_token(&mut conn, token.id)?; let access_token = state .jwt - .generate_token(user.id, token.tenant_id, &user.username) + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: token.tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::ApiToken, + principal_id: token.id, + capability_set_id: token_capability_set.id, + cap_version: token_capability_set.cap_version, + }) .map_err(AppError::from)?; let tenant_name: String = tenant_dsl::tenants @@ -856,10 +873,33 @@ fn issue_session( clear_user_guc(conn)?; clear_user_session_hash(conn)?; + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .first(conn)?; + + let capability_set_id = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let capability_set = load_capability_set(conn, capability_set_id)?; + let now = Utc::now(); + let session_id = Uuid::new_v4(); let access_token = state .jwt - .generate_token(user.id, tenant_id, &user.username) + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version: capability_set.cap_version, + }) .map_err(AppError::from)?; let tenant_name: String = tenant_dsl::tenants @@ -873,7 +913,7 @@ fn issue_session( let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); let new_session = NewUserSession { - id: Uuid::new_v4(), + id: session_id, user_id: user.id, token_hash: session_hash, issued_at: now.naive_utc(), diff --git a/backend/src/routes/capability_sets.rs b/backend/src/routes/capability_sets.rs new file mode 100644 index 0000000..bc3bb7f --- /dev/null +++ b/backend/src/routes/capability_sets.rs @@ -0,0 +1,349 @@ +use axum::{extract::Path, http::StatusCode, Json}; +use chrono::Utc; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + auth::capability_sets::{ + compute_slug, create_capability_set as create_capability_set_record, is_system_slug, + load_capabilities_for_set, normalize_capabilities, refresh_capability_set, + }, + auth::TenantScopedConn, + error::{AppError, AppResult}, + models::{ApiCapability, CapabilitySet}, + schema::{ + api_tokens, + capability_sets::{self, dsl as cs_dsl}, + user_memberships, + }, +}; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CapabilitySetResponse { + pub id: Uuid, + pub slug: String, + #[serde(default)] + pub label: Option, + pub is_system: bool, + pub cap_version: i32, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + #[serde(default)] + pub capabilities: Option>, +} + +fn normalize_slug(value: &str) -> AppResult { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("slug must not be empty")); + } + + if trimmed.len() > 64 { + return Err(AppError::bad_request("slug must not exceed 64 characters")); + } + + let mut normalized = String::with_capacity(trimmed.len()); + for ch in trimmed.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + normalized.push(ch.to_ascii_lowercase()); + } else if ch.is_whitespace() { + normalized.push('-'); + } else { + return Err(AppError::bad_request( + "slug may only contain alphanumeric characters, hyphen, or underscore", + )); + } + } + + if normalized.is_empty() { + return Err(AppError::bad_request("slug must not be empty")); + } + + Ok(normalized) +} + +fn to_response(set: CapabilitySet, capabilities: Vec) -> CapabilitySetResponse { + CapabilitySetResponse { + id: set.id, + slug: set.slug, + label: None, + is_system: set.is_system, + cap_version: set.cap_version, + capabilities, + } +} + +#[utoipa::path( + get, + path = "/api/capability-sets", + responses((status = 200, body = [CapabilitySetResponse])), + tag = "Capability Sets" +)] +pub async fn list_capability_sets( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let sets = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .order(cs_dsl::slug.asc()) + .load::(&mut conn)?; + + let mut responses = Vec::with_capacity(sets.len()); + for set in sets { + let capabilities = load_capabilities_for_set(&mut conn, set.id)?; + responses.push(to_response(set, capabilities)); + } + + Ok(Json(responses)) +} + +#[utoipa::path( + get, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")), + tag = "Capability Sets" +)] +pub async fn get_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, +) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(&mut conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::not_found(), + other => AppError::from(other), + })?; + + let capabilities = load_capabilities_for_set(&mut conn, set.id)?; + Ok(Json(to_response(set, capabilities))) +} + +#[utoipa::path( + post, + path = "/api/capability-sets", + request_body = CreateCapabilitySetRequest, + responses((status = 201, body = CapabilitySetResponse)), + tag = "Capability Sets" +)] +pub async fn create_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { + let original_caps = payload.capabilities; + let normalized_caps = normalize_capabilities(original_caps.clone())?; + if normalized_caps.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = if let Some(raw) = payload.slug { + let normalized = normalize_slug(&raw)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + normalized + } else { + let generated = compute_slug(&normalized_caps); + if is_system_slug(&generated) { + return Err(AppError::conflict( + "capabilities match a reserved system capability set", + )); + } + generated + }; + + let set = create_capability_set_record(&mut conn, tenant_id, &slug, original_caps)?; + let response = to_response(set, normalized_caps); + + Ok((StatusCode::CREATED, Json(response))) +} + +#[utoipa::path( + patch, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + request_body = UpdateCapabilitySetRequest, + responses((status = 200, body = CapabilitySetResponse)), + tag = "Capability Sets" +)] +pub async fn update_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, + Json(payload): Json, +) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(&mut conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::not_found(), + other => AppError::from(other), + })?; + + if set.is_system { + if payload.slug.is_some() || payload.capabilities.is_some() { + return Err(AppError::conflict( + "system capability sets cannot be modified", + )); + } + let capabilities = load_capabilities_for_set(&mut conn, set.id)?; + return Ok(Json(to_response(set, capabilities))); + } + + let set = conn.transaction::(|conn| { + let mut working = set.clone(); + + if let Some(slug) = &payload.slug { + let normalized = normalize_slug(slug)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&normalized)) + .filter(cs_dsl::id.ne(working.id)) + .first::(conn) + .optional() + .map_err(AppError::from)? + .is_some() + { + return Err(AppError::conflict("slug already exists")); + } + + diesel::update(cs_dsl::capability_sets.find(working.id)) + .set(( + cs_dsl::slug.eq(&normalized), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_err(AppError::from)?; + + working.slug = normalized; + } + + if let Some(capabilities) = &payload.capabilities { + let normalized = normalize_capabilities(capabilities.clone())?; + if normalized.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let updated = refresh_capability_set(conn, &working, &normalized)?; + working = updated; + } + + capability_sets::table + .find(working.id) + .first::(conn) + .map_err(AppError::from) + })?; + + let capabilities = load_capabilities_for_set(&mut conn, set.id)?; + Ok(Json(to_response(set, capabilities))) +} + +#[utoipa::path( + delete, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + responses((status = 204), (status = 409, description = "Set in use")), + tag = "Capability Sets" +)] +pub async fn delete_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, +) -> AppResult { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(&mut conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::not_found(), + other => AppError::from(other), + })?; + + if set.is_system { + return Err(AppError::conflict( + "system capability sets cannot be deleted", + )); + } + + let in_use_memberships: i64 = user_memberships::table + .filter(user_memberships::capability_set_id.eq(Some(set.id))) + .count() + .get_result(&mut conn)?; + + if in_use_memberships > 0 { + return Err(AppError::conflict( + "capability set is assigned to user memberships", + )); + } + + let in_use_tokens: i64 = api_tokens::table + .filter(api_tokens::capability_set_id.eq(set.id)) + .count() + .get_result(&mut conn)?; + + if in_use_tokens > 0 { + return Err(AppError::conflict( + "capability set is assigned to API tokens", + )); + } + + diesel::delete(cs_dsl::capability_sets.find(set.id)).execute(&mut conn)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::capability_sets::list_capability_sets, + crate::routes::capability_sets::get_capability_set, + crate::routes::capability_sets::create_capability_set, + crate::routes::capability_sets::update_capability_set, + crate::routes::capability_sets::delete_capability_set, + ), + components(schemas( + crate::routes::capability_sets::CapabilitySetResponse, + crate::routes::capability_sets::CreateCapabilitySetRequest, + crate::routes::capability_sets::UpdateCapabilitySetRequest, + )) +)] +pub struct CapabilitySetsApiDoc; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 5b0ac54..ea375c4 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -35,7 +35,9 @@ use crate::documents::{ tags::{assign_tags as assign_tags_to_document, load_tags_for_documents}, }; use crate::error::{AppError, AppResult}; -use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT}; +use crate::jobs::{ + enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT, +}; use crate::models::{ Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, @@ -1288,13 +1290,13 @@ pub async fn download_with_token( } #[utoipa::path( - delete, - path = "/api/documents/{id}", + post, + path = "/api/documents/{id}/trash", params(("id" = Uuid, Path, description = "Document ID")), responses((status = 204, description = "Document deleted")), tag = "Documents" )] -pub async fn delete_document( +pub async fn trash_document( Path(document_id): Path, TenantScopedConn { mut conn, @@ -1316,6 +1318,50 @@ pub async fn delete_document( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + delete, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 202, description = "Document purge scheduled")), + tag = "Documents" +)] +pub async fn delete_document( + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + conn.transaction::<(), AppError, _>(|conn| { + let document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .for_update() + .first::(conn) + .optional()? + .ok_or_else(AppError::not_found)?; + + if document.deleted_at.is_none() { + return Err(AppError::conflict( + "document must be trashed before permanent deletion", + )); + } + + let payload = json!({ "document_id": document_id }); + match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) { + Ok(_) => Ok(()), + Err(JobQueueError::Database(diesel::result::Error::DatabaseError( + DatabaseErrorKind::UniqueViolation, + _, + ))) => Ok(()), + Err(JobQueueError::Database(err)) => Err(AppError::from(err)), + } + })?; + + Ok(StatusCode::ACCEPTED) +} + #[utoipa::path( patch, path = "/api/documents/{id}", @@ -2433,6 +2479,7 @@ pub(crate) fn hydrate_documents( crate::routes::documents::upload_document, crate::routes::documents::get_document, crate::routes::documents::update_document, + crate::routes::documents::trash_document, crate::routes::documents::delete_document, crate::routes::documents::restore_document, crate::routes::documents::download_with_token, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 4841ec9..a53f7a8 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -13,9 +13,15 @@ use tower_http::{ }; use utoipa::OpenApi; -use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState}; +use crate::{ + auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser}, + models::ApiCapability, + openapi::ApiDoc, + state::AppState, +}; pub mod auth; +pub mod capability_sets; pub mod correspondents; pub mod documents; pub mod folders; @@ -75,93 +81,290 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/me", get(auth::me)); let documents_routes = Router::new() - .route("/check", get(documents::check_document)) + .route( + "/check", + get(documents::check_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) .route( "/", - get(documents::list_documents).post(documents::upload_document), + get(documents::list_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/", + post(documents::upload_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ApiCapability::DocumentsUpload, + ])), + ) + .route( + "/bulk/move", + post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/tags", + post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), ) - .route("/bulk/move", post(documents::bulk_move_documents)) - .route("/bulk/tags", post(documents::bulk_update_tags)) .route( "/bulk/correspondents", - post(documents::bulk_assign_correspondents), + post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), ) .route( "/bulk/reanalyze", - post(documents::reanalyze_selected_documents), + post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), ) .route( "/:id", - get(documents::get_document) - .delete(documents::delete_document) - .patch(documents::update_document), + get(documents::get_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/:id/trash", + post(documents::trash_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/:id", + delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/:id", + patch(documents::update_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), ) .route( "/:id/assets", - get(documents::list_document_assets).post(documents::request_document_assets), + get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/:id/assets", + post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/:id/folder", + patch(documents::move_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/:id/versions", + get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), ) - .route("/:id/folder", patch(documents::move_document)) - .route("/:id/versions", get(documents::list_document_versions)) .route( "/:id/versions/:version_id", - get(documents::get_document_version), + get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/:id/restore", + post(documents::restore_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/:id/tags", + post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/:id/tags/:tag_id", + delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), ) - .route("/:id/restore", post(documents::restore_document)) - .route("/:id/tags", post(documents::assign_tags)) - .route("/:id/tags/:tag_id", delete(documents::remove_tag)) .route( "/:id/correspondents", - post(documents::assign_correspondents), + post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), ) .route( "/:id/correspondents/:correspondent_id", - delete(documents::remove_correspondent), + delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), ); let download_routes = Router::new().route("/download/:token", get(documents::download_with_token)); let folders_routes = Router::new() - .route("/", post(folders::create_folder)) - .route("/path", post(folders::ensure_folder_path)) - .route("/:id", get(folders::get_folder)) - .route("/:id", delete(folders::delete_folder)) - .route("/:id", patch(folders::update_folder)) - .route("/:id/contents", get(folders::list_folder_contents)); + .route( + "/", + post(folders::create_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/path", + post(folders::ensure_folder_path) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/:id", + get(folders::get_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ) + .route( + "/:id", + delete(folders::delete_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/:id", + patch(folders::update_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])), + ) + .route( + "/:id/contents", + get(folders::list_folder_contents) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ); let tags_routes = Router::new() - .route("/", get(tags::list_tags).post(tags::create_tag)) - .route("/:id", patch(tags::update_tag).delete(tags::delete_tag)); + .route( + "/", + get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])), + ) + .route( + "/", + post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), + ) + .route( + "/:id", + patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])), + ) + .route( + "/:id", + delete(tags::delete_tag) + .layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), + ); let correspondents_routes = Router::new() .route( "/", - get(correspondents::list_correspondents).post(correspondents::create_correspondent), + get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsRead, + ])), + ) + .route( + "/", + post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsWrite, + ])), ) .route( "/:id", - patch(correspondents::update_correspondent) - .delete(correspondents::delete_correspondent), + patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsEdit, + ])), + ) + .route( + "/:id", + delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsWrite, + ])), ); let profile_routes = Router::new() .route( "/api-tokens", - get(profile::list_api_tokens).post(profile::create_api_token), + get(profile::list_api_tokens) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), + ) + .route( + "/api-tokens", + post(profile::create_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), ) .route( "/api-tokens/:id/regenerate", - post(profile::regenerate_api_token), + post(profile::regenerate_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), ) .route( "/api-tokens/:id", - patch(profile::update_api_token).delete(profile::delete_api_token), + patch(profile::update_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), ) - .route("/passkeys", get(profile::list_passkeys)) - .route("/passkeys/:id", delete(profile::delete_passkey)); + .route( + "/api-tokens/:id", + delete(profile::delete_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/passkeys", + get(profile::list_passkeys) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), + ) + .route( + "/passkeys/:id", + delete(profile::delete_passkey) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ); + + let capability_sets_routes = Router::new() + .route( + "/", + get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ) + .route( + "/", + post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ) + .route( + "/:id", + get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ) + .route( + "/:id", + patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ) + .route( + "/:id", + delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ); let protected_state = state.clone(); - let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); + let assets_routes = Router::new().route( + "/:asset_id", + get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ); let protected_routes = Router::new() .nest("/api/documents", documents_routes) @@ -169,6 +372,7 @@ pub fn create_router(state: AppState) -> Router<()> { .nest("/api/tags", tags_routes) .nest("/api/correspondents", correspondents_routes) .nest("/api/profile", profile_routes) + .nest("/api/capability-sets", capability_sets_routes) .nest("/api/assets", assets_routes) .layer(middleware::from_extractor_with_state::(protected_state)); diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index a99a2b7..ceb80b9 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -14,12 +14,13 @@ use crate::auth::{ regenerate_api_token as rotate_token, revoke_api_token as revoke_token, update_api_token_capabilities as update_capabilities, }, + capability_sets::load_capabilities_for_set, passkeys::PasskeySummary, TenantScopedConn, }; use crate::error::{AppError, AppResult}; -use crate::models::{ApiToken, ApiTokenCapability}; -use crate::state::AppState; +use crate::models::{ApiCapability, ApiToken}; +use crate::state::{AppState, PgPooledConnection}; use crate::utils::{db::no_content, time::to_iso}; #[derive(Debug, Serialize, ToSchema)] @@ -28,7 +29,7 @@ pub struct ApiTokenResponse { pub tenant_id: Uuid, #[schema(nullable)] pub label: Option, - pub capabilities: Vec, + pub capabilities: Vec, pub created_at: String, #[schema(nullable)] pub last_used_at: Option, @@ -52,12 +53,12 @@ pub struct CreateApiTokenRequest { pub expires_at: Option, #[serde(default)] #[schema(nullable)] - pub capabilities: Option>, + pub capabilities: Option>, } #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateApiTokenCapabilitiesRequest { - pub capabilities: Vec, + pub capabilities: Vec, } #[derive(Debug, Deserialize, ToSchema)] @@ -103,7 +104,10 @@ pub async fn list_api_tokens( }: TenantScopedConn, ) -> AppResult>> { let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; - let responses = tokens.into_iter().map(api_token_to_response).collect(); + let mut responses = Vec::with_capacity(tokens.len()); + for token in tokens { + responses.push(api_token_to_response(&mut conn, token)?); + } Ok(Json(responses)) } @@ -130,7 +134,11 @@ pub async fn create_api_token( let capabilities = payload .capabilities - .unwrap_or_else(|| vec![ApiTokenCapability::Webdav]); + .ok_or_else(|| AppError::bad_request("at least one capability is required"))?; + + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } let issued = issue_token( &mut conn, @@ -141,9 +149,11 @@ pub async fn create_api_token( capabilities, )?; + let token_info = api_token_to_response(&mut conn, issued.record)?; + let response = ApiTokenCreatedResponse { token: issued.token, - token_info: api_token_to_response(issued.record), + token_info, }; Ok((StatusCode::CREATED, Json(response))) @@ -166,9 +176,10 @@ pub async fn regenerate_api_token( Path(token_id): Path, ) -> AppResult> { let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; + let token_info = api_token_to_response(&mut conn, issued.record)?; let response = ApiTokenCreatedResponse { token: issued.token, - token_info: api_token_to_response(issued.record), + token_info, }; Ok(Json(response)) @@ -192,6 +203,10 @@ pub async fn update_api_token( Path(token_id): Path, Json(payload): Json, ) -> AppResult> { + if payload.capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + let updated = update_capabilities( &mut conn, token_id, @@ -200,7 +215,9 @@ pub async fn update_api_token( payload.capabilities, )?; - Ok(Json(api_token_to_response(updated))) + let response = api_token_to_response(&mut conn, updated)?; + + Ok(Json(response)) } #[utoipa::path( @@ -254,7 +271,10 @@ pub async fn delete_passkey( no_content() } -fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { +fn api_token_to_response( + conn: &mut PgPooledConnection, + token: ApiToken, +) -> AppResult { let ApiToken { id, tenant_id, @@ -263,11 +283,13 @@ fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { last_used_at, expires_at, revoked_at, - capabilities, + capability_set_id, .. } = token; - ApiTokenResponse { + let capabilities = load_capabilities_for_set(conn, capability_set_id)?; + + Ok(ApiTokenResponse { id, tenant_id, label, @@ -276,7 +298,7 @@ fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { last_used_at: last_used_at.map(to_iso), expires_at: expires_at.map(to_iso), revoked_at: revoked_at.map(to_iso), - } + }) } fn parse_timestamp(value: &str) -> AppResult { @@ -297,7 +319,7 @@ fn parse_timestamp(value: &str) -> AppResult { crate::routes::profile::delete_passkey ), components(schemas( - crate::models::ApiTokenCapability, + crate::models::ApiCapability, crate::routes::profile::ApiTokenResponse, crate::routes::profile::ApiTokenCreatedResponse, crate::routes::profile::CreateApiTokenRequest, diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index b8781d4..4643620 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use crate::auth::api_tokens::{find_active_token_by_secret, touch_api_token}; use crate::error::{AppError, AppResult}; -use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User}; +use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User}; use crate::schema::{ document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, @@ -438,7 +438,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result token, None => { diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs index a0a42c4..dfaf438 100644 --- a/backend/src/workers/mod.rs +++ b/backend/src/workers/mod.rs @@ -16,6 +16,7 @@ pub mod analyze; pub mod common; pub mod index; pub mod ocr; +pub mod purge; pub mod tenants; pub mod thumbnails; @@ -149,6 +150,7 @@ pub fn default_handlers() -> Vec> { Arc::new(analyze::AnalyzeDocumentJob::new()), Arc::new(thumbnails::GenerateThumbnailsJob::new()), Arc::new(ocr::GenerateOcrTextJob::new()), + Arc::new(purge::PurgeDocumentJob::new()), Arc::new(index::IndexDocumentTextJob::new()), Arc::new(ProvisionTenantJob::new()), ] diff --git a/backend/src/workers/purge.rs b/backend/src/workers/purge.rs new file mode 100644 index 0000000..0bce8b7 --- /dev/null +++ b/backend/src/workers/purge.rs @@ -0,0 +1,239 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use diesel::prelude::*; +use diesel::result::Error as DieselError; +use serde::Deserialize; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::jobs::JOB_PURGE_DOCUMENT; +use crate::models::{Document, DocumentVersion}; +use crate::schema::{document_asset_objects, document_assets, document_versions}; +use crate::state::AppState; +use crate::storage::TenantStorage; + +use super::{JobExecution, JobHandler}; + +#[derive(Debug, Deserialize)] +struct PurgeDocumentPayload { + document_id: Uuid, +} + +#[derive(Debug)] +struct PurgeContext { + document_id: Uuid, + version_keys: Vec, + asset_keys: Vec, +} + +pub struct PurgeDocumentJob; + +impl PurgeDocumentJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for PurgeDocumentJob { + fn job_type(&self) -> &'static str { + JOB_PURGE_DOCUMENT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { + let payload: PurgeDocumentPayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid purge payload: {err}"), + }; + } + }; + + let tenant_id = job.tenant_id; + let document_id = payload.document_id; + let state_for_prepare = state.clone(); + + let preparation = tokio::task::spawn_blocking(move || { + prepare_purge_context(state_for_prepare, tenant_id, document_id) + }) + .await; + + let context = match preparation { + Ok(Ok(Some(ctx))) => ctx, + Ok(Ok(None)) => { + // Document already gone or restored; nothing to do. + return JobExecution::Success; + } + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "purge preparation failed"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "purge preparation task panicked"); + return JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("purge preparation panicked: {join_err}"), + }; + } + }; + + if let Err(err) = delete_storage_objects(&storage, &context).await { + warn!(job_id = %job.id, error = %err, "failed to delete storage objects for purge"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + + let PurgeContext { document_id, .. } = context; + let state_for_finalize = state.clone(); + + let finalize = tokio::task::spawn_blocking(move || { + finalize_purge(state_for_finalize, tenant_id, document_id) + }) + .await; + + match finalize { + Ok(Ok(())) => JobExecution::Success, + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "failed to finalize purge"); + JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + } + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "purge finalize task panicked"); + JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("purge finalize panicked: {join_err}"), + } + } + } + } +} + +fn prepare_purge_context( + state: Arc, + tenant_id: Uuid, + document_id: Uuid, +) -> Result, String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + + conn.transaction(|conn| { + use crate::schema::documents::dsl as doc_dsl; + + let doc_opt = doc_dsl::documents + .filter(doc_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .for_update() + .first::(conn) + .optional()?; + + let Some(document) = doc_opt else { + return Ok(None); + }; + + if document.deleted_at.is_none() { + return Ok(None); + } + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .load(conn)?; + + let version_keys: Vec = versions + .iter() + .map(|version| version.s3_key.clone()) + .collect(); + let version_ids: Vec = versions.iter().map(|version| version.id).collect(); + + let asset_keys = if version_ids.is_empty() { + Vec::new() + } else { + let asset_ids: Vec = document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .select(document_assets::id) + .load(conn)?; + + if asset_ids.is_empty() { + Vec::new() + } else { + document_asset_objects::table + .filter(document_asset_objects::asset_id.eq_any(&asset_ids)) + .filter(document_asset_objects::tenant_id.eq(tenant_id)) + .select(document_asset_objects::s3_key) + .load(conn)? + } + }; + + Ok(Some(PurgeContext { + document_id, + version_keys, + asset_keys, + })) + }) + .map_err(|err: DieselError| format!("failed to prepare purge: {err}")) +} + +async fn delete_storage_objects( + storage: &TenantStorage, + context: &PurgeContext, +) -> Result<(), String> { + let mut keys = HashSet::new(); + keys.extend(context.version_keys.iter().cloned()); + keys.extend(context.asset_keys.iter().cloned()); + + for key in keys { + if let Err(err) = storage.delete_object(&key).await { + return Err(format!("failed to delete object {}: {err:?}", key)); + } + } + + Ok(()) +} + +fn finalize_purge(state: Arc, tenant_id: Uuid, document_id: Uuid) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + + conn.transaction(|conn| { + use crate::schema::documents::dsl as doc_dsl; + + let doc_opt = doc_dsl::documents + .filter(doc_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .for_update() + .first::(conn) + .optional()?; + + let Some(document) = doc_opt else { + return Ok(()); + }; + + if document.deleted_at.is_none() { + return Ok(()); + } + + diesel::delete(doc_dsl::documents.filter(doc_dsl::id.eq(document_id))).execute(conn)?; + Ok(()) + }) + .map_err(|err: DieselError| format!("failed to finalize purge: {err}")) +} diff --git a/backend/src/workers/tenants.rs b/backend/src/workers/tenants.rs index 5eaade8..59529d8 100644 --- a/backend/src/workers/tenants.rs +++ b/backend/src/workers/tenants.rs @@ -8,6 +8,9 @@ use serde::Deserialize; use tracing::warn; use uuid::Uuid; +use crate::auth::capability_sets::{ + ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities, +}; use crate::documents::search::ensure_quickwit_index; use crate::jobs::JOB_PROVISION_TENANT; use crate::models::{NewUserMembership, TenantStatus}; @@ -128,12 +131,56 @@ impl JobHandler for ProvisionTenantJob { }; } + let owner_capability_set_id = + match ensure_capability_set(&mut conn, tenant.id, owner_capabilities()) { + Ok(set) => set.id, + Err(err) => { + warn!( + job_id = %job.id, + tenant_id = %tenant.id, + error = ?err, + "failed to ensure owner capability set during provisioning" + ); + return JobExecution::Retry { + delay: std::time::Duration::from_secs(30), + error: "owner capability set unavailable".into(), + }; + } + }; + + if let Err(err) = ensure_capability_set(&mut conn, tenant.id, user_capabilities()) { + warn!( + job_id = %job.id, + tenant_id = %tenant.id, + error = ?err, + "failed to ensure user capability set during provisioning" + ); + return JobExecution::Retry { + delay: std::time::Duration::from_secs(30), + error: "user capability set unavailable".into(), + }; + } + + if let Err(err) = ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()) { + warn!( + job_id = %job.id, + tenant_id = %tenant.id, + error = ?err, + "failed to ensure webdav capability set during provisioning" + ); + return JobExecution::Retry { + delay: std::time::Duration::from_secs(30), + error: "webdav capability set unavailable".into(), + }; + } + if let Some(members) = ProvisionPayload::from_job(&job) { for member in members { let new_membership = NewUserMembership { id: Uuid::new_v4(), user_id: member, tenant_id: tenant.id, + capability_set_id: Some(owner_capability_set_id), }; if let Err(err) = diesel::insert_into(user_memberships::table) diff --git a/backend/tests/api_tokens_flow.rs b/backend/tests/api_tokens_flow.rs index 58f9515..461ef9d 100644 --- a/backend/tests/api_tokens_flow.rs +++ b/backend/tests/api_tokens_flow.rs @@ -7,7 +7,7 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; -use papercrate::models::{ApiToken, ApiTokenCapability}; +use papercrate::models::{ApiCapability, ApiToken}; use papercrate::routes::webdav; use papercrate::schema::api_tokens; use serde::Deserialize; @@ -15,13 +15,46 @@ use serde_json::json; use tower::ServiceExt; use uuid::Uuid; +const LEGACY_WEBDAV_CAPS: &[&str] = &[ + "documents:edit", + "documents:read", + "documents:upload", + "documents:write", + "folders:edit", + "folders:read", + "folders:write", + "webdav:read", +]; + +const OWNER_CAPS: &[&str] = &[ + "correspondents:edit", + "correspondents:read", + "correspondents:write", + "documents:edit", + "documents:read", + "documents:upload", + "documents:write", + "folders:edit", + "folders:read", + "folders:write", + "profile:read", + "profile:write", + "tags:edit", + "tags:read", + "tags:write", + "webdav:read", + "webdav:write", + "capability_sets:read", + "capability_sets:write", +]; + #[derive(Debug, Deserialize)] struct TokenInfo { id: Uuid, label: Option, last_used_at: Option, revoked_at: Option, - capabilities: Vec, + capabilities: Vec, } #[derive(Debug, Deserialize)] @@ -55,11 +88,16 @@ async fn api_token_crud_flow() -> Result<()> { app.insert_user(username, password, "admin").await?; let access_token = app.login_token(username, password).await?; - let created = create_token(&app, &access_token, json!({ "label": "dav" })).await?; + let created = create_token( + &app, + &access_token, + json!({ "label": "dav", "capabilities": LEGACY_WEBDAV_CAPS }), + ) + .await?; let token_id = created.info.id; assert_eq!(created.info.label.as_deref(), Some("dav")); assert!(created.info.last_used_at.is_none()); - assert_eq!(created.info.capabilities, vec![ApiTokenCapability::Webdav]); + assert_capabilities(&created.info.capabilities, LEGACY_WEBDAV_CAPS); let regenerated = regenerate_token(&app, &access_token, token_id).await?; assert_eq!(regenerated.info.id, token_id); @@ -70,12 +108,10 @@ async fn api_token_crud_flow() -> Result<()> { &app, &access_token, token_id, - json!({ "capabilities": ["webdav", "api"] }), + json!({ "capabilities": OWNER_CAPS }), ) .await?; - assert_eq!(updated.capabilities.len(), 2); - assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav)); - assert!(updated.capabilities.contains(&ApiTokenCapability::Api)); + assert_capabilities(&updated.capabilities, OWNER_CAPS); let listed = list_tokens(&app, &access_token).await?; assert_eq!(listed.len(), 1); @@ -119,7 +155,12 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { app.insert_user(username, password, "admin").await?; let access_token = app.login_token(username, password).await?; - let created = create_token(&app, &access_token, json!({ "label": "webdav" })).await?; + let created = create_token( + &app, + &access_token, + json!({ "label": "webdav", "capabilities": LEGACY_WEBDAV_CAPS }), + ) + .await?; let token_id = created.info.id; let router = webdav::create_router().with_state(app.state.clone()); @@ -187,6 +228,50 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { let response = router.clone().oneshot(success_request).await?; assert_eq!(response.status(), StatusCode::MULTI_STATUS); + // Token without webdav_read cannot authenticate. + let limited_token = create_token( + &app, + &access_token, + json!({ + "label": "limited", + "capabilities": ["documents:read"] + }), + ) + .await?; + + let limited_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, limited_token.token)) + ); + + let limited_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, limited_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let limited_response = router.clone().oneshot(limited_request).await?; + assert_eq!(limited_response.status(), StatusCode::UNAUTHORIZED); + + let _ = app + .patch_json( + &format!("/api/profile/api-tokens/{}", limited_token.info.id), + &json!({ + "capabilities": ["documents:read", "webdav:read"] + }), + Some(&access_token), + ) + .await?; + + let upgraded_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, limited_header) + .header("depth", "0") + .body(Body::empty())?; + let upgraded_response = router.clone().oneshot(upgraded_request).await?; + assert_eq!(upgraded_response.status(), StatusCode::MULTI_STATUS); + delete_token(&app, &access_token, token_id).await?; let failure_request = Request::builder() @@ -269,6 +354,19 @@ async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Resu assert_eq!(response.status(), StatusCode::NO_CONTENT); Ok(()) } + +fn assert_capabilities(actual: &[ApiCapability], expected: &[&str]) { + assert_eq!(actual.len(), expected.len()); + for capability in expected { + assert!( + actual + .iter() + .any(|candidate| candidate.as_str() == *capability), + "capability '{}' not present", + capability + ); + } +} async fn exchange_token(app: &TestApp, api_token: &str) -> Result { let response = app .post_json( diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 8fdad44..0fd72b8 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -5,13 +5,15 @@ use axum::http::{header::SET_COOKIE, StatusCode}; use chrono::{Duration as ChronoDuration, Utc}; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; +use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; +use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind}; use papercrate::auth::passkeys::{ PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, RegistrationChallengeResponse, }; use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey}; use papercrate::openapi::schemas::PasskeySummary; -use papercrate::schema::{tenants, user_memberships, user_sessions, users}; +use papercrate::schema::{capability_sets, tenants, user_memberships, user_sessions, users}; use rand::rngs::OsRng; use rand::RngCore; use serde::Deserialize; @@ -522,10 +524,16 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() )) .execute(conn)?; + let owner_capability_set_id = + ensure_capability_set(conn, secondary_id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + let membership = NewUserMembership { id: Uuid::new_v4(), user_id, tenant_id: secondary_id, + capability_set_id: Some(owner_capability_set_id), }; diesel::insert_into(user_memberships::table) @@ -592,10 +600,28 @@ async fn login_with_session( let tenant: papercrate::models::Tenant = tenants::table.find(membership.tenant_id).first(conn)?; + let capability_set_id = membership + .capability_set_id + .ok_or_else(|| anyhow!("membership missing capability set"))?; + + let cap_version = capability_sets::table + .find(capability_set_id) + .select(capability_sets::cap_version) + .first::(conn)?; + let now = Utc::now(); + let session_id = Uuid::new_v4(); let access_token = state .jwt - .generate_token(user.id, tenant.id, &user.username) + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: tenant.id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version, + }) .map_err(|err| anyhow!(err))?; let session_value = generate_session_token(); @@ -603,7 +629,7 @@ async fn login_with_session( let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); let new_session = NewUserSession { - id: Uuid::new_v4(), + id: session_id, user_id: user.id, token_hash: session_hash, issued_at: now.naive_utc(), diff --git a/backend/tests/capability_guards_flow.rs b/backend/tests/capability_guards_flow.rs new file mode 100644 index 0000000..dbb1061 --- /dev/null +++ b/backend/tests/capability_guards_flow.rs @@ -0,0 +1,106 @@ +mod common; + +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use common::TestApp; +use diesel::prelude::*; +use papercrate::models::ApiCapability; +use serde_json::json; + +async fn set_user_capabilities( + app: &TestApp, + user_id: uuid::Uuid, + caps: &[ApiCapability], +) -> Result<()> { + let capabilities = caps.to_vec(); + app.with_conn(move |conn| { + use papercrate::schema::user_memberships::dsl as memberships_dsl; + + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .first::(conn)?; + + let capability_set = papercrate::auth::capability_sets::ensure_capability_set( + conn, + membership.tenant_id, + &capabilities, + ) + .map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?; + + diesel::update(memberships_dsl::user_memberships.find(membership.id)) + .set(memberships_dsl::capability_set_id.eq(Some(capability_set.id))) + .execute(conn)?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn documents_routes_enforce_capabilities() -> Result<()> { + let _lock = common::acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "limited-docs"; + let user_id = app.insert_user("limited-docs", password, "admin").await?; + set_user_capabilities(&app, user_id, &[ApiCapability::DocumentsRead]).await?; + + let token = app.login_token("limited-docs", password).await?; + + let list = app.get("/api/documents", Some(&token)).await?; + assert_eq!(list.status(), StatusCode::OK); + + let upload = app + .upload_document( + "/api/documents", + "limited.txt", + "text/plain", + b"limited", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::FORBIDDEN); + let upload_body = common::body_to_vec(upload.into_body()).await?; + assert!(String::from_utf8_lossy(&upload_body).contains("missing")); + + let capability_sets = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN); + let caps_body = common::body_to_vec(capability_sets.into_body()).await?; + assert!(String::from_utf8_lossy(&caps_body).contains("missing")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn capability_set_routes_require_write_privilege() -> Result<()> { + let _lock = common::acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "caps-reader"; + let user_id = app.insert_user("caps-reader", password, "admin").await?; + set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?; + + let token = app.login_token("caps-reader", password).await?; + + let list = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(list.status(), StatusCode::OK); + + let create = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": "should-fail", + "capabilities": ["documents:read"] + }), + Some(&token), + ) + .await?; + assert_eq!(create.status(), StatusCode::FORBIDDEN); + let create_body = common::body_to_vec(create.into_body()).await?; + assert!(String::from_utf8_lossy(&create_body).contains("missing")); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/capability_sets_flow.rs b/backend/tests/capability_sets_flow.rs new file mode 100644 index 0000000..5a70762 --- /dev/null +++ b/backend/tests/capability_sets_flow.rs @@ -0,0 +1,104 @@ +mod common; + +use anyhow::Result; +use axum::http::StatusCode; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct CapabilitySetResponse { + id: Uuid, + slug: String, + cap_version: i32, + #[allow(dead_code)] + is_system: bool, + capabilities: Vec, +} + +#[tokio::test] +async fn capability_set_crud_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "caps-admin"; + app.insert_user("caps", password, "admin").await?; + let token = app.login_token("caps", password).await?; + + // Initial list should contain system sets. + let initial = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(initial.status(), StatusCode::OK); + let initial_body = body_to_vec(initial.into_body()).await?; + let sets: Vec = serde_json::from_slice(&initial_body)?; + let owner_id = sets + .iter() + .find(|set| set.slug == "owner") + .map(|set| set.id) + .expect("owner set present"); + assert!(sets.iter().any(|set| set.slug == "user")); + assert!(sets.iter().any(|set| set.slug == "webdav")); + + // Create a new capability set. + let create = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": "api_readonly", + "capabilities": ["documents:read", "capability_sets:read"] + }), + Some(&token), + ) + .await?; + assert_eq!(create.status(), StatusCode::CREATED); + let create_body = body_to_vec(create.into_body()).await?; + let created: CapabilitySetResponse = serde_json::from_slice(&create_body)?; + assert_eq!(created.slug, "api_readonly"); + assert!(created.capabilities.contains(&"documents:read".to_string())); + assert!(created + .capabilities + .contains(&"capability_sets:read".to_string())); + + // Update capabilities. + let update = app + .patch_json( + &format!("/api/capability-sets/{}", created.id), + &json!({ + "capabilities": ["documents:read", "documents:edit"], + }), + Some(&token), + ) + .await?; + assert_eq!(update.status(), StatusCode::OK); + let update_body = body_to_vec(update.into_body()).await?; + let updated: CapabilitySetResponse = serde_json::from_slice(&update_body)?; + assert_eq!(updated.cap_version, created.cap_version + 1); + assert!(updated.capabilities.contains(&"documents:edit".to_string())); + assert!(!updated + .capabilities + .contains(&"capability_sets:read".to_string())); + + // Attempt to delete system set should conflict. + let delete_owner = app + .delete(&format!("/api/capability-sets/{}", owner_id), Some(&token)) + .await?; + assert_eq!(delete_owner.status(), StatusCode::CONFLICT); + + // Delete custom set succeeds. + let delete = app + .delete( + &format!("/api/capability-sets/{}", created.id), + Some(&token), + ) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + let final_list = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(final_list.status(), StatusCode::OK); + let final_body = body_to_vec(final_list.into_body()).await?; + let final_sets: Vec = serde_json::from_slice(&final_body)?; + assert!(!final_sets.iter().any(|set| set.slug == "api_readonly")); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 4e106c0..fe9ec8f 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -16,7 +16,10 @@ use diesel::PgConnection; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use http_body_util::BodyExt; use once_cell::sync::Lazy; -use papercrate::auth::jwt::JwtService; +use papercrate::auth::capability_sets::{ + ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities, +}; +use papercrate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind}; use papercrate::config::AppConfig; use papercrate::db::{self, PgPool}; use papercrate::models::{ @@ -81,7 +84,12 @@ impl ObjectStorage for FakeStorage { Ok(()) } - async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result { + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + _response_content_disposition: Option<&str>, + ) -> Result { let guard = self.objects.lock().await; ensure!(guard.contains_key(key), "object {key} missing"); Ok(format!( @@ -217,34 +225,49 @@ impl TestApp { Ok(format!("{}{}", root, key)) } - pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result { + pub async fn insert_user(&self, username: &str, _password: &str, role: &str) -> Result { let username = username.to_string(); + let role = role.to_string(); let tenant_id = self.ensure_default_tenant().await?; - self.with_conn(move |conn| { - let user = NewUser { - id: Uuid::new_v4(), - username, - }; - diesel::insert_into(papercrate::schema::users::table) - .values(&user) - .execute(conn) - .context("failed to insert user")?; + let user_id = self + .with_conn(move |conn| { + let user = NewUser { + id: Uuid::new_v4(), + username, + }; + diesel::insert_into(papercrate::schema::users::table) + .values(&user) + .execute(conn) + .context("failed to insert user")?; - let membership = NewUserMembership { - id: Uuid::new_v4(), - user_id: user.id, - tenant_id, - }; + let capabilities = match role.as_str() { + "admin" => owner_capabilities(), + "webdav" => webdav_capabilities(), + _ => user_capabilities(), + }; - diesel::insert_into(papercrate::schema::user_memberships::table) - .values(&membership) - .execute(conn) - .context("failed to insert user membership")?; - Ok(user.id) - }) - .await + let capability_set = ensure_capability_set(conn, tenant_id, capabilities) + .map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user.id, + tenant_id, + capability_set_id: Some(capability_set.id), + }; + + diesel::insert_into(papercrate::schema::user_memberships::table) + .values(&membership) + .execute(conn) + .context("failed to insert user membership")?; + Ok(user.id) + }) + .await?; + + Ok(user_id) } + #[cfg(test)] pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result { let passkey_id = Uuid::new_v4(); let nickname = nickname.map(|value| value.to_string()); @@ -276,56 +299,71 @@ impl TestApp { async fn ensure_default_tenant(&self) -> Result { let name_value = TEST_TENANT_NAME.to_string(); let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); - self.with_conn(move |conn| { - use papercrate::schema::tenants::dsl as tenants_dsl; + let tenant_id = self + .with_conn(move |conn| { + use papercrate::schema::tenants::dsl as tenants_dsl; - let existing = tenants_dsl::tenants - .filter(tenants_dsl::name.eq(&name_value)) - .first::(conn) - .optional() - .context("failed to load default tenant")?; + let existing = tenants_dsl::tenants + .filter(tenants_dsl::name.eq(&name_value)) + .first::(conn) + .optional() + .context("failed to load default tenant")?; - let tenant_id = if let Some(current) = existing { - let desired_root = current - .storage_root - .clone() - .filter(|root| root.ends_with('/')) - .unwrap_or_else(|| format!("test-tenants/{}/", current.id)); + let tenant_id = if let Some(current) = existing { + let desired_root = current + .storage_root + .clone() + .filter(|root| root.ends_with('/')) + .unwrap_or_else(|| format!("test-tenants/{}/", current.id)); - if current.storage_root.as_deref() != Some(desired_root.as_str()) { - diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id))) - .set(tenants_dsl::storage_root.eq(Some(desired_root))) - .execute(conn) - .context("failed to update default tenant storage root")?; - } + if current.storage_root.as_deref() != Some(desired_root.as_str()) { + diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id))) + .set(tenants_dsl::storage_root.eq(Some(desired_root))) + .execute(conn) + .context("failed to update default tenant storage root")?; + } - current.id - } else { - let new_id = Uuid::new_v4(); - let root = format!("test-tenants/{}/", new_id); - let quickwit_value = if quickwit_enabled { - Some(format!("documents-{}", new_id)) + current.id } else { - None + let new_id = Uuid::new_v4(); + let root = format!("test-tenants/{}/", new_id); + let quickwit_value = if quickwit_enabled { + Some(format!("documents-{}", new_id)) + } else { + None + }; + + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(new_id), + tenants_dsl::name.eq(&name_value), + tenants_dsl::storage_root.eq(Some(root)), + tenants_dsl::quickwit_index.eq(quickwit_value), + tenants_dsl::status.eq(TenantStatus::Active), + )) + .execute(conn) + .context("failed to insert default tenant")?; + + new_id }; - diesel::insert_into(tenants_dsl::tenants) - .values(( - tenants_dsl::id.eq(new_id), - tenants_dsl::name.eq(&name_value), - tenants_dsl::storage_root.eq(Some(root)), - tenants_dsl::quickwit_index.eq(quickwit_value), - tenants_dsl::status.eq(TenantStatus::Active), - )) - .execute(conn) - .context("failed to insert default tenant")?; + Ok(tenant_id) + }) + .await?; - new_id - }; + let mut conn = self + .state + .db_for_tenant(tenant_id) + .map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?; - Ok(tenant_id) - }) - .await + ensure_capability_set(&mut conn, tenant_id, owner_capabilities()) + .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, webdav_capabilities()) + .map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?; + + Ok(tenant_id) } pub async fn login_token(&self, username: &str, _password: &str) -> Result { @@ -337,6 +375,7 @@ impl TestApp { let username = username.to_string(); let state = self.state.clone(); self.with_conn(move |conn| { + use papercrate::schema::capability_sets::dsl as capability_sets_dsl; use papercrate::schema::tenants::dsl as tenants_dsl; use papercrate::schema::user_memberships::dsl as memberships_dsl; use papercrate::schema::users::dsl as users_dsl; @@ -353,10 +392,28 @@ impl TestApp { .find(membership.tenant_id) .first(conn)?; + let capability_set_id = membership + .capability_set_id + .ok_or_else(|| anyhow!("membership missing capability set"))?; + + let cap_version = capability_sets_dsl::capability_sets + .find(capability_set_id) + .select(capability_sets_dsl::cap_version) + .first::(conn)?; + let now = Utc::now(); + let session_id = Uuid::new_v4(); let access_token = state .jwt - .generate_token(user.id, tenant.id, &user.username) + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: tenant.id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version, + }) .map_err(|err| anyhow!(err))?; let session_value = generate_session_token(); @@ -365,7 +422,7 @@ impl TestApp { now + ChronoDuration::days(state.config.refresh_token_expiry_days); let new_session = NewUserSession { - id: Uuid::new_v4(), + id: session_id, user_id: user.id, token_hash: session_hash, issued_at: now.naive_utc(), @@ -540,7 +597,7 @@ impl TestApp { tag_ids_json: None, correspondents_json: None, issued_at: None, - skip_existing: false, + skip_existing: None, }; self.upload_document_with_extras( path, @@ -620,9 +677,15 @@ impl TestApp { body.extend(b"\r\n"); } - if extras.skip_existing { + if let Some(skip_flag) = extras.skip_existing { body.extend(format!("--{boundary}\r\n").as_bytes()); - body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n"); + body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n"); + body.extend(if skip_flag { + b"true".as_ref() + } else { + b"false".as_ref() + }); + body.extend(b"\r\n"); } body.extend(format!("--{boundary}--\r\n").as_bytes()); @@ -668,7 +731,7 @@ pub struct UploadExtras<'a> { pub tag_ids_json: Option<&'a str>, pub correspondents_json: Option<&'a str>, pub issued_at: Option<&'a str>, - pub skip_existing: bool, + pub skip_existing: Option, } impl<'a> UploadExtras<'a> { @@ -679,7 +742,7 @@ impl<'a> UploadExtras<'a> { tag_ids_json: None, correspondents_json: None, issued_at: None, - skip_existing: false, + skip_existing: None, } } } diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 41b659b..c5f5a39 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -1,12 +1,16 @@ mod common; -use anyhow::Result; +use anyhow::{anyhow, Result}; use axum::http::StatusCode; use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use uuid::Uuid; +use papercrate::jobs::{mark_job_succeeded, JOB_PURGE_DOCUMENT}; +use papercrate::models::Job; +use papercrate::workers::{purge::PurgeDocumentJob, JobExecution, JobHandler}; +use std::sync::Arc; #[derive(Deserialize)] struct DocumentDetail { document: DocumentInfo, @@ -17,6 +21,8 @@ struct ApiErrorResponse { error: String, #[serde(default)] code: Option, + #[serde(default)] + details: Option, } #[derive(Deserialize)] @@ -291,15 +297,16 @@ async fn duplicate_and_restore_document() -> Result<()> { &token, ) .await?; - { - let status = first.status(); - assert!( - status == StatusCode::OK - || status == StatusCode::CREATED - || status == StatusCode::NO_CONTENT - ); - } + let first_status = first.status(); let first_body = body_to_vec(first.into_body()).await?; + assert!( + first_status == StatusCode::OK + || first_status == StatusCode::CREATED + || first_status == StatusCode::NO_CONTENT, + "unexpected first upload status {} with body {}", + first_status, + String::from_utf8_lossy(&first_body) + ); let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; let second = app @@ -312,59 +319,69 @@ async fn duplicate_and_restore_document() -> Result<()> { &token, ) .await?; - { - let status = second.status(); - assert!( - status == StatusCode::OK - || status == StatusCode::CREATED - || status == StatusCode::NO_CONTENT - ); - } + let second_status = second.status(); let second_body = body_to_vec(second.into_body()).await?; - let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; - - assert_eq!(first_detail.document.id, second_detail.document.id); - assert_eq!(second_detail.document.deleted_at, None); - assert!(second_detail - .document - .current_version + assert_eq!(second_status, StatusCode::CONFLICT); + let second_error: ApiErrorResponse = serde_json::from_slice(&second_body)?; + assert_eq!(second_error.code.as_deref(), Some("duplicate_document")); + let conflict_id = second_error + .details .as_ref() - .expect("second current version") - .assets - .is_empty()); + .and_then(|details| details.get("conflict_document_id")) + .and_then(|value| value.as_str()) + .and_then(|value| Uuid::parse_str(value).ok()) + .expect("conflict_document_id present"); + assert_eq!(conflict_id, first_detail.document.id); assert_eq!(app.storage().object_count().await, 1); let delete = app - .delete( - &format!("/api/documents/{}", first_detail.document.id), + .post_json( + &format!("/api/documents/{}/trash", first_detail.document.id), + &json!({}), Some(&token), ) .await?; assert_eq!(delete.status(), StatusCode::NO_CONTENT); let third = app - .upload_document( + .upload_document_with_extras( "/api/documents", "dup.bin", "application/octet-stream", &payload, None, + UploadExtras { + title: None, + metadata_json: None, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: Some(false), + }, &token, ) .await?; - { - let status = third.status(); - assert!( - status == StatusCode::OK - || status == StatusCode::CREATED - || status == StatusCode::NO_CONTENT - ); - } + let third_status = third.status(); let third_body = body_to_vec(third.into_body()).await?; + assert!( + third_status == StatusCode::OK + || third_status == StatusCode::CREATED + || third_status == StatusCode::NO_CONTENT, + "unexpected third upload status {} with body {}", + third_status, + String::from_utf8_lossy(&third_body) + ); let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; assert_eq!(third_detail.document.id, first_detail.document.id); assert_eq!(third_detail.document.deleted_at, None); + assert!(third_detail + .document + .current_version + .as_ref() + .expect("third current version") + .assets + .is_empty()); assert_eq!(app.storage().object_count().await, 1); app.cleanup().await?; @@ -406,7 +423,7 @@ async fn upload_skips_existing_when_requested() -> Result<()> { tag_ids_json: Some(primary_tag_ids.as_str()), correspondents_json: None, issued_at: None, - skip_existing: false, + skip_existing: None, }; let first_upload = app @@ -456,7 +473,7 @@ async fn upload_skips_existing_when_requested() -> Result<()> { tag_ids_json: Some(alt_tag_ids.as_str()), correspondents_json: None, issued_at: None, - skip_existing: true, + skip_existing: Some(true), }; let skip_resp = app @@ -470,7 +487,18 @@ async fn upload_skips_existing_when_requested() -> Result<()> { &token, ) .await?; - assert_eq!(skip_resp.status(), StatusCode::NO_CONTENT); + assert_eq!(skip_resp.status(), StatusCode::CONFLICT); + let skip_body = body_to_vec(skip_resp.into_body()).await?; + let skip_error: ApiErrorResponse = serde_json::from_slice(&skip_body)?; + assert_eq!(skip_error.code.as_deref(), Some("duplicate_document")); + let conflict_id = skip_error + .details + .as_ref() + .and_then(|details| details.get("conflict_document_id")) + .and_then(|value| value.as_str()) + .and_then(|value| Uuid::parse_str(value).ok()) + .expect("conflict_document_id present"); + assert_eq!(conflict_id, first_detail.document.id); let fetch = app .get( @@ -1407,8 +1435,9 @@ async fn list_documents_by_status_filter() -> Result<()> { let detail: DocumentDetail = serde_json::from_slice(&body)?; let delete_resp = app - .delete( - &format!("/api/documents/{}", detail.document.id), + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), Some(&token), ) .await?; @@ -1437,6 +1466,137 @@ async fn list_documents_by_status_filter() -> Result<()> { app.cleanup().await?; Ok(()) } +#[tokio::test] +async fn purge_document_removes_data() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "purge"; + app.insert_user("purger", password, "admin").await?; + let token = app.login_token("purger", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "purge.bin", + "application/octet-stream", + b"permanent", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let document_id = detail.document.id; + + assert_eq!(app.storage().object_count().await, 1); + + let trash_resp = app + .post_json( + &format!("/api/documents/{}/trash", document_id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(trash_resp.status(), StatusCode::NO_CONTENT); + + let delete_resp = app + .delete(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(delete_resp.status(), StatusCode::ACCEPTED); + + let duplicate_delete = app + .delete(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(duplicate_delete.status(), StatusCode::ACCEPTED); + + let purge_job_count: i64 = app + .with_conn(|conn| { + use diesel::dsl::count_star; + use diesel::prelude::*; + use papercrate::schema::jobs::dsl::*; + + let count: i64 = jobs + .filter(job_type.eq(JOB_PURGE_DOCUMENT)) + .select(count_star()) + .get_result(conn)?; + Ok(count) + }) + .await?; + assert_eq!(purge_job_count, 1); + + let job: Job = app + .with_conn(|conn| { + use diesel::prelude::*; + use papercrate::schema::jobs::dsl::*; + + let job = jobs + .filter(job_type.eq(JOB_PURGE_DOCUMENT)) + .order(created_at.desc()) + .first(conn)?; + Ok(job) + }) + .await?; + + let handler = PurgeDocumentJob::new(); + let state = Arc::new(app.state.clone()); + let storage = app + .state + .storage_for_tenant(job.tenant_id) + .map_err(|err| anyhow!("tenant storage unavailable: {err:?}"))?; + let execution = handler.handle(state, job.clone(), storage).await; + assert!(matches!(execution, JobExecution::Success)); + + app.with_conn(move |conn| { + mark_job_succeeded(conn, job.id)?; + Ok(()) + }) + .await?; + + let fetch = app + .get(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(fetch.status(), StatusCode::NOT_FOUND); + + assert_eq!(app.storage().object_count().await, 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_document_requires_trash() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "conflict"; + app.insert_user("conflict-user", password, "admin").await?; + let token = app.login_token("conflict-user", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "conflict.bin", + "application/octet-stream", + b"restore", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .delete( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::CONFLICT); + + app.cleanup().await?; + Ok(()) +} #[tokio::test] async fn restore_document_to_original_and_custom_folder() -> Result<()> { @@ -1461,8 +1621,9 @@ async fn restore_document_to_original_and_custom_folder() -> Result<()> { let detail: DocumentDetail = serde_json::from_slice(&body)?; let delete_resp = app - .delete( - &format!("/api/documents/{}", detail.document.id), + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), Some(&token), ) .await?; @@ -1503,8 +1664,9 @@ async fn restore_document_to_original_and_custom_folder() -> Result<()> { let folder: FolderResponse = serde_json::from_slice(&folder_body)?; let delete_again = app - .delete( - &format!("/api/documents/{}", detail.document.id), + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), Some(&token), ) .await?; diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index f8ac054..95ed121 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -1,9 +1,10 @@ mod common; -use anyhow::Result; +use anyhow::{anyhow, Result}; use axum::http::StatusCode; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; +use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus}; use papercrate::schema::{ tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, @@ -223,10 +224,16 @@ async fn tags_are_isolated_between_tenants() -> Result<()> { .values(&new_user) .execute(conn)?; + let owner_capability_set_id = + ensure_capability_set(conn, tenant_b_id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + let membership = NewUserMembership { id: Uuid::new_v4(), user_id: user_b_id, tenant_id: tenant_b_id, + capability_set_id: Some(owner_capability_set_id), }; diesel::insert_into(memberships_dsl::user_memberships) .values(&membership) diff --git a/docs/capability_sets.md b/docs/capability_sets.md new file mode 100644 index 0000000..7ed6c0a --- /dev/null +++ b/docs/capability_sets.md @@ -0,0 +1,99 @@ +# Capability Sets + +Capability sets are the tenant-scoped bundles of REST and WebDAV permissions. Every user membership and API token now references one of these sets, and the capability guard middleware enforces the scopes on every route. + +## Enumerated Capabilities + +All capabilities live in the `ApiCapability` enum. The current list is: + +- `documents:read` +- `documents:edit` +- `documents:write` +- `documents:upload` +- `folders:read` +- `folders:edit` +- `folders:write` +- `tags:read` +- `tags:edit` +- `tags:write` +- `correspondents:read` +- `correspondents:edit` +- `correspondents:write` +- `profile:read` +- `profile:write` +- `webdav:read` +- `webdav:write` +- `capability_sets:read` +- `capability_sets:write` + +## Default Sets + +Provisioning (and the test harness) seed three 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. +- `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. + +## REST API + +The capability-set endpoints live at `/api/capability-sets` and require the new admin capabilities: + +| Method & Path | Capability | Description | +|----------------------------------------|-------------------------|-----------------------------------------| +| `GET /api/capability-sets` | `capability_sets:read` | List all sets for the tenant | +| `POST /api/capability-sets` | `capability_sets:write` | Create a new set | +| `GET /api/capability-sets/{id}` | `capability_sets:read` | Fetch details of a specific set | +| `PATCH /api/capability-sets/{id}` | `capability_sets:write` | Replace capabilities / rename the set | +| `DELETE /api/capability-sets/{id}` | `capability_sets:write` | Remove a custom set (must be unused) | + +### Examples + +Create a read-only set: + +```bash +curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + https://app.papercrate.org/api/capability-sets \ + -d '{ + "slug": "api_readonly", + "capabilities": [ + "documents:read", + "folders:read", + "tags:read" + ] + }' +``` + +Update an existing set: + +```bash +curl -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + https://app.papercrate.org/api/capability-sets/$SET_ID \ + -d '{ + "capabilities": ["documents:read", "documents:edit"] + }' +``` + +Delete (fails if still referenced by memberships or tokens): + +```bash +curl -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + https://app.papercrate.org/api/capability-sets/$SET_ID +``` + +## Assigning Sets + +- **User memberships**: change the `capability_set_id` column (via future admin APIs or direct SQL) to reassign a user. The authentication pipeline will enforce the new capabilities automatically. +- **API tokens**: `PATCH /api/profile/api-tokens/{id}` accepts a capability array; underneath the token is mapped to the corresponding capability set. With the new endpoints, we can expose a `capability_set_id` field to limit tokens to specific bundles. + +## Guard Coverage + +The `RequireCapabilitiesLayer` middleware wraps all protected routers (documents, folders, tags, correspondents, profile, capability sets, assets). Requests missing the necessary capability now terminate with a 403 containing `missing_capability` details. + +Integration tests in `backend/tests/capability_guards_flow.rs` ensure read-only users cannot upload or manage capability sets, and WebDAV tokens without `webdav:read` are rejected (`backend/tests/api_tokens_flow.rs`).