caps and delete

This commit is contained in:
2025-11-05 12:32:10 +01:00
parent 4ec19dbd70
commit 480bc20ae7
30 changed files with 2702 additions and 262 deletions
@@ -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;
@@ -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;
@@ -0,0 +1 @@
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
@@ -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');
+30 -33
View File
@@ -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<String>,
expires_at: Option<NaiveDateTime>,
capabilities: Vec<ApiTokenCapability>,
capabilities: Vec<ApiCapability>,
) -> Result<IssuedApiToken, AppError> {
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<Uuid>,
capabilities: Vec<ApiTokenCapability>,
capabilities: Vec<ApiCapability>,
) -> Result<ApiToken, AppError> {
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::<ApiToken>(conn)?;
Ok(updated)
@@ -147,7 +153,7 @@ pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
tenant_id: Option<Uuid>,
secret: &str,
required_capability: ApiTokenCapability,
required_capability: ApiCapability,
) -> Result<Option<ApiToken>, 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<bool, AppEr
})
}
fn normalize_capabilities(
capabilities: Vec<ApiTokenCapability>,
) -> Result<Vec<ApiTokenCapability>, 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<String, AppError> {
#[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);
+120
View File
@@ -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<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl RequireCapabilitiesLayer {
pub fn all<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::All,
}
}
pub fn any<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::Any,
}
}
}
impl<S> Layer<S> for RequireCapabilitiesLayer {
type Service = RequireCapabilities<S>;
fn layer(&self, inner: S) -> Self::Service {
RequireCapabilities {
inner,
required: Arc::clone(&self.required),
strategy: self.strategy,
}
}
}
#[derive(Clone)]
pub struct RequireCapabilities<S> {
inner: S,
required: Arc<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
where
S: Service<Request<B>, 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<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> 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::<AuthenticatedUser>() {
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 })
}
}
+304
View File
@@ -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<C>(
conn: &mut C,
tenant_id: Uuid,
slug: &str,
capabilities: Vec<ApiCapability>,
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let normalized = normalize_capabilities(capabilities)?;
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
if cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(slug))
.first::<CapabilitySet>(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::<CapabilitySet>(conn)
.map_err(AppError::from)
})
}
pub fn normalize_capabilities(
mut capabilities: Vec<ApiCapability>,
) -> Result<Vec<ApiCapability>, 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<C>(
conn: &mut C,
capability_set_id: Uuid,
) -> Result<Vec<ApiCapability>, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let mut capabilities: Vec<ApiCapability> = 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<C>(
conn: &mut C,
tenant_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + 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::<CapabilitySet, AppError, _>(|conn| {
if let Some(existing) = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(&slug))
.first::<CapabilitySet>(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::<CapabilitySet>(conn)
.map_err(AppError::from)?)
})
}
pub fn refresh_capability_set<C>(
conn: &mut C,
set: &CapabilitySet,
capabilities: &[ApiCapability],
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
conn.transaction::<CapabilitySet, AppError, _>(|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::<CapabilitySet>(conn)
.map_err(AppError::from)
})
}
pub fn load_capability_set<C>(conn: &mut C, id: Uuid) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
capability_sets::table
.find(id)
.first::<CapabilitySet>(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::<Vec<_>>()
.join(",");
let digest = Sha256::digest(joined.as_bytes());
let hex = hex::encode(digest);
format!("caps-{}", &hex[..12])
}
fn ensure_capability_membership<C>(
conn: &mut C,
set: &CapabilitySet,
desired: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + 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<C>(
conn: &mut C,
set_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let records: Vec<NewCapabilitySetCapability> = 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(())
}
+30 -4
View File
@@ -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<String> {
pub fn generate_token(&self, context: AccessTokenContext) -> Result<String> {
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,
+27
View File
@@ -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<ApiCapability>,
}
#[async_trait]
@@ -44,10 +55,26 @@ impl FromRequestParts<AppState> 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());
+6
View File
@@ -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)
+1
View File
@@ -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 {
+180 -23
View File
@@ -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<Uuid>,
}
#[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<Uuid>,
}
#[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<MagicTokenKindSql, Pg> for MagicTokenKind {
}
}
impl ToSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability {
impl ToSql<ApiCapabilitySql, Pg> 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<MagicTokenKindSql, Pg> for MagicTokenKind {
}
}
impl FromSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability {
impl FromSql<ApiCapabilitySql, Pg> for ApiCapability {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
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<Self, Self::Err> {
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<NaiveDateTime>,
pub expires_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
pub capabilities: Vec<ApiTokenCapability>,
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<String>,
pub expires_at: Option<NaiveDateTime>,
pub capabilities: Vec<ApiTokenCapability>,
pub capability_set_id: Uuid,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
+9 -1
View File
@@ -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,
+52 -12
View File
@@ -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::<Uuid>(&mut conn)
.first::<UserMembership>(&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(),
+349
View File
@@ -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<String>,
pub is_system: bool,
pub cap_version: i32,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
#[serde(default)]
pub capabilities: Option<Vec<ApiCapability>>,
}
fn normalize_slug(value: &str) -> AppResult<String> {
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<ApiCapability>) -> 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<Json<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
.load::<CapabilitySet>(&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<Uuid>,
) -> AppResult<Json<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&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<CreateCapabilitySetRequest>,
) -> AppResult<(StatusCode, Json<CapabilitySetResponse>)> {
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<Uuid>,
Json(payload): Json<UpdateCapabilitySetRequest>,
) -> AppResult<Json<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&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::<CapabilitySet, AppError, _>(|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::<CapabilitySet>(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::<CapabilitySet>(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<Uuid>,
) -> AppResult<StatusCode> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&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;
+51 -4
View File
@@ -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<Uuid>,
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<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
conn.transaction::<(), AppError, _>(|conn| {
let document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.for_update()
.first::<Document>(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,
+240 -36
View File
@@ -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::<AuthenticatedUser, _>(protected_state));
+37 -15
View File
@@ -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<String>,
pub capabilities: Vec<ApiTokenCapability>,
pub capabilities: Vec<ApiCapability>,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
@@ -52,12 +53,12 @@ pub struct CreateApiTokenRequest {
pub expires_at: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub capabilities: Option<Vec<ApiTokenCapability>>,
pub capabilities: Option<Vec<ApiCapability>>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateApiTokenCapabilitiesRequest {
pub capabilities: Vec<ApiTokenCapability>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -103,7 +104,10 @@ pub async fn list_api_tokens(
}: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
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<Uuid>,
) -> AppResult<Json<ApiTokenCreatedResponse>> {
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<Uuid>,
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
) -> AppResult<Json<ApiTokenResponse>> {
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<ApiTokenResponse> {
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<NaiveDateTime> {
@@ -297,7 +319,7 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
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,
+2 -2
View File
@@ -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<Option<WebDavCo
&mut conn,
None,
secret,
ApiTokenCapability::Webdav,
ApiCapability::WebdavRead,
)? {
Some(token) => token,
None => {
+2
View File
@@ -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<dyn JobHandler>> {
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()),
]
+239
View File
@@ -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<String>,
asset_keys: Vec<String>,
}
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<AppState>,
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<AppState>,
tenant_id: Uuid,
document_id: Uuid,
) -> Result<Option<PurgeContext>, 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::<Document>(conn)
.optional()?;
let Some(document) = doc_opt else {
return Ok(None);
};
if document.deleted_at.is_none() {
return Ok(None);
}
let versions: Vec<DocumentVersion> = 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<String> = versions
.iter()
.map(|version| version.s3_key.clone())
.collect();
let version_ids: Vec<Uuid> = versions.iter().map(|version| version.id).collect();
let asset_keys = if version_ids.is_empty() {
Vec::new()
} else {
let asset_ids: Vec<Uuid> = 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<AppState>, 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::<Document>(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}"))
}
+47
View File
@@ -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)
+107 -9
View File
@@ -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<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
capabilities: Vec<ApiTokenCapability>,
capabilities: Vec<ApiCapability>,
}
#[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<LoginResponseView> {
let response = app
.post_json(
+29 -3
View File
@@ -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::<i32>(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(),
+106
View File
@@ -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::<papercrate::models::UserMembership>(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(())
}
+104
View File
@@ -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<String>,
}
#[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<CapabilitySetResponse> = 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<CapabilitySetResponse> = serde_json::from_slice(&final_body)?;
assert!(!final_sets.iter().any(|set| set.slug == "api_readonly"));
app.cleanup().await?;
Ok(())
}
+135 -72
View File
@@ -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<String> {
async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
_response_content_disposition: Option<&str>,
) -> Result<String> {
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<Uuid> {
pub async fn insert_user(&self, username: &str, _password: &str, role: &str) -> Result<Uuid> {
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<Uuid> {
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<Uuid> {
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::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value))
.first::<Tenant>(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<String> {
@@ -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::<i32>(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<bool>,
}
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,
}
}
}
+209 -47
View File
@@ -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<String>,
#[serde(default)]
details: Option<Value>,
}
#[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?;
+8 -1
View File
@@ -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)
+99
View File
@@ -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`).