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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
auth::capability_sets::{
|
||||||
|
ensure_capability_set, load_capabilities_for_set, normalize_capabilities,
|
||||||
|
},
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{ApiToken, ApiTokenCapability, NewApiToken},
|
models::{ApiCapability, ApiToken, NewApiToken},
|
||||||
schema::api_tokens,
|
schema::api_tokens,
|
||||||
state::PgPooledConnection,
|
state::PgPooledConnection,
|
||||||
tenants::{apply_api_token_prefix, clear_api_token_prefix},
|
tenants::{apply_api_token_prefix, clear_api_token_prefix},
|
||||||
@@ -34,9 +37,10 @@ pub fn create_api_token(
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
expires_at: Option<NaiveDateTime>,
|
expires_at: Option<NaiveDateTime>,
|
||||||
capabilities: Vec<ApiTokenCapability>,
|
capabilities: Vec<ApiCapability>,
|
||||||
) -> Result<IssuedApiToken, AppError> {
|
) -> Result<IssuedApiToken, AppError> {
|
||||||
let capabilities = normalize_capabilities(capabilities)?;
|
let capabilities = normalize_capabilities(capabilities)?;
|
||||||
|
let capability_set = ensure_capability_set(conn, tenant_id, &capabilities)?;
|
||||||
|
|
||||||
let raw_secret = generate_secret()?;
|
let raw_secret = generate_secret()?;
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||||
@@ -49,7 +53,7 @@ pub fn create_api_token(
|
|||||||
token_hash,
|
token_hash,
|
||||||
label,
|
label,
|
||||||
expires_at,
|
expires_at,
|
||||||
capabilities,
|
capability_set_id: capability_set.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let record = diesel::insert_into(api_tokens::table)
|
let record = diesel::insert_into(api_tokens::table)
|
||||||
@@ -122,7 +126,7 @@ pub fn update_api_token_capabilities(
|
|||||||
token_id: Uuid,
|
token_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
tenant_id: Option<Uuid>,
|
tenant_id: Option<Uuid>,
|
||||||
capabilities: Vec<ApiTokenCapability>,
|
capabilities: Vec<ApiCapability>,
|
||||||
) -> Result<ApiToken, AppError> {
|
) -> Result<ApiToken, AppError> {
|
||||||
let capabilities = normalize_capabilities(capabilities)?;
|
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))
|
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)?;
|
.get_result::<ApiToken>(conn)?;
|
||||||
|
|
||||||
Ok(updated)
|
Ok(updated)
|
||||||
@@ -147,7 +153,7 @@ pub fn find_active_token_by_secret(
|
|||||||
conn: &mut PgPooledConnection,
|
conn: &mut PgPooledConnection,
|
||||||
tenant_id: Option<Uuid>,
|
tenant_id: Option<Uuid>,
|
||||||
secret: &str,
|
secret: &str,
|
||||||
required_capability: ApiTokenCapability,
|
required_capability: ApiCapability,
|
||||||
) -> Result<Option<ApiToken>, AppError> {
|
) -> Result<Option<ApiToken>, AppError> {
|
||||||
if secret.len() < TOKEN_PREFIX_LENGTH {
|
if secret.len() < TOKEN_PREFIX_LENGTH {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -175,7 +181,8 @@ pub fn find_active_token_by_secret(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
for token in candidates {
|
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;
|
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(
|
fn find_user_token(
|
||||||
conn: &mut PgPooledConnection,
|
conn: &mut PgPooledConnection,
|
||||||
token_id: Uuid,
|
token_id: Uuid,
|
||||||
@@ -299,6 +289,9 @@ fn hash_secret(secret: &str) -> Result<String, AppError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::capability_sets::{
|
||||||
|
compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_secret_has_expected_length() {
|
fn generated_secret_has_expected_length() {
|
||||||
@@ -316,15 +309,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_capabilities_deduplicates() {
|
fn normalize_capabilities_deduplicates() {
|
||||||
let caps = normalize_capabilities(vec![
|
let mut caps = owner_capabilities().to_vec();
|
||||||
ApiTokenCapability::Api,
|
caps.push(ApiCapability::DocumentsRead);
|
||||||
ApiTokenCapability::Webdav,
|
let normalized = normalize_capabilities(caps).unwrap();
|
||||||
ApiTokenCapability::Api,
|
assert_eq!(normalized.len(), owner_capabilities().len());
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(caps.len(), 2);
|
|
||||||
assert!(caps.contains(&ApiTokenCapability::Api));
|
|
||||||
assert!(caps.contains(&ApiTokenCapability::Webdav));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -332,6 +320,15 @@ mod tests {
|
|||||||
assert!(normalize_capabilities(Vec::new()).is_err());
|
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]
|
#[test]
|
||||||
fn prefix_length_is_less_than_secret_length() {
|
fn prefix_length_is_less_than_secret_length() {
|
||||||
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
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;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct JwtService {
|
pub struct JwtService {
|
||||||
encoding: EncodingKey,
|
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 now = Utc::now();
|
||||||
let exp = now + self.expiry;
|
let exp = now + self.expiry;
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id,
|
sub: context.user_id,
|
||||||
tenant_id,
|
tenant_id: context.tenant_id,
|
||||||
username: username.to_owned(),
|
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(),
|
iss: self.issuer.clone(),
|
||||||
aud: self.audience.clone(),
|
aud: self.audience.clone(),
|
||||||
iat: now.timestamp() as usize,
|
iat: now.timestamp() as usize,
|
||||||
@@ -148,6 +170,10 @@ pub struct Claims {
|
|||||||
pub sub: Uuid,
|
pub sub: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub principal_kind: PrincipalKind,
|
||||||
|
pub principal_id: Uuid,
|
||||||
|
pub capability_set_id: Uuid,
|
||||||
|
pub cap_version: i32,
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
pub aud: String,
|
pub aud: String,
|
||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
pub mod api_tokens;
|
pub mod api_tokens;
|
||||||
|
pub mod capability_guard;
|
||||||
|
pub mod capability_sets;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod passkeys;
|
pub mod passkeys;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
@@ -10,16 +12,25 @@ use serde::{Deserialize, Serialize};
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
|
||||||
error::AppError,
|
error::AppError,
|
||||||
|
models::ApiCapability,
|
||||||
state::{AppState, PgPooledConnection},
|
state::{AppState, PgPooledConnection},
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::jwt::PrincipalKind;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub tenant_id: uuid::Uuid,
|
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]
|
#[async_trait]
|
||||||
@@ -44,10 +55,26 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
|||||||
.verify_token(bearer.token())
|
.verify_token(bearer.token())
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.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 {
|
let user = AuthenticatedUser {
|
||||||
user_id: claims.sub,
|
user_id: claims.sub,
|
||||||
username: claims.username,
|
username: claims.username,
|
||||||
tenant_id: claims.tenant_id,
|
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());
|
parts.extensions.insert(user.clone());
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use sha2::{Digest, Sha256};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use papercrate::{
|
use papercrate::{
|
||||||
|
auth::capability_sets::{ensure_capability_set, owner_capabilities},
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::{self, PgPool},
|
db::{self, PgPool},
|
||||||
documents::search::ensure_quickwit_index,
|
documents::search::ensure_quickwit_index,
|
||||||
@@ -363,10 +364,15 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<
|
|||||||
.optional()?
|
.optional()?
|
||||||
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?;
|
.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 {
|
let membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
|
capability_set_id: Some(owner_capability_set_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
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_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
|
||||||
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
|
||||||
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
pub const JOB_PROVISION_TENANT: &str = "provision-tenant";
|
||||||
|
pub const JOB_PURGE_DOCUMENT: &str = "purge-document";
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum JobQueueError {
|
pub enum JobQueueError {
|
||||||
|
|||||||
+180
-23
@@ -14,7 +14,7 @@ use uuid::Uuid;
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::schema::sql_types::{
|
use crate::schema::sql_types::{
|
||||||
ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
||||||
TenantStatus as TenantStatusSql,
|
TenantStatus as TenantStatusSql,
|
||||||
};
|
};
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
@@ -29,6 +29,7 @@ pub struct UserMembership {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub capability_set_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -37,6 +38,7 @@ pub struct NewUserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
|
pub capability_set_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||||
@@ -57,13 +59,58 @@ pub enum MagicTokenKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[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)]
|
#[diesel(sql_type = ApiCapabilitySql)]
|
||||||
#[serde(rename_all = "snake_case")]
|
pub enum ApiCapability {
|
||||||
pub enum ApiTokenCapability {
|
#[serde(rename = "documents:read")]
|
||||||
Api,
|
DocumentsRead,
|
||||||
Webdav,
|
#[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 {
|
impl MagicTokenKind {
|
||||||
@@ -79,16 +126,53 @@ impl MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiTokenCapability {
|
impl ApiCapability {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ApiTokenCapability::Api => "api",
|
ApiCapability::DocumentsRead => "documents:read",
|
||||||
ApiTokenCapability::Webdav => "webdav",
|
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] {
|
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 {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.as_str())
|
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 {
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
out.write_all(self.as_str().as_bytes())?;
|
||||||
Ok(IsNull::No)
|
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> {
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
match std::str::from_utf8(bytes.as_bytes())? {
|
||||||
"api" => Ok(ApiTokenCapability::Api),
|
"documents:read" => Ok(ApiCapability::DocumentsRead),
|
||||||
"webdav" => Ok(ApiTokenCapability::Webdav),
|
"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(
|
other => Err(Box::new(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidData,
|
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;
|
type Err = &'static str;
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
match value {
|
match value {
|
||||||
"api" => Ok(ApiTokenCapability::Api),
|
"documents:read" => Ok(ApiCapability::DocumentsRead),
|
||||||
"webdav" => Ok(ApiTokenCapability::Webdav),
|
"documents:edit" => Ok(ApiCapability::DocumentsEdit),
|
||||||
_ => Err("unsupported api token capability"),
|
"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 last_used_at: Option<NaiveDateTime>,
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
pub revoked_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)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -335,7 +492,7 @@ pub struct NewApiToken {
|
|||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
pub capabilities: Vec<ApiTokenCapability>,
|
pub capability_set_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ impl OpenApi for ApiDoc {
|
|||||||
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
|
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
|
||||||
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
|
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
|
||||||
doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
|
doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
|
||||||
|
doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi());
|
||||||
|
|
||||||
doc.info = InfoBuilder::new()
|
doc.info = InfoBuilder::new()
|
||||||
.title("Papercrate API")
|
.title("Papercrate API")
|
||||||
@@ -51,6 +52,10 @@ impl OpenApi for ApiDoc {
|
|||||||
.name("Profile")
|
.name("Profile")
|
||||||
.description(Some("User profile and WebDAV tokens"))
|
.description(Some("User profile and WebDAV tokens"))
|
||||||
.build(),
|
.build(),
|
||||||
|
TagBuilder::new()
|
||||||
|
.name("Capability Sets")
|
||||||
|
.description(Some("Capability set management"))
|
||||||
|
.build(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
doc
|
doc
|
||||||
@@ -68,12 +73,15 @@ pub mod schemas {
|
|||||||
DocumentVersionDetailResponse, DocumentVersionResponse,
|
DocumentVersionDetailResponse, DocumentVersionResponse,
|
||||||
};
|
};
|
||||||
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
||||||
pub use crate::models::ApiTokenCapability;
|
pub use crate::models::ApiCapability;
|
||||||
pub use crate::routes::auth::{
|
pub use crate::routes::auth::{
|
||||||
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
||||||
};
|
};
|
||||||
|
pub use crate::routes::capability_sets::{
|
||||||
|
CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
|
||||||
|
};
|
||||||
pub use crate::routes::correspondents::{
|
pub use crate::routes::correspondents::{
|
||||||
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
||||||
UpdateCorrespondentRequest,
|
UpdateCorrespondentRequest,
|
||||||
|
|||||||
+52
-12
@@ -19,6 +19,8 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{
|
||||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||||
|
capability_sets::load_capability_set,
|
||||||
|
jwt::{AccessTokenContext, PrincipalKind},
|
||||||
passkeys::{
|
passkeys::{
|
||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
@@ -27,8 +29,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus,
|
ApiCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User,
|
||||||
User, UserSession,
|
UserMembership, UserSession,
|
||||||
},
|
},
|
||||||
schema::{
|
schema::{
|
||||||
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
|
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::PasskeyRegistrationFinishPayload,
|
||||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||||
crate::models::ApiTokenCapability,
|
crate::models::ApiCapability,
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
pub struct AuthApiDoc;
|
pub struct AuthApiDoc;
|
||||||
@@ -230,7 +232,7 @@ pub async fn api_token_exchange(
|
|||||||
|
|
||||||
let mut conn = state.db_unscoped()?;
|
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)?;
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
|
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
|
let membership = memberships_dsl::user_memberships
|
||||||
.filter(memberships_dsl::user_id.eq(user.id))
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||||
.select(memberships_dsl::tenant_id)
|
.first::<UserMembership>(&mut conn)
|
||||||
.first::<Uuid>(&mut conn)
|
|
||||||
.optional()?;
|
.optional()?;
|
||||||
clear_user_guc(&mut conn)?;
|
clear_user_guc(&mut conn)?;
|
||||||
|
|
||||||
if membership.is_none() {
|
let membership = membership.ok_or_else(AppError::unauthorized)?;
|
||||||
return Err(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)?;
|
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||||
touch_api_token(&mut conn, token.id)?;
|
touch_api_token(&mut conn, token.id)?;
|
||||||
|
|
||||||
let access_token = state
|
let access_token = state
|
||||||
.jwt
|
.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)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let tenant_name: String = tenant_dsl::tenants
|
let tenant_name: String = tenant_dsl::tenants
|
||||||
@@ -856,10 +873,33 @@ fn issue_session(
|
|||||||
clear_user_guc(conn)?;
|
clear_user_guc(conn)?;
|
||||||
clear_user_session_hash(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 now = Utc::now();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
let access_token = state
|
let access_token = state
|
||||||
.jwt
|
.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)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let tenant_name: String = tenant_dsl::tenants
|
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 refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_session = NewUserSession {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: session_id,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: session_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
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},
|
tags::{assign_tags as assign_tags_to_document, load_tags_for_documents},
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
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::{
|
use crate::models::{
|
||||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag,
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag,
|
||||||
NewDocumentVersion, Tag,
|
NewDocumentVersion, Tag,
|
||||||
@@ -1288,13 +1290,13 @@ pub async fn download_with_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
post,
|
||||||
path = "/api/documents/{id}",
|
path = "/api/documents/{id}/trash",
|
||||||
params(("id" = Uuid, Path, description = "Document ID")),
|
params(("id" = Uuid, Path, description = "Document ID")),
|
||||||
responses((status = 204, description = "Document deleted")),
|
responses((status = 204, description = "Document deleted")),
|
||||||
tag = "Documents"
|
tag = "Documents"
|
||||||
)]
|
)]
|
||||||
pub async fn delete_document(
|
pub async fn trash_document(
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
@@ -1316,6 +1318,50 @@ pub async fn delete_document(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
#[utoipa::path(
|
||||||
patch,
|
patch,
|
||||||
path = "/api/documents/{id}",
|
path = "/api/documents/{id}",
|
||||||
@@ -2433,6 +2479,7 @@ pub(crate) fn hydrate_documents(
|
|||||||
crate::routes::documents::upload_document,
|
crate::routes::documents::upload_document,
|
||||||
crate::routes::documents::get_document,
|
crate::routes::documents::get_document,
|
||||||
crate::routes::documents::update_document,
|
crate::routes::documents::update_document,
|
||||||
|
crate::routes::documents::trash_document,
|
||||||
crate::routes::documents::delete_document,
|
crate::routes::documents::delete_document,
|
||||||
crate::routes::documents::restore_document,
|
crate::routes::documents::restore_document,
|
||||||
crate::routes::documents::download_with_token,
|
crate::routes::documents::download_with_token,
|
||||||
|
|||||||
+240
-36
@@ -13,9 +13,15 @@ use tower_http::{
|
|||||||
};
|
};
|
||||||
use utoipa::OpenApi;
|
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 auth;
|
||||||
|
pub mod capability_sets;
|
||||||
pub mod correspondents;
|
pub mod correspondents;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
@@ -75,93 +81,290 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
.route("/check", get(documents::check_document))
|
.route(
|
||||||
|
"/check",
|
||||||
|
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::DocumentsRead,
|
||||||
|
])),
|
||||||
|
)
|
||||||
.route(
|
.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(
|
.route(
|
||||||
"/bulk/correspondents",
|
"/bulk/correspondents",
|
||||||
post(documents::bulk_assign_correspondents),
|
post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::DocumentsEdit,
|
||||||
|
])),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/bulk/reanalyze",
|
"/bulk/reanalyze",
|
||||||
post(documents::reanalyze_selected_documents),
|
post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::DocumentsWrite,
|
||||||
|
])),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/:id",
|
"/:id",
|
||||||
get(documents::get_document)
|
get(documents::get_document).layer(RequireCapabilitiesLayer::all([
|
||||||
.delete(documents::delete_document)
|
ApiCapability::DocumentsRead,
|
||||||
.patch(documents::update_document),
|
])),
|
||||||
|
)
|
||||||
|
.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(
|
.route(
|
||||||
"/:id/assets",
|
"/: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(
|
.route(
|
||||||
"/:id/versions/:version_id",
|
"/: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(
|
.route(
|
||||||
"/:id/correspondents",
|
"/:id/correspondents",
|
||||||
post(documents::assign_correspondents),
|
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::DocumentsEdit,
|
||||||
|
])),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/:id/correspondents/:correspondent_id",
|
"/:id/correspondents/:correspondent_id",
|
||||||
delete(documents::remove_correspondent),
|
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::DocumentsEdit,
|
||||||
|
])),
|
||||||
);
|
);
|
||||||
|
|
||||||
let download_routes =
|
let download_routes =
|
||||||
Router::new().route("/download/:token", get(documents::download_with_token));
|
Router::new().route("/download/:token", get(documents::download_with_token));
|
||||||
|
|
||||||
let folders_routes = Router::new()
|
let folders_routes = Router::new()
|
||||||
.route("/", post(folders::create_folder))
|
.route(
|
||||||
.route("/path", post(folders::ensure_folder_path))
|
"/",
|
||||||
.route("/:id", get(folders::get_folder))
|
post(folders::create_folder)
|
||||||
.route("/:id", delete(folders::delete_folder))
|
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
|
||||||
.route("/:id", patch(folders::update_folder))
|
)
|
||||||
.route("/:id/contents", get(folders::list_folder_contents));
|
.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()
|
let tags_routes = Router::new()
|
||||||
.route("/", get(tags::list_tags).post(tags::create_tag))
|
.route(
|
||||||
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
|
"/",
|
||||||
|
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()
|
let correspondents_routes = Router::new()
|
||||||
.route(
|
.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(
|
.route(
|
||||||
"/:id",
|
"/:id",
|
||||||
patch(correspondents::update_correspondent)
|
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||||
.delete(correspondents::delete_correspondent),
|
ApiCapability::CorrespondentsEdit,
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/:id",
|
||||||
|
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::CorrespondentsWrite,
|
||||||
|
])),
|
||||||
);
|
);
|
||||||
|
|
||||||
let profile_routes = Router::new()
|
let profile_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/api-tokens",
|
"/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(
|
.route(
|
||||||
"/api-tokens/:id/regenerate",
|
"/api-tokens/:id/regenerate",
|
||||||
post(profile::regenerate_api_token),
|
post(profile::regenerate_api_token)
|
||||||
|
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api-tokens/:id",
|
"/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(
|
||||||
.route("/passkeys/:id", delete(profile::delete_passkey));
|
"/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 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()
|
let protected_routes = Router::new()
|
||||||
.nest("/api/documents", documents_routes)
|
.nest("/api/documents", documents_routes)
|
||||||
@@ -169,6 +372,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
.nest("/api/profile", profile_routes)
|
.nest("/api/profile", profile_routes)
|
||||||
|
.nest("/api/capability-sets", capability_sets_routes)
|
||||||
.nest("/api/assets", assets_routes)
|
.nest("/api/assets", assets_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.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,
|
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
|
||||||
update_api_token_capabilities as update_capabilities,
|
update_api_token_capabilities as update_capabilities,
|
||||||
},
|
},
|
||||||
|
capability_sets::load_capabilities_for_set,
|
||||||
passkeys::PasskeySummary,
|
passkeys::PasskeySummary,
|
||||||
TenantScopedConn,
|
TenantScopedConn,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{ApiToken, ApiTokenCapability};
|
use crate::models::{ApiCapability, ApiToken};
|
||||||
use crate::state::AppState;
|
use crate::state::{AppState, PgPooledConnection};
|
||||||
use crate::utils::{db::no_content, time::to_iso};
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
@@ -28,7 +29,7 @@ pub struct ApiTokenResponse {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub capabilities: Vec<ApiTokenCapability>,
|
pub capabilities: Vec<ApiCapability>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub last_used_at: Option<String>,
|
pub last_used_at: Option<String>,
|
||||||
@@ -52,12 +53,12 @@ pub struct CreateApiTokenRequest {
|
|||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub capabilities: Option<Vec<ApiTokenCapability>>,
|
pub capabilities: Option<Vec<ApiCapability>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
pub struct UpdateApiTokenCapabilitiesRequest {
|
pub struct UpdateApiTokenCapabilitiesRequest {
|
||||||
pub capabilities: Vec<ApiTokenCapability>,
|
pub capabilities: Vec<ApiCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
@@ -103,7 +104,10 @@ pub async fn list_api_tokens(
|
|||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
||||||
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
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))
|
Ok(Json(responses))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +134,11 @@ pub async fn create_api_token(
|
|||||||
|
|
||||||
let capabilities = payload
|
let capabilities = payload
|
||||||
.capabilities
|
.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(
|
let issued = issue_token(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
@@ -141,9 +149,11 @@ pub async fn create_api_token(
|
|||||||
capabilities,
|
capabilities,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let token_info = api_token_to_response(&mut conn, issued.record)?;
|
||||||
|
|
||||||
let response = ApiTokenCreatedResponse {
|
let response = ApiTokenCreatedResponse {
|
||||||
token: issued.token,
|
token: issued.token,
|
||||||
token_info: api_token_to_response(issued.record),
|
token_info,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(response)))
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
@@ -166,9 +176,10 @@ pub async fn regenerate_api_token(
|
|||||||
Path(token_id): Path<Uuid>,
|
Path(token_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<ApiTokenCreatedResponse>> {
|
) -> AppResult<Json<ApiTokenCreatedResponse>> {
|
||||||
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
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 {
|
let response = ApiTokenCreatedResponse {
|
||||||
token: issued.token,
|
token: issued.token,
|
||||||
token_info: api_token_to_response(issued.record),
|
token_info,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
@@ -192,6 +203,10 @@ pub async fn update_api_token(
|
|||||||
Path(token_id): Path<Uuid>,
|
Path(token_id): Path<Uuid>,
|
||||||
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
|
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
|
||||||
) -> AppResult<Json<ApiTokenResponse>> {
|
) -> AppResult<Json<ApiTokenResponse>> {
|
||||||
|
if payload.capabilities.is_empty() {
|
||||||
|
return Err(AppError::bad_request("at least one capability is required"));
|
||||||
|
}
|
||||||
|
|
||||||
let updated = update_capabilities(
|
let updated = update_capabilities(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
token_id,
|
token_id,
|
||||||
@@ -200,7 +215,9 @@ pub async fn update_api_token(
|
|||||||
payload.capabilities,
|
payload.capabilities,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(Json(api_token_to_response(updated)))
|
let response = api_token_to_response(&mut conn, updated)?;
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -254,7 +271,10 @@ pub async fn delete_passkey(
|
|||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
fn api_token_to_response(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token: ApiToken,
|
||||||
|
) -> AppResult<ApiTokenResponse> {
|
||||||
let ApiToken {
|
let ApiToken {
|
||||||
id,
|
id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -263,11 +283,13 @@ fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
|||||||
last_used_at,
|
last_used_at,
|
||||||
expires_at,
|
expires_at,
|
||||||
revoked_at,
|
revoked_at,
|
||||||
capabilities,
|
capability_set_id,
|
||||||
..
|
..
|
||||||
} = token;
|
} = token;
|
||||||
|
|
||||||
ApiTokenResponse {
|
let capabilities = load_capabilities_for_set(conn, capability_set_id)?;
|
||||||
|
|
||||||
|
Ok(ApiTokenResponse {
|
||||||
id,
|
id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
label,
|
label,
|
||||||
@@ -276,7 +298,7 @@ fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
|||||||
last_used_at: last_used_at.map(to_iso),
|
last_used_at: last_used_at.map(to_iso),
|
||||||
expires_at: expires_at.map(to_iso),
|
expires_at: expires_at.map(to_iso),
|
||||||
revoked_at: revoked_at.map(to_iso),
|
revoked_at: revoked_at.map(to_iso),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||||
@@ -297,7 +319,7 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
|||||||
crate::routes::profile::delete_passkey
|
crate::routes::profile::delete_passkey
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
crate::models::ApiTokenCapability,
|
crate::models::ApiCapability,
|
||||||
crate::routes::profile::ApiTokenResponse,
|
crate::routes::profile::ApiTokenResponse,
|
||||||
crate::routes::profile::ApiTokenCreatedResponse,
|
crate::routes::profile::ApiTokenCreatedResponse,
|
||||||
crate::routes::profile::CreateApiTokenRequest,
|
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::auth::api_tokens::{find_active_token_by_secret, touch_api_token};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User};
|
use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
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,
|
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,
|
&mut conn,
|
||||||
None,
|
None,
|
||||||
secret,
|
secret,
|
||||||
ApiTokenCapability::Webdav,
|
ApiCapability::WebdavRead,
|
||||||
)? {
|
)? {
|
||||||
Some(token) => token,
|
Some(token) => token,
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod analyze;
|
|||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod ocr;
|
pub mod ocr;
|
||||||
|
pub mod purge;
|
||||||
pub mod tenants;
|
pub mod tenants;
|
||||||
pub mod thumbnails;
|
pub mod thumbnails;
|
||||||
|
|
||||||
@@ -149,6 +150,7 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
|
|||||||
Arc::new(analyze::AnalyzeDocumentJob::new()),
|
Arc::new(analyze::AnalyzeDocumentJob::new()),
|
||||||
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
|
||||||
Arc::new(ocr::GenerateOcrTextJob::new()),
|
Arc::new(ocr::GenerateOcrTextJob::new()),
|
||||||
|
Arc::new(purge::PurgeDocumentJob::new()),
|
||||||
Arc::new(index::IndexDocumentTextJob::new()),
|
Arc::new(index::IndexDocumentTextJob::new()),
|
||||||
Arc::new(ProvisionTenantJob::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 tracing::warn;
|
||||||
use uuid::Uuid;
|
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::documents::search::ensure_quickwit_index;
|
||||||
use crate::jobs::JOB_PROVISION_TENANT;
|
use crate::jobs::JOB_PROVISION_TENANT;
|
||||||
use crate::models::{NewUserMembership, TenantStatus};
|
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) {
|
if let Some(members) = ProvisionPayload::from_job(&job) {
|
||||||
for member in members {
|
for member in members {
|
||||||
let new_membership = NewUserMembership {
|
let new_membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: member,
|
user_id: member,
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
|
capability_set_id: Some(owner_capability_set_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = diesel::insert_into(user_memberships::table)
|
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 base64::Engine;
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use papercrate::models::{ApiToken, ApiTokenCapability};
|
use papercrate::models::{ApiCapability, ApiToken};
|
||||||
use papercrate::routes::webdav;
|
use papercrate::routes::webdav;
|
||||||
use papercrate::schema::api_tokens;
|
use papercrate::schema::api_tokens;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -15,13 +15,46 @@ use serde_json::json;
|
|||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TokenInfo {
|
struct TokenInfo {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
last_used_at: Option<String>,
|
last_used_at: Option<String>,
|
||||||
revoked_at: Option<String>,
|
revoked_at: Option<String>,
|
||||||
capabilities: Vec<ApiTokenCapability>,
|
capabilities: Vec<ApiCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -55,11 +88,16 @@ async fn api_token_crud_flow() -> Result<()> {
|
|||||||
app.insert_user(username, password, "admin").await?;
|
app.insert_user(username, password, "admin").await?;
|
||||||
let access_token = app.login_token(username, password).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;
|
let token_id = created.info.id;
|
||||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||||
assert!(created.info.last_used_at.is_none());
|
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?;
|
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
||||||
assert_eq!(regenerated.info.id, token_id);
|
assert_eq!(regenerated.info.id, token_id);
|
||||||
@@ -70,12 +108,10 @@ async fn api_token_crud_flow() -> Result<()> {
|
|||||||
&app,
|
&app,
|
||||||
&access_token,
|
&access_token,
|
||||||
token_id,
|
token_id,
|
||||||
json!({ "capabilities": ["webdav", "api"] }),
|
json!({ "capabilities": OWNER_CAPS }),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(updated.capabilities.len(), 2);
|
assert_capabilities(&updated.capabilities, OWNER_CAPS);
|
||||||
assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav));
|
|
||||||
assert!(updated.capabilities.contains(&ApiTokenCapability::Api));
|
|
||||||
|
|
||||||
let listed = list_tokens(&app, &access_token).await?;
|
let listed = list_tokens(&app, &access_token).await?;
|
||||||
assert_eq!(listed.len(), 1);
|
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?;
|
app.insert_user(username, password, "admin").await?;
|
||||||
let access_token = app.login_token(username, password).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 token_id = created.info.id;
|
||||||
|
|
||||||
let router = webdav::create_router().with_state(app.state.clone());
|
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?;
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
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?;
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
let failure_request = Request::builder()
|
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);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
Ok(())
|
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> {
|
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
|
||||||
let response = app
|
let response = app
|
||||||
.post_json(
|
.post_json(
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ use axum::http::{header::SET_COOKIE, StatusCode};
|
|||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
|
||||||
|
use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind};
|
||||||
use papercrate::auth::passkeys::{
|
use papercrate::auth::passkeys::{
|
||||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
RegistrationChallengeResponse,
|
RegistrationChallengeResponse,
|
||||||
};
|
};
|
||||||
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
|
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
|
||||||
use papercrate::openapi::schemas::PasskeySummary;
|
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::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -522,10 +524,16 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
))
|
))
|
||||||
.execute(conn)?;
|
.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 {
|
let membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id: secondary_id,
|
tenant_id: secondary_id,
|
||||||
|
capability_set_id: Some(owner_capability_set_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
diesel::insert_into(user_memberships::table)
|
||||||
@@ -592,10 +600,28 @@ async fn login_with_session(
|
|||||||
let tenant: papercrate::models::Tenant =
|
let tenant: papercrate::models::Tenant =
|
||||||
tenants::table.find(membership.tenant_id).first(conn)?;
|
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 now = Utc::now();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
let access_token = state
|
let access_token = state
|
||||||
.jwt
|
.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))?;
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
let session_value = generate_session_token();
|
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 refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_session = NewUserSession {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: session_id,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: session_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
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(())
|
||||||
|
}
|
||||||
+77
-14
@@ -16,7 +16,10 @@ use diesel::PgConnection;
|
|||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use once_cell::sync::Lazy;
|
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::config::AppConfig;
|
||||||
use papercrate::db::{self, PgPool};
|
use papercrate::db::{self, PgPool};
|
||||||
use papercrate::models::{
|
use papercrate::models::{
|
||||||
@@ -81,7 +84,12 @@ impl ObjectStorage for FakeStorage {
|
|||||||
Ok(())
|
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;
|
let guard = self.objects.lock().await;
|
||||||
ensure!(guard.contains_key(key), "object {key} missing");
|
ensure!(guard.contains_key(key), "object {key} missing");
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
@@ -217,10 +225,12 @@ impl TestApp {
|
|||||||
Ok(format!("{}{}", root, key))
|
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 username = username.to_string();
|
||||||
|
let role = role.to_string();
|
||||||
let tenant_id = self.ensure_default_tenant().await?;
|
let tenant_id = self.ensure_default_tenant().await?;
|
||||||
self.with_conn(move |conn| {
|
let user_id = self
|
||||||
|
.with_conn(move |conn| {
|
||||||
let user = NewUser {
|
let user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username,
|
username,
|
||||||
@@ -230,10 +240,20 @@ impl TestApp {
|
|||||||
.execute(conn)
|
.execute(conn)
|
||||||
.context("failed to insert user")?;
|
.context("failed to insert user")?;
|
||||||
|
|
||||||
|
let capabilities = match role.as_str() {
|
||||||
|
"admin" => owner_capabilities(),
|
||||||
|
"webdav" => webdav_capabilities(),
|
||||||
|
_ => user_capabilities(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let capability_set = ensure_capability_set(conn, tenant_id, capabilities)
|
||||||
|
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
|
||||||
|
|
||||||
let membership = NewUserMembership {
|
let membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
|
capability_set_id: Some(capability_set.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(papercrate::schema::user_memberships::table)
|
diesel::insert_into(papercrate::schema::user_memberships::table)
|
||||||
@@ -242,9 +262,12 @@ impl TestApp {
|
|||||||
.context("failed to insert user membership")?;
|
.context("failed to insert user membership")?;
|
||||||
Ok(user.id)
|
Ok(user.id)
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
|
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
|
||||||
let passkey_id = Uuid::new_v4();
|
let passkey_id = Uuid::new_v4();
|
||||||
let nickname = nickname.map(|value| value.to_string());
|
let nickname = nickname.map(|value| value.to_string());
|
||||||
@@ -276,7 +299,8 @@ impl TestApp {
|
|||||||
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
||||||
let name_value = TEST_TENANT_NAME.to_string();
|
let name_value = TEST_TENANT_NAME.to_string();
|
||||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
||||||
self.with_conn(move |conn| {
|
let tenant_id = self
|
||||||
|
.with_conn(move |conn| {
|
||||||
use papercrate::schema::tenants::dsl as tenants_dsl;
|
use papercrate::schema::tenants::dsl as tenants_dsl;
|
||||||
|
|
||||||
let existing = tenants_dsl::tenants
|
let existing = tenants_dsl::tenants
|
||||||
@@ -325,7 +349,21 @@ impl TestApp {
|
|||||||
|
|
||||||
Ok(tenant_id)
|
Ok(tenant_id)
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
let mut conn = self
|
||||||
|
.state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?;
|
||||||
|
|
||||||
|
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> {
|
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
|
||||||
@@ -337,6 +375,7 @@ impl TestApp {
|
|||||||
let username = username.to_string();
|
let username = username.to_string();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
self.with_conn(move |conn| {
|
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::tenants::dsl as tenants_dsl;
|
||||||
use papercrate::schema::user_memberships::dsl as memberships_dsl;
|
use papercrate::schema::user_memberships::dsl as memberships_dsl;
|
||||||
use papercrate::schema::users::dsl as users_dsl;
|
use papercrate::schema::users::dsl as users_dsl;
|
||||||
@@ -353,10 +392,28 @@ impl TestApp {
|
|||||||
.find(membership.tenant_id)
|
.find(membership.tenant_id)
|
||||||
.first(conn)?;
|
.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 now = Utc::now();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
let access_token = state
|
let access_token = state
|
||||||
.jwt
|
.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))?;
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
let session_value = generate_session_token();
|
let session_value = generate_session_token();
|
||||||
@@ -365,7 +422,7 @@ impl TestApp {
|
|||||||
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_session = NewUserSession {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: session_id,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: session_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
issued_at: now.naive_utc(),
|
||||||
@@ -540,7 +597,7 @@ impl TestApp {
|
|||||||
tag_ids_json: None,
|
tag_ids_json: None,
|
||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: None,
|
||||||
};
|
};
|
||||||
self.upload_document_with_extras(
|
self.upload_document_with_extras(
|
||||||
path,
|
path,
|
||||||
@@ -620,9 +677,15 @@ impl TestApp {
|
|||||||
body.extend(b"\r\n");
|
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(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());
|
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||||
@@ -668,7 +731,7 @@ pub struct UploadExtras<'a> {
|
|||||||
pub tag_ids_json: Option<&'a str>,
|
pub tag_ids_json: Option<&'a str>,
|
||||||
pub correspondents_json: Option<&'a str>,
|
pub correspondents_json: Option<&'a str>,
|
||||||
pub issued_at: Option<&'a str>,
|
pub issued_at: Option<&'a str>,
|
||||||
pub skip_existing: bool,
|
pub skip_existing: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> UploadExtras<'a> {
|
impl<'a> UploadExtras<'a> {
|
||||||
@@ -679,7 +742,7 @@ impl<'a> UploadExtras<'a> {
|
|||||||
tag_ids_json: None,
|
tag_ids_json: None,
|
||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+209
-47
@@ -1,12 +1,16 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
|
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentDetail {
|
struct DocumentDetail {
|
||||||
document: DocumentInfo,
|
document: DocumentInfo,
|
||||||
@@ -17,6 +21,8 @@ struct ApiErrorResponse {
|
|||||||
error: String,
|
error: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
details: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -291,15 +297,16 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
{
|
let first_status = first.status();
|
||||||
let status = first.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let first_body = body_to_vec(first.into_body()).await?;
|
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 first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
let second = app
|
let second = app
|
||||||
@@ -312,59 +319,69 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
{
|
let second_status = second.status();
|
||||||
let status = second.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let second_body = body_to_vec(second.into_body()).await?;
|
let second_body = body_to_vec(second.into_body()).await?;
|
||||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
assert_eq!(second_status, StatusCode::CONFLICT);
|
||||||
|
let second_error: ApiErrorResponse = serde_json::from_slice(&second_body)?;
|
||||||
assert_eq!(first_detail.document.id, second_detail.document.id);
|
assert_eq!(second_error.code.as_deref(), Some("duplicate_document"));
|
||||||
assert_eq!(second_detail.document.deleted_at, None);
|
let conflict_id = second_error
|
||||||
assert!(second_detail
|
.details
|
||||||
.document
|
|
||||||
.current_version
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("second current version")
|
.and_then(|details| details.get("conflict_document_id"))
|
||||||
.assets
|
.and_then(|value| value.as_str())
|
||||||
.is_empty());
|
.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);
|
assert_eq!(app.storage().object_count().await, 1);
|
||||||
|
|
||||||
let delete = app
|
let delete = app
|
||||||
.delete(
|
.post_json(
|
||||||
&format!("/api/documents/{}", first_detail.document.id),
|
&format!("/api/documents/{}/trash", first_detail.document.id),
|
||||||
|
&json!({}),
|
||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
|
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
let third = app
|
let third = app
|
||||||
.upload_document(
|
.upload_document_with_extras(
|
||||||
"/api/documents",
|
"/api/documents",
|
||||||
"dup.bin",
|
"dup.bin",
|
||||||
"application/octet-stream",
|
"application/octet-stream",
|
||||||
&payload,
|
&payload,
|
||||||
None,
|
None,
|
||||||
|
UploadExtras {
|
||||||
|
title: None,
|
||||||
|
metadata_json: None,
|
||||||
|
tag_ids_json: None,
|
||||||
|
correspondents_json: None,
|
||||||
|
issued_at: None,
|
||||||
|
skip_existing: Some(false),
|
||||||
|
},
|
||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
{
|
let third_status = third.status();
|
||||||
let status = third.status();
|
|
||||||
assert!(
|
|
||||||
status == StatusCode::OK
|
|
||||||
|| status == StatusCode::CREATED
|
|
||||||
|| status == StatusCode::NO_CONTENT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let third_body = body_to_vec(third.into_body()).await?;
|
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)?;
|
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.id, first_detail.document.id);
|
||||||
assert_eq!(third_detail.document.deleted_at, None);
|
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);
|
assert_eq!(app.storage().object_count().await, 1);
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
@@ -406,7 +423,7 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
|
|||||||
tag_ids_json: Some(primary_tag_ids.as_str()),
|
tag_ids_json: Some(primary_tag_ids.as_str()),
|
||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: false,
|
skip_existing: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let first_upload = app
|
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()),
|
tag_ids_json: Some(alt_tag_ids.as_str()),
|
||||||
correspondents_json: None,
|
correspondents_json: None,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
skip_existing: true,
|
skip_existing: Some(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
let skip_resp = app
|
let skip_resp = app
|
||||||
@@ -470,7 +487,18 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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
|
let fetch = app
|
||||||
.get(
|
.get(
|
||||||
@@ -1407,8 +1435,9 @@ async fn list_documents_by_status_filter() -> Result<()> {
|
|||||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
let delete_resp = app
|
let delete_resp = app
|
||||||
.delete(
|
.post_json(
|
||||||
&format!("/api/documents/{}", detail.document.id),
|
&format!("/api/documents/{}/trash", detail.document.id),
|
||||||
|
&json!({}),
|
||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1437,6 +1466,137 @@ async fn list_documents_by_status_filter() -> Result<()> {
|
|||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
|
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 detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
let delete_resp = app
|
let delete_resp = app
|
||||||
.delete(
|
.post_json(
|
||||||
&format!("/api/documents/{}", detail.document.id),
|
&format!("/api/documents/{}/trash", detail.document.id),
|
||||||
|
&json!({}),
|
||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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 folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||||
|
|
||||||
let delete_again = app
|
let delete_again = app
|
||||||
.delete(
|
.post_json(
|
||||||
&format!("/api/documents/{}", detail.document.id),
|
&format!("/api/documents/{}/trash", detail.document.id),
|
||||||
|
&json!({}),
|
||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
|
||||||
use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus};
|
use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus};
|
||||||
use papercrate::schema::{
|
use papercrate::schema::{
|
||||||
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
|
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)
|
.values(&new_user)
|
||||||
.execute(conn)?;
|
.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 {
|
let membership = NewUserMembership {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user_b_id,
|
user_id: user_b_id,
|
||||||
tenant_id: tenant_b_id,
|
tenant_id: tenant_b_id,
|
||||||
|
capability_set_id: Some(owner_capability_set_id),
|
||||||
};
|
};
|
||||||
diesel::insert_into(memberships_dsl::user_memberships)
|
diesel::insert_into(memberships_dsl::user_memberships)
|
||||||
.values(&membership)
|
.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