caps and delete
This commit is contained in:
@@ -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');
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`).
|
||||
Reference in New Issue
Block a user