9 Commits
Author SHA1 Message Date
nils 9bf1fc1983 fix
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 49s
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 17m1s
2025-11-07 11:29:48 +01:00
nils 7f53bc900a refactor 2025-11-06 22:52:25 +01:00
nils ef2ed2dc58 refactor 2025-11-06 22:21:27 +01:00
nils a6f79dbc75 refactor 2025-11-06 12:26:29 +01:00
nils 4027ac66cb folder tree 2025-11-06 00:51:38 +01:00
nils 911051e75d order 2025-11-05 23:01:42 +01:00
nils c193b30c8e capsets 2025-11-05 22:15:27 +01:00
nils a75281aa13 readonly role 2025-11-05 14:10:57 +01:00
nils 480bc20ae7 caps and delete 2025-11-05 12:32:10 +01:00
64 changed files with 7552 additions and 3534 deletions
@@ -0,0 +1,28 @@
CREATE TYPE api_token_capability AS ENUM ('api', 'webdav');
ALTER TABLE tenant.api_tokens
ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[];
UPDATE tenant.api_tokens t
SET capabilities = ARRAY['api']::api_token_capability[]
FROM tenant.capability_sets cs
WHERE t.capability_set_id = cs.id
AND cs.slug = 'owner';
UPDATE tenant.api_tokens t
SET capabilities = ARRAY['webdav']::api_token_capability[]
FROM tenant.capability_sets cs
WHERE t.capability_set_id = cs.id
AND cs.slug = 'webdav'
AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0);
ALTER TABLE tenant.api_tokens
DROP COLUMN capability_set_id;
ALTER TABLE tenant.user_memberships
DROP COLUMN capability_set_id;
DROP TABLE IF EXISTS tenant.capability_set_capabilities;
DROP TABLE IF EXISTS tenant.capability_sets;
DROP TYPE IF EXISTS api_capability;
@@ -0,0 +1,158 @@
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
),
readonly_sets AS (
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
SELECT id, 'readonly', TRUE
FROM shared.tenants
RETURNING id, tenant_id
),
webdav_sets AS (
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
SELECT id, 'webdav', TRUE
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 rs.id,
UNNEST(ARRAY[
'documents:read'::api_capability,
'folders:read'::api_capability,
'tags:read'::api_capability,
'correspondents:read'::api_capability,
'webdav:read'::api_capability
]) AS capability
FROM readonly_sets rs
UNION ALL
SELECT ws.id,
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
FROM webdav_sets ws
) 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');
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order;
DROP COLLATION IF EXISTS unicode_ci;
@@ -0,0 +1,37 @@
CREATE COLLATION IF NOT EXISTS unicode_ci
(provider = icu, locale = 'und-u-ks-level2');
CREATE INDEX idx_documents_tenant_folder_title_order
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_issued_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
issued_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_created_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
created_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_updated_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
updated_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
+42 -57
View File
@@ -9,8 +9,9 @@ use rand::RngCore;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
error::AppError, error::AppError,
models::{ApiToken, ApiTokenCapability, NewApiToken}, models::{ApiCapability, ApiToken, CapabilitySet, 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 +35,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>, capability_set_id: Uuid,
) -> Result<IssuedApiToken, AppError> { ) -> Result<IssuedApiToken, AppError> {
let capabilities = normalize_capabilities(capabilities)?; let capability_set =
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
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 +51,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)
@@ -116,38 +118,13 @@ pub fn regenerate_api_token(
}) })
} }
/// Updates the set of capabilities associated with an API token.
pub fn update_api_token_capabilities(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
capabilities: Vec<ApiTokenCapability>,
) -> Result<ApiToken, AppError> {
let capabilities = normalize_capabilities(capabilities)?;
let token = find_user_token(conn, token_id, user_id, tenant_id)?;
if token.revoked_at.is_some() {
return Err(AppError::bad_request(
"cannot modify capabilities of a revoked API token",
));
}
let updated = diesel::update(api_tokens::table.find(token.id))
.set(api_tokens::capabilities.eq(capabilities))
.get_result::<ApiToken>(conn)?;
Ok(updated)
}
/// Attempts to resolve an API token by its secret value while ensuring it provides the /// Attempts to resolve an API token by its secret value while ensuring it provides the
/// requested capability. /// requested capability.
pub fn find_active_token_by_secret( 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: Option<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,9 +152,12 @@ pub fn find_active_token_by_secret(
})?; })?;
for token in candidates { for token in candidates {
if !token.capabilities.contains(&required_capability) { if let Some(required) = required_capability {
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
if !capabilities.contains(&required) {
continue; continue;
} }
}
if verify_token_secret(secret, &token.token_hash)? { if verify_token_secret(secret, &token.token_hash)? {
return Ok(Some(token)); return Ok(Some(token));
@@ -218,23 +198,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,
@@ -257,6 +220,21 @@ fn find_user_token(
.ok_or_else(AppError::not_found) .ok_or_else(AppError::not_found)
} }
fn validate_capability_set_belongs_to_tenant(
conn: &mut PgPooledConnection,
capability_set_id: Uuid,
tenant_id: Uuid,
) -> Result<CapabilitySet, AppError> {
let capability_set = load_capability_set(conn, capability_set_id)?;
if capability_set.tenant_id != tenant_id {
return Err(AppError::bad_request(
"capability set does not belong to the tenant",
));
}
Ok(capability_set)
}
fn with_api_token_prefix<T, F>( fn with_api_token_prefix<T, F>(
conn: &mut PgPooledConnection, conn: &mut PgPooledConnection,
prefix: &str, prefix: &str,
@@ -299,6 +277,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 +297,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 +308,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);
+120
View File
@@ -0,0 +1,120 @@
use std::sync::Arc;
use axum::{
http::{Request, StatusCode},
response::IntoResponse,
};
use tower::{Layer, Service};
use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability};
#[derive(Clone, Copy)]
pub enum CapabilityStrategy {
All,
Any,
}
#[derive(Clone)]
pub struct RequireCapabilitiesLayer {
required: Arc<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl RequireCapabilitiesLayer {
pub fn all<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::All,
}
}
pub fn any<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::Any,
}
}
}
impl<S> Layer<S> for RequireCapabilitiesLayer {
type Service = RequireCapabilities<S>;
fn layer(&self, inner: S) -> Self::Service {
RequireCapabilities {
inner,
required: Arc::clone(&self.required),
strategy: self.strategy,
}
}
}
#[derive(Clone)]
pub struct RequireCapabilities<S> {
inner: S,
required: Arc<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
where
S: Service<Request<B>, Response = axum::response::Response> + Send,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> Self::Future {
if self.required.is_empty() {
let fut = self.inner.call(req);
return Box::pin(async move { fut.await });
}
let (parts, body) = req.into_parts();
let user = match parts.extensions.get::<AuthenticatedUser>() {
Some(user) => user,
None => {
let response = AppError::unauthorized().into_response();
return Box::pin(async move { Ok(response) });
}
};
let allowed = match self.strategy {
CapabilityStrategy::All => self
.required
.iter()
.all(|cap| user.capabilities.contains(cap)),
CapabilityStrategy::Any => self
.required
.iter()
.any(|cap| user.capabilities.contains(cap)),
};
if !allowed {
let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability")
.with_code("missing_capability")
.into_response();
return Box::pin(async move { Ok(response) });
}
let req = Request::from_parts(parts, body);
let fut = self.inner.call(req);
Box::pin(async move { fut.await })
}
}
+320
View File
@@ -0,0 +1,320 @@
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 READONLY_CAPABILITIES: [ApiCapability; 5] = [
ApiCapability::CorrespondentsRead,
ApiCapability::DocumentsRead,
ApiCapability::FoldersRead,
ApiCapability::TagsRead,
ApiCapability::WebdavRead,
];
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
pub fn owner_capabilities() -> &'static [ApiCapability] {
&OWNER_CAPABILITIES
}
pub fn user_capabilities() -> &'static [ApiCapability] {
&USER_CAPABILITIES
}
pub fn readonly_capabilities() -> &'static [ApiCapability] {
&READONLY_CAPABILITIES
}
pub fn webdav_capabilities() -> &'static [ApiCapability] {
&WEBDAV_CAPABILITIES
}
pub fn is_system_slug(slug: &str) -> bool {
matches!(slug, "owner" | "user" | "readonly" | "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: matches!(slug.as_str(), "owner" | "user" | "readonly" | "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 == readonly_capabilities() {
return "readonly".to_string();
}
if capabilities == webdav_capabilities() {
return "webdav".to_string();
}
let joined = capabilities
.iter()
.map(|cap| cap.as_str())
.collect::<Vec<_>>()
.join(",");
let digest = Sha256::digest(joined.as_bytes());
let hex = hex::encode(digest);
format!("caps-{}", &hex[..12])
}
fn ensure_capability_membership<C>(
conn: &mut C,
set: &CapabilitySet,
desired: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let current = load_capabilities_for_set(conn, set.id)?;
if current == desired {
return Ok(());
}
let _ = refresh_capability_set(conn, set, desired)?;
Ok(())
}
fn persist_capabilities<C>(
conn: &mut C,
set_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let records: Vec<NewCapabilitySetCapability> = capabilities
.iter()
.map(|cap| NewCapabilitySetCapability {
capability_set_id: set_id,
capability: *cap,
})
.collect();
diesel::insert_into(capability_set_capabilities::table)
.values(&records)
.execute(conn)
.map_err(AppError::from)?;
Ok(())
}
+30 -4
View File
@@ -6,6 +6,24 @@ use uuid::Uuid;
use crate::config::AppConfig; 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,
+56 -1
View File
@@ -1,8 +1,12 @@
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;
use std::sync::{Arc, Mutex};
use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
@@ -10,16 +14,42 @@ 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(Clone)]
pub struct TenantConnectionHolder {
inner: Arc<Mutex<Option<PgPooledConnection>>>,
}
impl TenantConnectionHolder {
pub fn new(conn: PgPooledConnection) -> Self {
Self {
inner: Arc::new(Mutex::new(Some(conn))),
}
}
pub fn into_conn(self) -> Option<PgPooledConnection> {
self.inner.lock().ok()?.take()
}
}
#[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,13 +74,32 @@ 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());
parts
.extensions
.insert(TenantConnectionHolder::new(tenant_conn));
Ok(user) Ok(user)
} }
@@ -79,7 +128,13 @@ impl FromRequestParts<AppState> for TenantScopedConn {
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
let user = AuthenticatedUser::from_request_parts(parts, state).await?; let user = AuthenticatedUser::from_request_parts(parts, state).await?;
let tenant_id = user.tenant_id; let tenant_id = user.tenant_id;
let conn = state.db_for_tenant(tenant_id)?; let conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>() {
holder
.into_conn()
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
} else {
state.db_for_tenant(tenant_id)?
};
Ok(Self { Ok(Self {
conn, conn,
+18 -6
View File
@@ -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,
@@ -25,7 +26,7 @@ use papercrate::{
}, },
storage::{ObjectStorage, S3Storage, TenantStorage}, storage::{ObjectStorage, S3Storage, TenantStorage},
tenants::TenantService, tenants::TenantService,
utils::tracing::init_tracing, utils::{text::normalize_identifier, tracing::init_tracing},
}; };
#[derive(Parser)] #[derive(Parser)]
@@ -161,20 +162,26 @@ async fn main() -> Result<()> {
} }
fn create_user(pool: &PgPool, username: &str) -> Result<()> { fn create_user(pool: &PgPool, username: &str) -> Result<()> {
if username.trim().is_empty() { let username = normalize_identifier(
bail!("username must not be empty"); username,
} 100,
"username must not be empty",
"username must not exceed 100 characters",
Some("username may only contain printable characters"),
|ch| !ch.is_control(),
)
.map_err(|err| anyhow!("{:?}", err))?;
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
let exists: bool = let exists: bool =
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?; select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?;
if exists { if exists {
bail!("user '{}' already exists", username); bail!("user '{}' already exists", username);
} }
let new_user = NewUser { let new_user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username: username.to_string(), username: username.clone(),
}; };
diesel::insert_into(users::table) diesel::insert_into(users::table)
@@ -363,10 +370,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)
+13 -17
View File
@@ -10,7 +10,7 @@ use uuid::Uuid;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion}; use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions}; use crate::schema::{document_asset_objects, document_assets, document_versions};
use crate::state::AppState; use crate::state::{AppState, PgPooledConnection};
use crate::utils::time::to_iso; use crate::utils::time::to_iso;
#[derive(Serialize, Clone, ToSchema)] #[derive(Serialize, Clone, ToSchema)]
@@ -139,25 +139,26 @@ pub fn to_asset_object_response(
} }
} }
pub fn delete_asset(state: &AppState, tenant_id: Uuid, asset_id: Uuid) -> AppResult<()> { pub fn delete_asset(
let mut conn = state.db_for_tenant(tenant_id)?; conn: &mut PgPooledConnection,
tenant_id: Uuid,
asset_id: Uuid,
) -> AppResult<()> {
diesel::delete( diesel::delete(
document_assets::table document_assets::table
.filter(document_assets::id.eq(asset_id)) .filter(document_assets::id.eq(asset_id))
.filter(document_assets::tenant_id.eq(tenant_id)), .filter(document_assets::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(conn)?;
Ok(()) Ok(())
} }
pub async fn load_asset_responses( pub fn load_asset_responses_with_conn(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid, tenant_id: Uuid,
version_id: Uuid, version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> { ) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db_for_tenant(tenant_id)?;
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table
.left_outer_join( .left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id document_asset_objects::table.on(document_asset_objects::asset_id
@@ -171,8 +172,7 @@ pub async fn load_asset_responses(
document_assets::all_columns, document_assets::all_columns,
document_asset_objects::all_columns.nullable(), document_asset_objects::all_columns.nullable(),
)) ))
.load(&mut conn)?; .load(conn)?;
drop(conn);
Ok(assets Ok(assets
.into_iter() .into_iter()
@@ -181,8 +181,7 @@ pub async fn load_asset_responses(
} }
pub fn load_primary_assets( pub fn load_primary_assets(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid,
documents: &[Document], documents: &[Document],
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> { ) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() { if documents.is_empty() {
@@ -199,10 +198,9 @@ pub fn load_primary_assets(
version_ids.sort(); version_ids.sort();
version_ids.dedup(); version_ids.dedup();
let mut conn = state.db_for_tenant(tenant_id)?;
let versions: Vec<DocumentVersion> = document_versions::table let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids)) .filter(document_versions::id.eq_any(&version_ids))
.load(&mut conn)?; .load(conn)?;
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new(); let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions { for version in versions {
@@ -224,9 +222,7 @@ pub fn load_primary_assets(
document_assets::all_columns, document_assets::all_columns,
document_asset_objects::all_columns.nullable(), document_asset_objects::all_columns.nullable(),
)) ))
.load(&mut conn)?; .load(conn)?;
drop(conn);
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new(); let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, _object) in assets { for (asset, _object) in assets {
+4
View File
@@ -2,5 +2,9 @@ pub mod asset;
pub mod correspondents; pub mod correspondents;
pub mod folders; pub mod folders;
pub mod metadata; pub mod metadata;
pub mod ordering;
pub mod relations;
pub mod search; pub mod search;
pub mod tags; pub mod tags;
pub use ordering::{DocumentSortField, SortDirection};
+58
View File
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
const CREATED_AT_ASC: &str = "created_at ASC";
const CREATED_AT_DESC: &str = "created_at DESC";
const UPDATED_AT_ASC: &str = "updated_at ASC";
const UPDATED_AT_DESC: &str = "updated_at DESC";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DocumentSortField {
Title,
IssuedAt,
CreatedAt,
UpdatedAt,
}
impl Default for DocumentSortField {
fn default() -> Self {
DocumentSortField::Title
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
impl Default for SortDirection {
fn default() -> Self {
SortDirection::Asc
}
}
pub fn ordering_clauses(
field: DocumentSortField,
direction: SortDirection,
) -> (&'static str, Option<&'static str>) {
match (field, direction) {
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
}
}
+33
View File
@@ -0,0 +1,33 @@
use std::collections::HashMap;
use uuid::Uuid;
use crate::documents::correspondents::{
load_correspondents_for_documents, DocumentCorrespondentResponse,
};
use crate::documents::tags::load_tags_for_documents;
use crate::error::AppResult;
use crate::models::Tag;
use crate::state::PgPooledConnection;
/// Loads tags and correspondents for the provided documents in a single pass.
pub fn load_tags_and_correspondents(
conn: &mut PgPooledConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let tags_map = load_tags_for_documents(conn, document_ids)?;
let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?;
let mut result = HashMap::with_capacity(document_ids.len());
for id in document_ids {
let tags = tags_map.get(id).cloned().unwrap_or_default();
let correspondents = correspondents_map.remove(id).unwrap_or_default();
result.insert(*id, (tags, correspondents));
}
Ok(result)
}
+1
View File
@@ -0,0 +1 @@
pub mod responders;
+149
View File
@@ -0,0 +1,149 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use crate::error::{AppError, AppResult};
/// Helper trait to convert error-centric results into the application's error type.
pub trait IntoAppResult<T> {
fn into_app_result(self) -> AppResult<T>;
}
impl<T, E> IntoAppResult<T> for Result<T, E>
where
AppError: From<E>,
{
fn into_app_result(self) -> AppResult<T> {
self.map_err(AppError::from)
}
}
/// Extension helpers for optional values to map them into `AppResult`.
pub trait OptionAppResultExt<T> {
fn or_not_found(self) -> AppResult<T>;
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
}
impl<T> OptionAppResultExt<T> for Option<T> {
fn or_not_found(self) -> AppResult<T> {
self.ok_or_else(AppError::not_found)
}
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
self.ok_or_else(|| AppError::bad_request(message))
}
}
/// Provides helpers for statements returning number of affected rows.
pub trait RowsAffectedExt: Sized {
fn or_error(self, error: AppError) -> AppResult<usize>;
fn or_not_found(self) -> AppResult<usize> {
self.or_error(AppError::not_found())
}
}
impl RowsAffectedExt for usize {
fn or_error(self, error: AppError) -> AppResult<usize> {
if self == 0 {
Err(error)
} else {
Ok(self)
}
}
}
/// Wrapper providing a consistent JSON response with a status code.
pub struct JsonResponse<T> {
status: StatusCode,
payload: T,
}
impl<T> JsonResponse<T> {
pub fn new(status: StatusCode, payload: T) -> Self {
Self { status, payload }
}
pub fn ok(payload: T) -> Self {
Self::new(StatusCode::OK, payload)
}
pub fn created(payload: T) -> Self {
Self::new(StatusCode::CREATED, payload)
}
pub fn accepted(payload: T) -> Self {
Self::new(StatusCode::ACCEPTED, payload)
}
}
impl<T> From<T> for JsonResponse<T> {
fn from(value: T) -> Self {
Self::ok(value)
}
}
impl<T> IntoResponse for JsonResponse<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
(self.status, Json(self.payload)).into_response()
}
}
/// Helper for returning empty responses with a status code.
pub fn empty(status: StatusCode) -> AppResult<StatusCode> {
Ok(status)
}
/// Helper for returning `204 No Content`.
pub fn no_content() -> AppResult<StatusCode> {
empty(StatusCode::NO_CONTENT)
}
/// Helper for returning JSON payloads with `200 OK`.
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::ok(value))
}
/// Helper for returning JSON payloads with `201 Created`.
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::created(value))
}
/// Helper for returning JSON payloads with `202 Accepted`.
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::accepted(value))
}
/// Standard wrapper for paginated responses.
#[derive(Serialize)]
pub struct PaginatedResponse<T, M>
where
T: Serialize,
M: Serialize,
{
pub data: T,
pub meta: M,
}
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
where
T: Serialize,
M: Serialize,
{
let payload = PaginatedResponse { data, meta };
Ok(JsonResponse::ok(payload))
}
+1
View File
@@ -20,6 +20,7 @@ pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; pub const JOB_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 {
+2
View File
@@ -3,12 +3,14 @@ pub mod config;
pub mod db; pub mod db;
pub mod documents; pub mod documents;
pub mod error; pub mod error;
pub mod http;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod openapi; pub mod openapi;
pub mod routes; pub mod routes;
pub mod s3; pub mod s3;
pub mod schema; pub mod schema;
pub mod services;
pub mod state; pub mod state;
pub mod storage; pub mod storage;
pub mod tenants; pub mod tenants;
+180 -23
View File
@@ -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)]
+36 -21
View File
@@ -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,34 +73,44 @@ 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::{
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
};
pub use crate::routes::correspondents::{ pub use crate::routes::correspondents::{
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest, CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
UpdateCorrespondentRequest, UpdateCorrespondentRequest,
}; };
pub use crate::routes::documents::{ pub use crate::routes::documents::{
AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest, AssetObjectsQuery, AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest,
BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest, RestoreDocumentRequest, UploadDocumentForm,
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput,
DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery,
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, MoveDocumentRequest,
RestoreDocumentRequest, TagResponse, UpdateDocumentRequest, UploadDocumentForm,
};
pub use crate::routes::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse,
FolderInfo, FolderResponse, UpdateFolderRequest,
};
pub use crate::routes::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
UpdateApiTokenCapabilitiesRequest,
}; };
pub use crate::routes::folders::FolderContentsResponse;
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
pub use crate::services::auth::{
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
};
pub use crate::services::capability_sets::{
CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
};
pub use crate::services::correspondents::{
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
BulkCorrespondentsRequest, CorrespondentAssignmentInput,
};
pub use crate::services::documents::{
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest,
};
pub use crate::services::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo,
UpdateFolderRequest,
};
pub use crate::services::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
};
pub use crate::services::tags::{
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse,
};
} }
#[cfg(test)] #[cfg(test)]
+43 -730
View File
@@ -1,129 +1,33 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::Response,
Json, Json,
}; };
use axum_extra::{ use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie}, headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader, typed_header::TypedHeader,
}; };
use chrono::{Duration as ChronoDuration, Utc}; use utoipa::OpenApi;
use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use utoipa::{OpenApi, ToSchema};
use uuid::Uuid;
use crate::{ use crate::{
auth::{ auth::{
api_tokens::{find_active_token_by_secret, touch_api_token},
passkeys::{ passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
}, },
AuthenticatedUser, AuthenticatedUser, TenantScopedConn,
}, },
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{ http::responders::JsonResponse,
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, services::auth::{
User, UserSession, ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
}, SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
schema::{ TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions,
users::dsl,
}, },
state::AppState, state::AppState,
tenants::{
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
clear_user_session_hash,
},
}; };
use crate::schema::user_sessions::dsl as session_dsl;
use webauthn_rs::prelude::RegisterPublicKeyCredential;
const SESSION_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
#[serde(default)]
pub username: String,
#[serde(default)]
#[schema(nullable)]
pub password: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ApiTokenExchangeRequest {
pub api_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSnippet {
pub id: Uuid,
pub name: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize, ToSchema)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupStartRequest {
pub username: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupFinishRequest {
pub signup_token: String,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
Token(LoginResponse),
Selection(TenantSelectionResponse),
}
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
@@ -160,7 +64,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;
@@ -179,37 +83,7 @@ pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let magic_token = payload AuthService::new(&state).login(payload)
.magic_token
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
if magic_token.is_none() {
if payload.password.is_some() {
return Err(AppError::bad_request(
"password authentication is no longer supported",
));
}
return Err(AppError::bad_request(
"magic_token is required for passwordless login",
));
}
let token_value = magic_token.unwrap();
let mut conn = state.db_unscoped()?;
let username_hint = payload.username.trim();
let preferred_tenant_id = payload.preferred_tenant_id;
magic_token_login(
&state,
&mut conn,
token_value,
(!username_hint.is_empty()).then_some(username_hint),
preferred_tenant_id,
)
} }
#[utoipa::path( #[utoipa::path(
@@ -222,57 +96,8 @@ pub async fn login(
pub async fn api_token_exchange( pub async fn api_token_exchange(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ApiTokenExchangeRequest>, Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<Json<LoginResponse>> { ) -> AppResult<JsonResponse<LoginResponse>> {
let secret = payload.api_token.trim(); AuthService::new(&state).exchange_api_token(payload)
if secret.is_empty() {
return Err(AppError::bad_request("api_token must not be empty"));
}
let mut conn = state.db_unscoped()?;
let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)?
.ok_or_else(AppError::unauthorized)?;
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
apply_user_guc(&mut conn, user.id)?;
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
if membership.is_none() {
return Err(AppError::unauthorized());
}
apply_tenant_guc(&mut conn, token.tenant_id)?;
touch_api_token(&mut conn, token.id)?;
let access_token = state
.jwt
.generate_token(user.id, token.tenant_id, &user.username)
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(token.tenant_id)
.select(tenant_dsl::name)
.first(&mut conn)
.map_err(AppError::from)?;
let response = LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: token.tenant_id,
name: tenant_name,
},
};
Ok(Json(response))
} }
#[utoipa::path( #[utoipa::path(
@@ -289,38 +114,8 @@ pub async fn api_token_exchange(
pub async fn signup_start( pub async fn signup_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>, Json(payload): Json<SignupStartRequest>,
) -> AppResult<Json<SignupStartResponse>> { ) -> AppResult<JsonResponse<SignupStartResponse>> {
let username = payload.username.trim(); AuthService::new(&state).signup_start(payload)
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let challenge = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
.start_signup_registration(&mut conn, user_id, username)?;
let signup_token = state
.jwt
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
.map_err(AppError::from)?;
Ok(Json(SignupStartResponse {
signup_token,
challenge,
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -338,57 +133,7 @@ pub async fn signup_finish(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>, Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let claims = state AuthService::new(&state).signup_finish(payload)
.jwt
.verify_signup_token(&payload.signup_token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&claims.username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let prepared_passkey =
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
let state_clone = state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| {
insert_user(conn, claims.sub, &claims.username)?;
let tenant = state_clone.tenants.create_tenant_with_conn(
conn,
&claims.username,
None,
None,
TenantStatus::Creating,
&[claims.sub],
Some(claims.sub),
)?;
let passkey_insert =
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&passkey_insert)
.execute(conn)
.map_err(AppError::from)?;
let user: User = dsl::users.find(claims.sub).first(conn)?;
issue_session(&state_clone, conn, &user, tenant.id)
})?;
Ok(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -409,53 +154,7 @@ pub async fn refresh(
.get(SESSION_COOKIE_NAME) .get(SESSION_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
let hashed = hash_session_token(refresh_value); AuthService::new(&state).refresh(refresh_value)
let mut conn = state.db_unscoped()?;
let now = Utc::now();
let now_naive = now.naive_utc();
apply_user_session_hash(&mut conn, &hashed)?;
let token = match session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(&hashed))
.filter(session_dsl::revoked_at.is_null())
.filter(session_dsl::expires_at.gt(now_naive))
.first::<UserSession>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
clear_user_session_hash(&mut conn)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
clear_user_guc(&mut conn)?;
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
.set((
session_dsl::revoked_at.eq(now_naive),
session_dsl::updated_at.eq(now_naive),
))
.execute(&mut conn)?;
let user: User = dsl::users
.find(token.user_id)
.first(&mut conn)
.map_err(AppError::from)?;
issue_session(&state, &mut conn, &user, token.tenant_id)
}
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
let new_user = NewUser {
id,
username: username.to_string(),
};
diesel::insert_into(dsl::users)
.values(&new_user)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
} }
#[utoipa::path( #[utoipa::path(
@@ -470,37 +169,7 @@ pub async fn select_tenant(
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>, Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) { AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
Ok(claims) => claims.sub,
Err(_) => state
.jwt
.verify_token(bearer.token())
.map(|claims| claims.sub)
.map_err(|_| AppError::unauthorized())?,
};
let mut conn = state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(user_id)
.first(&mut conn)
.map_err(AppError::from)?;
issue_session(&state, &mut conn, &user, payload.tenant_id)
} }
#[utoipa::path( #[utoipa::path(
@@ -511,53 +180,21 @@ pub async fn select_tenant(
)] )]
pub async fn logout( pub async fn logout(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db_for_tenant(user.tenant_id)?; let refresh_cookie = jar.as_ref().and_then(|cookies| {
let now = Utc::now().naive_utc(); cookies
let mut rows_affected = 0; .get(SESSION_COOKIE_NAME)
.map(|value| value.to_owned())
if let Some(cookies) = jar { });
if let Some(value) = cookies.get(SESSION_COOKIE_NAME) { AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
let hashed = hash_session_token(value);
rows_affected = diesel::update(
session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(hashed))
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(&mut conn)
.unwrap_or(0);
}
}
if rows_affected == 0 {
let _ = diesel::update(
session_dsl::user_sessions
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(&mut conn);
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_session_cookie(&state));
Ok((headers, StatusCode::NO_CONTENT))
} }
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/auth/me", path = "/api/auth/me",
responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)), responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> { pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
@@ -567,147 +204,62 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/auth/tenants", path = "/api/auth/tenants",
responses((status = 200, description = "Available tenants", body = TenantListResponse)), responses((status = 200, description = "List of tenants", body = TenantListResponse)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn list_tenants( pub async fn list_tenants(
State(state): State<AppState>, State(state): State<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>, user: AuthenticatedUser,
) -> AppResult<Json<TenantListResponse>> { ) -> AppResult<JsonResponse<TenantListResponse>> {
let bearer = auth.ok_or_else(AppError::unauthorized)?; AuthService::new(&state).list_tenants(user)
let token = bearer.token();
let user_id = match state.jwt.verify_token(token) {
Ok(claims) => claims.sub,
Err(_) => {
let claims = state
.jwt
.verify_tenant_selector_token(token)
.map_err(|_| AppError::unauthorized())?;
claims.sub
}
};
let mut conn = state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.select(memberships_dsl::tenant_id)
.load(&mut conn)?;
clear_user_guc(&mut conn)?;
drop(conn);
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
Ok(Json(TenantListResponse { tenants }))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/register/start", path = "/api/auth/passkeys/register/start",
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn passkey_register_start( pub async fn passkey_register_start(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
) -> AppResult<Json<RegistrationChallengeResponse>> { ) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
let service = state AuthService::new(&state).passkey_register_start(user)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?;
Ok(Json(challenge))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/register/finish", path = "/api/auth/passkeys/register/finish",
request_body = PasskeyRegistrationFinishPayload, request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
responses((status = 200, description = "Passkey registered", body = PasskeySummary)), responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn passkey_register_finish( pub async fn passkey_register_finish(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>, Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<Json<PasskeySummary>> { ) -> AppResult<JsonResponse<PasskeySummary>> {
let service = state AuthService::new(&state).passkey_register_finish(user, payload)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let PasskeyRegistrationFinishPayload {
challenge_id,
credential,
nickname,
} = payload;
let passkey = service.finish_registration(
&mut conn,
&current_user,
challenge_id,
credential,
nickname,
)?;
Ok(Json(PasskeySummary::from(passkey)))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/login/start", path = "/api/auth/passkeys/login/start",
request_body = PasskeyLoginStartPayload, request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
tag = "Auth" tag = "Auth"
)] )]
pub async fn passkey_login_start( pub async fn passkey_login_start(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>, Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<Json<AuthenticationChallengeResponse>> { ) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
let service = state AuthService::new(&state).passkey_login_start(&payload.username)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let username = payload.username.trim();
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let user: User = dsl::users
.filter(dsl::username.eq(username))
.first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?;
Ok(Json(challenge))
} }
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/passkeys/login/finish", path = "/api/auth/passkeys/login/finish",
request_body = PasskeyLoginFinishPayload, request_body = crate::auth::passkeys::PasskeyLoginFinishPayload,
responses( responses(
(status = 200, description = "Passkey login successful", body = LoginResponseVariants), (status = 200, description = "Passkey login successful", body = LoginResponseVariants),
(status = 401, description = "Authentication failed") (status = 401, description = "Authentication failed")
@@ -718,244 +270,5 @@ pub async fn passkey_login_finish(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>, Json(payload): Json<PasskeyLoginFinishPayload>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let service = state AuthService::new(&state).passkey_login_finish(payload)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = state.db_unscoped()?;
let (user, _passkey, auth_result) =
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
if !auth_result.user_verified() {
return Err(AppError::unauthorized());
}
complete_login(&state, &mut conn, &user, None)
}
fn complete_login(
state: &AppState,
conn: &mut PgConnection,
user: &User,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
apply_user_guc(conn, user.id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.select(memberships_dsl::tenant_id)
.load(conn)?;
clear_user_guc(conn)?;
tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships");
if tenant_ids.is_empty() {
return Err(AppError::unauthorized());
}
if let Some(preferred_id) = preferred_tenant_id {
if tenant_ids.iter().any(|id| *id == preferred_id) {
return issue_session(state, conn, user, preferred_id);
}
}
if tenant_ids.len() == 1 {
return issue_session(state, conn, user, tenant_ids[0]);
}
let selection_token = state
.jwt
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(&mut tenant_conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
Ok(Json(TenantSelectionResponse {
access_token: selection_token,
tenants,
})
.into_response())
}
fn magic_token_login(
state: &AppState,
conn: &mut PgConnection,
token_value: &str,
username_hint: Option<&str>,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
if token_value.is_empty() {
return Err(AppError::bad_request("magic_token must not be empty"));
}
let token_hash = hash_magic_token(token_value);
let now = Utc::now();
let now_naive = now.naive_utc();
conn.transaction::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::unauthorized(),
_ => AppError::from(err),
})?;
if let Some(limit) = magic.max_uses {
if magic.used_count >= limit {
return Err(AppError::unauthorized());
}
}
match magic.kind {
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
}
let user: User = dsl::users
.find(magic.user_id)
.first(conn)
.map_err(AppError::from)?;
if let Some(expected) = username_hint {
if expected != user.username {
return Err(AppError::unauthorized());
}
}
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
.set((
magic_dsl::used_count.eq(magic.used_count + 1),
magic_dsl::last_used_at.eq(Some(now_naive)),
))
.execute(conn)?;
complete_login(state, conn, &user, preferred_tenant_id)
})
}
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
clear_user_session_hash(conn)?;
let now = Utc::now();
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(conn)
.map_err(AppError::from)?;
let session_value = generate_session_token();
let session_hash = hash_session_token(&session_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(user_sessions::table)
.values(&new_session)
.execute(conn)?;
let mut response = Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: tenant_id,
name: tenant_name,
},
})
.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_session_cookie(state, &session_value, refresh_expires_at),
);
Ok(response)
}
fn hash_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn hash_magic_token(token: &str) -> String {
hash_session_token(token)
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn build_session_cookie(
state: &AppState,
token: &str,
expires_at: chrono::DateTime<Utc>,
) -> HeaderValue {
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push(format!("Max-Age={}", max_age));
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
}
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push("Max-Age=0".into());
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
} }
+134
View File
@@ -0,0 +1,134 @@
use axum::{extract::Path, http::StatusCode, Json};
use utoipa::OpenApi;
use uuid::Uuid;
use crate::{
auth::TenantScopedConn,
error::AppResult,
http::responders::JsonResponse,
services::capability_sets::{
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
UpdateCapabilitySetRequest,
},
};
#[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<JsonResponse<Vec<CapabilitySetResponse>>> {
CapabilitySetService::new().list(&mut conn, tenant_id)
}
#[utoipa::path(
get,
path = "/api/capabilities",
responses((status = 200, body = [crate::models::ApiCapability])),
tag = "Capability Sets"
)]
pub async fn list_capabilities(
TenantScopedConn { .. }: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
CapabilitySetService::new().list_capabilities()
}
#[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<JsonResponse<CapabilitySetResponse>> {
CapabilitySetService::new().get(&mut conn, tenant_id, id)
}
#[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<JsonResponse<CapabilitySetResponse>> {
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
}
#[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<JsonResponse<CapabilitySetResponse>> {
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
}
#[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> {
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
}
#[derive(OpenApi)]
#[openapi(
paths(
crate::routes::capability_sets::list_capability_sets,
crate::routes::capability_sets::list_capabilities,
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::models::ApiCapability,
crate::services::capability_sets::CapabilitySetResponse,
crate::services::capability_sets::CreateCapabilitySetRequest,
crate::services::capability_sets::UpdateCapabilitySetRequest,
))
)]
pub struct CapabilitySetsApiDoc;
+18 -17
View File
@@ -11,10 +11,10 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
utils::{ utils::{
db::{no_content, EnsureEntity, IntoJsonResponse},
named_entity::{ensure_name_available, normalize_name}, named_entity::{ensure_name_available, normalize_name},
time::to_iso, time::to_iso,
}, },
@@ -71,7 +71,7 @@ pub async fn list_correspondents(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
@@ -94,7 +94,7 @@ pub async fn list_correspondents(
response.push(build_summary(correspondent, total)); response.push(build_summary(correspondent, total));
} }
response.into_json() ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -111,7 +111,7 @@ pub async fn create_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let name = normalize_name(&payload.name, || { let name = normalize_name(&payload.name, || {
AppError::bad_request("name must not be empty") AppError::bad_request("name must not be empty")
})?; })?;
@@ -140,9 +140,9 @@ pub async fn create_correspondent(
.find(new_id) .find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
build_summary(correspondent, 0).into_json() ok_json(build_summary(correspondent, 0))
} }
#[utoipa::path( #[utoipa::path(
@@ -161,12 +161,12 @@ pub async fn update_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let existing: Correspondent = correspondents::table let existing: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { if let Some(ref candidate) = payload.name {
@@ -199,7 +199,7 @@ pub async fn update_correspondent(
if new_name.is_none() && new_metadata.is_none() { if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return build_summary(existing.clone(), usage).into_json(); return ok_json(build_summary(existing.clone(), usage));
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -217,15 +217,17 @@ pub async fn update_correspondent(
.filter(correspondents::tenant_id.eq(tenant_id)), .filter(correspondents::tenant_id.eq(tenant_id)),
) )
.set((&changeset, correspondents::updated_at.eq(now))) .set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?; .execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json() ok_json(build_summary(updated, usage))
} }
#[utoipa::path( #[utoipa::path(
@@ -255,15 +257,14 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = diesel::delete( diesel::delete(
correspondents::table correspondents::table
.filter(correspondents::id.eq(correspondent_id)) .filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)), .filter(correspondents::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() no_content()
} }
File diff suppressed because it is too large Load Diff
+85 -471
View File
@@ -2,42 +2,28 @@ use axum::{
extract::{Json, Path, Query, State}, extract::{Json, Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use diesel::{dsl::exists, prelude::*, PgConnection}; use utoipa::OpenApi;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{ use crate::{
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{created_json, no_content, ok_json, JsonResponse},
services::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
},
state::AppState,
}; };
use super::documents::{hydrate_documents, DocumentResponse}; use crate::services::documents::DocumentResponse;
use crate::utils::{json::deserialize_patch_field, time::to_iso};
#[derive(Deserialize, ToSchema)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub struct FolderResponse { pub struct FolderResponse {
pub folder: FolderInfo, pub folder: FolderInfo,
} }
#[derive(Serialize, ToSchema)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct FolderContentsResponse { pub struct FolderContentsResponse {
#[schema(nullable)] #[schema(nullable)]
pub folder: Option<FolderInfo>, pub folder: Option<FolderInfo>,
@@ -45,63 +31,6 @@ pub struct FolderContentsResponse {
pub documents: Vec<DocumentResponse>, pub documents: Vec<DocumentResponse>,
} }
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")]
#[schema(default = true)]
pub include_documents: bool,
}
const fn default_include_documents() -> bool {
true
}
#[derive(Serialize, ToSchema)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Default, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn update_folder_request_deserializes_null_parent() {
let req: UpdateFolderRequest =
serde_json::from_value(json!({ "parent_id": null })).unwrap();
assert!(matches!(req.parent_id, Some(None)));
}
#[test]
fn update_folder_request_deserializes_absent_parent() {
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
assert!(req.parent_id.is_none());
}
#[test]
fn update_folder_request_deserializes_null_name() {
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
assert!(matches!(req.name, Some(None)));
}
}
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/folders/{id}", path = "/api/folders/{id}",
@@ -110,21 +39,17 @@ mod tests {
tag = "Folders" tag = "Folders"
)] )]
pub async fn get_folder( pub async fn get_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
let folder: Folder = folders::table let service = FolderService::new(&state);
.find(folder_id) let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
.filter(folders::tenant_id.eq(tenant_id)) ok_json(FolderResponse { folder })
.first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -135,89 +60,17 @@ pub async fn get_folder(
tag = "Folders" tag = "Folders"
)] )]
pub async fn ensure_folder_path( pub async fn ensure_folder_path(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.segments.is_empty() { let service = FolderService::new(&state);
return Err(AppError::bad_request("segments must not be empty")); let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
} ok_json(FolderResponse { folder })
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = raw_name.trim();
if name.is_empty() {
return Err(AppError::bad_request("folder names must not be empty"));
}
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(conn)
.optional()?;
if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?
} else if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)?
}
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
})?;
Ok(Json(FolderResponse {
folder: folder_to_info(target_folder),
}))
} }
#[utoipa::path( #[utoipa::path(
@@ -231,96 +84,28 @@ pub async fn ensure_folder_path(
tag = "Folders" tag = "Folders"
)] )]
pub async fn create_folder( pub async fn create_folder(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<(StatusCode, Json<FolderResponse>)> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.name.trim().is_empty() { let service = FolderService::new(&state);
return Err(AppError::bad_request("name must not be empty")); let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
} let response = FolderResponse { folder };
let name = payload.name.trim();
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)
.optional()?
};
let (folder, created): (Folder, bool) = if let Some(folder) = existing {
(folder, false)
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: payload.parent_id,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(&mut conn)
.optional()?;
if let Some(id) = inserted_id {
(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(&mut conn)?,
true,
)
} else if let Some(parent_id) = payload.parent_id {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(&mut conn)?,
false,
)
} else {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(&mut conn)?,
false,
)
}
};
let response = Json(FolderResponse {
folder: folder_to_info(folder),
});
if created { if created {
Ok((StatusCode::CREATED, response)) created_json(response)
} else { } else {
Ok((StatusCode::OK, response)) ok_json(response)
} }
} }
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/folders/{id}/contents", path = "/api/folders/{id}/contents",
params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery), params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
tag = "Folders" tag = "Folders"
)] )]
@@ -334,7 +119,13 @@ pub async fn list_folder_contents(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<JsonResponse<FolderContentsResponse>> {
let FolderContentsQuery {
include_documents,
sort,
dir,
} = query;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -344,57 +135,50 @@ pub async fn list_folder_contents(
) )
}; };
let folder = match folder_id { let service = FolderService::new(&state);
Some(id) => Some(folder_to_info( let FolderContentsData {
folders::table folder,
.find(id) subfolders,
.filter(folders::tenant_id.eq(tenant_id)) documents,
.first::<Folder>(&mut conn)?, } = service.list_folder_contents(
)), &mut conn,
None => None, tenant_id,
}; folder_id,
sort,
dir,
include_documents,
)?;
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id { let documents = if include_documents {
folders::table service.hydrate_documents(&mut conn, user_id, documents)?
.filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc())
.load(&mut conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc())
.load(&mut conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let documents = if query.include_documents {
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.order(documents::created_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load(&mut conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load(&mut conn)?
};
hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
} else { } else {
Vec::new() Vec::new()
}; };
Ok(Json(FolderContentsResponse { ok_json(FolderContentsResponse {
folder, folder,
subfolders, subfolders,
documents, documents,
})) })
}
#[utoipa::path(
get,
path = "/api/folders/tree",
responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])),
tag = "Folders"
)]
pub async fn list_folder_tree(
State(state): State<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
let service = FolderService::new(&state);
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
ok_json(tree)
} }
#[utoipa::path( #[utoipa::path(
@@ -405,6 +189,7 @@ pub async fn list_folder_contents(
tag = "Folders" tag = "Folders"
)] )]
pub async fn delete_folder( pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -412,50 +197,8 @@ pub async fn delete_folder(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
conn.transaction::<_, AppError, _>(|conn| { FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
folders::table no_content()
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists(
folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
if has_child_folders {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
let has_documents: bool = diesel::select(exists(
documents::table
.filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null()),
))
.get_result(conn)?;
if has_documents {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
diesel::delete(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path( #[utoipa::path(
@@ -467,6 +210,7 @@ pub async fn delete_folder(
tag = "Folders" tag = "Folders"
)] )]
pub async fn update_folder( pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -475,160 +219,30 @@ pub async fn update_folder(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
conn.transaction::<(), AppError, _>(|conn| { FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
let folder: Folder = folders::table no_content()
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
match payload.parent_id {
None => {}
Some(None) => {
if folder.parent_id.is_some() {
parent_changed = true;
}
next_parent = None;
}
Some(Some(parent_id)) => {
if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent"));
} }
let _parent: Folder = folders::table #[derive(OpenApi)]
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
if folder.parent_id != Some(parent_id) {
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
));
}
parent_changed = true;
}
next_parent = Some(parent_id);
}
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
match payload.name {
None => {}
Some(None) => {
return Err(AppError::bad_request("name cannot be null"));
}
Some(Some(value)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
}
}
}
if !parent_changed && !name_changed {
return Ok(());
}
let conflict = if let Some(parent_id) = next_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
};
if conflict.is_some() {
return Err(AppError::bad_request(
"a folder with the same name already exists in the target",
));
}
diesel::update(
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
}
fn folder_to_info(folder: Folder) -> FolderInfo {
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
#[derive(utoipa::OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::routes::folders::create_folder, crate::routes::folders::create_folder,
crate::routes::folders::ensure_folder_path, crate::routes::folders::ensure_folder_path,
crate::routes::folders::get_folder, crate::routes::folders::get_folder,
crate::routes::folders::list_folder_contents, crate::routes::folders::list_folder_contents,
crate::routes::folders::list_folder_tree,
crate::routes::folders::delete_folder, crate::routes::folders::delete_folder,
crate::routes::folders::update_folder crate::routes::folders::update_folder
), ),
components(schemas( components(schemas(
crate::routes::folders::CreateFolderRequest, crate::services::folders::CreateFolderRequest,
crate::routes::folders::EnsureFolderPathRequest, crate::services::folders::EnsureFolderPathRequest,
crate::routes::folders::FolderResponse, crate::routes::folders::FolderResponse,
crate::routes::folders::FolderInfo, crate::services::folders::FolderInfo,
crate::routes::folders::FolderContentsQuery, crate::services::folders::FolderContentsQuery,
crate::routes::folders::FolderContentsResponse, crate::routes::folders::FolderContentsResponse,
crate::routes::folders::UpdateFolderRequest crate::services::folders::FolderTreeNode,
crate::services::folders::UpdateFolderRequest
)) ))
)] )]
pub struct FoldersApiDoc; pub struct FoldersApiDoc;
+248 -36
View File
@@ -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,297 @@ 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(
"/tree",
get(folders::list_folder_tree)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
)
.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), delete(profile::delete_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
) )
.route("/passkeys", get(profile::list_passkeys)) .route(
.route("/passkeys/:id", delete(profile::delete_passkey)); "/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 capabilities_routes = Router::new().route(
"/",
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
);
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 +379,8 @@ 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/capabilities", capabilities_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));
+29 -188
View File
@@ -3,69 +3,19 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use chrono::{DateTime, NaiveDateTime}; use utoipa::OpenApi;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::{ use crate::{
api_tokens::{ auth::{passkeys::PasskeySummary, TenantScopedConn},
create_api_token as issue_token, list_api_tokens as load_tokens, error::AppResult,
regenerate_api_token as rotate_token, revoke_api_token as revoke_token, http::responders::JsonResponse,
update_api_token_capabilities as update_capabilities, services::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
RevokePasskeyQuery,
}, },
passkeys::PasskeySummary, state::AppState,
TenantScopedConn,
}; };
use crate::error::{AppError, AppResult};
use crate::models::{ApiToken, ApiTokenCapability};
use crate::state::AppState;
use crate::utils::{db::no_content, time::to_iso};
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub capabilities: Vec<ApiTokenCapability>,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenCreatedResponse {
pub token: String,
pub token_info: ApiTokenResponse,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateApiTokenRequest {
#[schema(nullable)]
pub label: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub capabilities: Option<Vec<ApiTokenCapability>>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateApiTokenCapabilitiesRequest {
pub capabilities: Vec<ApiTokenCapability>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RevokePasskeyQuery {
#[serde(default)]
#[schema(nullable)]
pub reason: Option<String>,
}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -78,14 +28,8 @@ pub async fn list_passkeys(
TenantScopedConn { TenantScopedConn {
mut conn, user_id, .. mut conn, user_id, ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<PasskeySummary>>> { ) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
let service = state ProfileService::new(&state).list_passkeys(&mut conn, user_id)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(&mut conn, user_id)?;
Ok(Json(passkeys))
} }
#[utoipa::path( #[utoipa::path(
@@ -95,16 +39,15 @@ pub async fn list_passkeys(
tag = "Profile" tag = "Profile"
)] )]
pub async fn list_api_tokens( pub async fn list_api_tokens(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> { ) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
let responses = tokens.into_iter().map(api_token_to_response).collect();
Ok(Json(responses))
} }
#[utoipa::path( #[utoipa::path(
@@ -115,6 +58,7 @@ pub async fn list_api_tokens(
tag = "Profile" tag = "Profile"
)] )]
pub async fn create_api_token( pub async fn create_api_token(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
@@ -122,31 +66,8 @@ pub async fn create_api_token(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateApiTokenRequest>, Json(payload): Json<CreateApiTokenRequest>,
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> { ) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let expires_at = match payload.expires_at { ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
Some(ref value) => Some(parse_timestamp(value)?),
None => None,
};
let capabilities = payload
.capabilities
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
let issued = issue_token(
&mut conn,
user_id,
tenant_id,
payload.label.clone(),
expires_at,
capabilities,
)?;
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info: api_token_to_response(issued.record),
};
Ok((StatusCode::CREATED, Json(response)))
} }
#[utoipa::path( #[utoipa::path(
@@ -157,6 +78,7 @@ pub async fn create_api_token(
tag = "Profile" tag = "Profile"
)] )]
pub async fn regenerate_api_token( pub async fn regenerate_api_token(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
@@ -164,43 +86,8 @@ pub async fn regenerate_api_token(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Path(token_id): Path<Uuid>, Path(token_id): Path<Uuid>,
) -> AppResult<Json<ApiTokenCreatedResponse>> { ) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info: api_token_to_response(issued.record),
};
Ok(Json(response))
}
#[utoipa::path(
patch,
path = "/api/profile/api-tokens/{id}",
params(("id" = Uuid, Path, description = "API token ID")),
request_body = UpdateApiTokenCapabilitiesRequest,
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
tag = "Profile"
)]
pub async fn update_api_token(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
) -> AppResult<Json<ApiTokenResponse>> {
let updated = update_capabilities(
&mut conn,
token_id,
user_id,
Some(tenant_id),
payload.capabilities,
)?;
Ok(Json(api_token_to_response(updated)))
} }
#[utoipa::path( #[utoipa::path(
@@ -211,13 +98,13 @@ pub async fn update_api_token(
tag = "Profile" tag = "Profile"
)] )]
pub async fn delete_api_token( pub async fn delete_api_token(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, user_id, .. mut conn, user_id, ..
}: TenantScopedConn, }: TenantScopedConn,
Path(token_id): Path<Uuid>, Path(token_id): Path<Uuid>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
revoke_token(&mut conn, token_id, user_id)?; ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
no_content()
} }
#[utoipa::path( #[utoipa::path(
@@ -238,71 +125,25 @@ pub async fn delete_passkey(
Path(passkey_id): Path<Uuid>, Path(passkey_id): Path<Uuid>,
Query(query): Query<RevokePasskeyQuery>, Query(query): Query<RevokePasskeyQuery>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
let service = state ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let active_count = service.active_passkey_count(&mut conn, user_id)?;
if active_count <= 1 {
return Err(AppError::bad_request(
"cannot revoke the last remaining passkey",
));
} }
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?; #[derive(OpenApi)]
no_content()
}
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
let ApiToken {
id,
tenant_id,
label,
created_at,
last_used_at,
expires_at,
revoked_at,
capabilities,
..
} = token;
ApiTokenResponse {
id,
tenant_id,
label,
capabilities,
created_at: to_iso(created_at),
last_used_at: last_used_at.map(to_iso),
expires_at: expires_at.map(to_iso),
revoked_at: revoked_at.map(to_iso),
}
}
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
#[derive(utoipa::OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::routes::profile::list_api_tokens, crate::routes::profile::list_api_tokens,
crate::routes::profile::create_api_token, crate::routes::profile::create_api_token,
crate::routes::profile::regenerate_api_token, crate::routes::profile::regenerate_api_token,
crate::routes::profile::update_api_token,
crate::routes::profile::delete_api_token, crate::routes::profile::delete_api_token,
crate::routes::profile::list_passkeys, crate::routes::profile::list_passkeys,
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::services::profile::ApiTokenResponse,
crate::routes::profile::ApiTokenCreatedResponse, crate::services::profile::ApiTokenCreatedResponse,
crate::routes::profile::CreateApiTokenRequest, crate::services::profile::CreateApiTokenRequest,
crate::routes::profile::UpdateApiTokenCapabilitiesRequest, crate::services::profile::RevokePasskeyQuery,
crate::routes::profile::RevokePasskeyQuery,
crate::auth::passkeys::PasskeySummary crate::auth::passkeys::PasskeySummary
)) ))
)] )]
+30 -30
View File
@@ -5,14 +5,16 @@ use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn; use crate::{
use crate::error::{AppError, AppResult}; auth::TenantScopedConn,
use crate::models::{NewTag, Tag}; error::{AppError, AppResult},
use crate::schema::{document_tags, tags}; http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
use crate::utils::{ models::{NewTag, Tag},
db::{no_content, EnsureEntity, IntoJsonResponse}, schema::{document_tags, tags},
utils::{
json::deserialize_patch_field, json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name}, named_entity::{ensure_name_available, normalize_name},
},
}; };
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
@@ -84,7 +86,7 @@ pub async fn list_tags(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> { ) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table let tag_list: Vec<Tag> = tags::table
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.order(tags::label.asc()) .order(tags::label.asc())
@@ -108,7 +110,7 @@ pub async fn list_tags(
}) })
.collect(); .collect();
response.into_json() ok_json(response)
} }
#[utoipa::path( #[utoipa::path(
@@ -125,7 +127,7 @@ pub async fn create_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let label = normalize_name(&payload.label, || { let label = normalize_name(&payload.label, || {
AppError::bad_request("label must not be empty") AppError::bad_request("label must not be empty")
})?; })?;
@@ -155,15 +157,14 @@ pub async fn create_tag(
.find(new_tag.id) .find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color, color: tag.color,
usage_count: 0, usage_count: 0,
} })
.into_json()
} }
#[utoipa::path( #[utoipa::path(
@@ -182,12 +183,12 @@ pub async fn update_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateTagRequest>, Json(payload): Json<UpdateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let existing: Tag = tags::table let existing: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let UpdateTagRequest { label, color } = payload; let UpdateTagRequest { label, color } = payload;
if label.is_none() && color.is_none() { if label.is_none() && color.is_none() {
@@ -195,13 +196,12 @@ pub async fn update_tag(
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
} });
.into_json();
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
@@ -258,12 +258,12 @@ pub async fn update_tag(
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
})); });
} }
let changeset = UpdateTagChangeset { let changeset = UpdateTagChangeset {
@@ -279,26 +279,27 @@ pub async fn update_tag(
.filter(tags::tenant_id.eq(tenant_id)), .filter(tags::tenant_id.eq(tenant_id)),
) )
.set(&changeset) .set(&changeset)
.execute(&mut conn)?; .execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Tag = tags::table let updated: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
color: updated.color, color: updated.color,
usage_count, usage_count,
} })
.into_json()
} }
#[utoipa::path( #[utoipa::path(
@@ -328,15 +329,14 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete( diesel::delete(
tags::table tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)), .filter(tags::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() no_content()
} }
+23 -24
View File
@@ -18,23 +18,23 @@ 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,
}; };
use crate::state::AppState; use crate::state::{AppState, PgPooledConnection};
use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc}; use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc};
use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV"; const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
struct WebDavContext { struct WebDavContext {
tenant_id: Uuid, tenant_id: Uuid,
_user_id: Uuid, _user_id: Uuid,
_username: String, _username: String,
conn: PgPooledConnection,
} }
pub fn create_router() -> Router<AppState> { pub fn create_router() -> Router<AppState> {
@@ -72,7 +72,7 @@ async fn handle_propfind(
path: &str, path: &str,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let mut context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -87,17 +87,18 @@ async fn handle_propfind(
let tenant_id = context.tenant_id; let tenant_id = context.tenant_id;
let resources = if segments.is_empty() { let resources = if segments.is_empty() {
let contents = fetch_folder_contents(state, tenant_id, None)?; let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?;
build_resources_for_folder(None, &[], &contents, depth) build_resources_for_folder(None, &[], &contents, depth)
} else { } else {
let resolution = match resolve_path(state, tenant_id, &segments)? { let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
match resolution { match resolution {
ResolvedPath::Folder { folder, chain } => { ResolvedPath::Folder { folder, chain } => {
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?; let contents =
fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth) build_resources_for_folder(Some(&folder), &chain, &contents, depth)
} }
ResolvedPath::Document { ResolvedPath::Document {
@@ -128,7 +129,7 @@ async fn handle_get_or_head(
headers: HeaderMap, headers: HeaderMap,
method: Method, method: Method,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let mut context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -139,7 +140,7 @@ async fn handle_get_or_head(
return Ok(method_not_allowed()); return Ok(method_not_allowed());
} }
let resolution = match resolve_path(state, tenant_id, &segments)? { let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
@@ -233,18 +234,16 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
} }
fn fetch_folder_contents( fn fetch_folder_contents(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid, tenant_id: Uuid,
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> { ) -> AppResult<WebDavFolderContents> {
let mut conn = state.db_for_tenant(tenant_id)?;
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some( Some(id) => Some(
folders_dsl::folders folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id)) .filter(folders_dsl::tenant_id.eq(tenant_id))
.find(id) .find(id)
.first::<Folder>(&mut conn)?, .first::<Folder>(conn)?,
), ),
None => None, None => None,
}; };
@@ -254,12 +253,12 @@ fn fetch_folder_contents(
.filter(folders_dsl::tenant_id.eq(tenant_id)) .filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.eq(Some(id))) .filter(folders_dsl::parent_id.eq(Some(id)))
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(conn)?,
None => folders_dsl::folders None => folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id)) .filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.is_null()) .filter(folders_dsl::parent_id.is_null())
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(conn)?,
}; };
let mut docs_query = documents_dsl::documents let mut docs_query = documents_dsl::documents
@@ -274,7 +273,7 @@ fn fetch_folder_contents(
let documents: Vec<Document> = docs_query let documents: Vec<Document> = docs_query
.order(documents_dsl::created_at.desc()) .order(documents_dsl::created_at.desc())
.load(&mut conn)?; .load(conn)?;
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect(); let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
let versions: Vec<DocumentVersion> = if version_ids.is_empty() { let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
@@ -282,7 +281,7 @@ fn fetch_folder_contents(
} else { } else {
document_versions_dsl::document_versions document_versions_dsl::document_versions
.filter(document_versions_dsl::id.eq_any(&version_ids)) .filter(document_versions_dsl::id.eq_any(&version_ids))
.load(&mut conn)? .load(conn)?
}; };
let mut version_map = versions let mut version_map = versions
@@ -438,7 +437,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
&mut conn, &mut conn,
None, None,
secret, secret,
ApiTokenCapability::Webdav, Some(ApiCapability::WebdavRead),
)? { )? {
Some(token) => token, Some(token) => token,
None => { None => {
@@ -498,6 +497,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
tenant_id, tenant_id,
_user_id: user.id, _user_id: user.id,
_username: user.username, _username: user.username,
conn,
})) }))
} }
@@ -689,11 +689,10 @@ enum ResolvedPath {
} }
fn resolve_path( fn resolve_path(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid, tenant_id: Uuid,
segments: &[String], segments: &[String],
) -> AppResult<Option<ResolvedPath>> { ) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db_for_tenant(tenant_id)?;
let mut parent_id: Option<Uuid> = None; let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new(); let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
@@ -701,7 +700,7 @@ fn resolve_path(
for (index, segment) in segments.iter().enumerate() { for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1; let is_last = index == segments.len() - 1;
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? { if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? {
chain.push(folder.name.clone()); chain.push(folder.name.clone());
if is_last { if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
@@ -713,7 +712,7 @@ fn resolve_path(
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)? find_document_by_filename(conn, tenant_id, parent_id, segment)?
{ {
chain.push(document.filename.clone()); chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document { return Ok(Some(ResolvedPath::Document {
@@ -725,7 +724,7 @@ fn resolve_path(
} }
if let Ok(uuid) = Uuid::parse_str(segment) { if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? { if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? {
if folder.parent_id != parent_id { if folder.parent_id != parent_id {
return Ok(None); return Ok(None);
} }
@@ -738,7 +737,7 @@ fn resolve_path(
continue; continue;
} }
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? { if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? {
if document.folder_id != parent_id { if document.folder_id != parent_id {
return Ok(None); return Ok(None);
} }
+32 -4
View File
@@ -10,8 +10,8 @@ pub mod sql_types {
pub struct TenantStatus; pub struct TenantStatus;
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "api_token_capability"))] #[diesel(postgres_type(name = "api_capability"))]
pub struct ApiTokenCapability; pub struct ApiCapability;
} }
diesel::table! { diesel::table! {
@@ -204,6 +204,7 @@ diesel::table! {
tenant_id -> Uuid, tenant_id -> Uuid,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
capability_set_id -> Nullable<Uuid>,
} }
} }
@@ -251,8 +252,29 @@ diesel::table! {
diesel::table! { diesel::table! {
use diesel::sql_types::*; use diesel::sql_types::*;
use super::sql_types::ApiTokenCapability;
capability_sets (id) {
id -> Uuid,
tenant_id -> Uuid,
slug -> Text,
cap_version -> Int4,
is_system -> Bool,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::ApiCapability;
capability_set_capabilities (capability_set_id, capability) {
capability_set_id -> Uuid,
capability -> ApiCapability,
}
}
diesel::table! {
api_tokens (id) { api_tokens (id) {
id -> Uuid, id -> Uuid,
user_id -> Uuid, user_id -> Uuid,
@@ -264,11 +286,13 @@ diesel::table! {
last_used_at -> Nullable<Timestamptz>, last_used_at -> Nullable<Timestamptz>,
expires_at -> Nullable<Timestamptz>, expires_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>, revoked_at -> Nullable<Timestamptz>,
capabilities -> Array<ApiTokenCapability>, capability_set_id -> Uuid,
} }
} }
diesel::joinable!(correspondents -> tenants (tenant_id)); diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id));
diesel::joinable!(capability_sets -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id)); diesel::joinable!(document_asset_objects -> tenants (tenant_id));
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
@@ -289,15 +313,19 @@ diesel::joinable!(jobs -> tenants (tenant_id));
diesel::joinable!(user_sessions -> tenants (tenant_id)); diesel::joinable!(user_sessions -> tenants (tenant_id));
diesel::joinable!(user_sessions -> users (user_id)); diesel::joinable!(user_sessions -> users (user_id));
diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_id)); diesel::joinable!(user_memberships -> users (user_id));
diesel::joinable!(user_passkeys -> users (user_id)); diesel::joinable!(user_passkeys -> users (user_id));
diesel::joinable!(webauthn_challenges -> users (user_id)); diesel::joinable!(webauthn_challenges -> users (user_id));
diesel::joinable!(api_tokens -> tenants (tenant_id)); diesel::joinable!(api_tokens -> tenants (tenant_id));
diesel::joinable!(api_tokens -> capability_sets (capability_set_id));
diesel::joinable!(api_tokens -> users (user_id)); diesel::joinable!(api_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents, correspondents,
capability_set_capabilities,
capability_sets,
document_asset_objects, document_asset_objects,
document_assets, document_assets,
document_correspondents, document_correspondents,
+835
View File
@@ -0,0 +1,835 @@
use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use utoipa::ToSchema;
use uuid::Uuid;
use webauthn_rs::prelude::RegisterPublicKeyCredential;
use crate::auth::{
api_tokens::{find_active_token_by_secret, touch_api_token},
capability_sets::load_capability_set,
jwt::{AccessTokenContext, PrincipalKind},
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
AuthenticatedUser,
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{ok_json, JsonResponse};
use crate::models::{
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
UserSession,
};
use crate::schema::{
magic_tokens::dsl as magic_dsl,
tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl,
user_passkeys::dsl as passkey_dsl,
user_sessions::{self, dsl as session_dsl},
users::dsl,
};
use crate::state::{AppState, PgPooledConnection};
use crate::tenants::{
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
clear_user_session_hash,
};
use crate::utils::text::normalize_identifier;
pub const SESSION_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
#[serde(default)]
pub username: String,
#[serde(default)]
#[schema(nullable)]
pub password: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ApiTokenExchangeRequest {
pub api_token: String,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub tenant: TenantSnippet,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantSnippet {
pub id: Uuid,
pub name: String,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantSelectionResponse {
pub access_token: String,
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Deserialize, ToSchema)]
pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupStartRequest {
pub username: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize, ToSchema)]
pub struct SignupFinishRequest {
pub signup_token: String,
#[schema(value_type = Object)]
pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
Token(LoginResponse),
Selection(TenantSelectionResponse),
}
pub struct AuthService<'a> {
state: &'a AppState,
}
impl<'a> AuthService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn login(&self, payload: LoginRequest) -> AppResult<Response> {
let magic_token = payload
.magic_token
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
if magic_token.is_none() {
if payload.password.is_some() {
return Err(AppError::bad_request(
"password authentication is no longer supported",
));
}
return Err(AppError::bad_request(
"magic_token is required for passwordless login",
));
}
let token_value = magic_token.unwrap();
let mut conn = self.state.db_unscoped()?;
let username_hint = payload.username.trim();
let preferred_tenant_id = payload.preferred_tenant_id;
self.magic_token_login(
&mut conn,
token_value,
(!username_hint.is_empty()).then_some(username_hint),
preferred_tenant_id,
)
}
pub fn exchange_api_token(
&self,
payload: ApiTokenExchangeRequest,
) -> AppResult<JsonResponse<LoginResponse>> {
let secret = payload.api_token.trim();
if secret.is_empty() {
return Err(AppError::bad_request("api_token must not be empty"));
}
let mut conn = self.state.db_unscoped()?;
let token = find_active_token_by_secret(&mut conn, None, secret, None)?
.ok_or_else(AppError::unauthorized)?;
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
apply_user_guc(&mut conn, user.id)?;
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.first::<UserMembership>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
let membership = membership.ok_or_else(AppError::unauthorized)?;
let membership_capability_set = membership.capability_set_id.ok_or_else(|| {
AppError::new(
StatusCode::FORBIDDEN,
"membership has no capability set assigned",
)
})?;
let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?;
let _membership_set = load_capability_set(&mut conn, membership_capability_set)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
touch_api_token(&mut conn, token.id)?;
let access_token = self
.state
.jwt
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id: token.tenant_id,
username: user.username.clone(),
principal_kind: PrincipalKind::ApiToken,
principal_id: token.id,
capability_set_id: token_capability_set.id,
cap_version: token_capability_set.cap_version,
})
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(token.tenant_id)
.select(tenant_dsl::name)
.first(&mut conn)
.map_err(AppError::from)?;
ok_json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: self.state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: token.tenant_id,
name: tenant_name,
},
})
}
pub fn signup_start(
&self,
payload: SignupStartRequest,
) -> AppResult<JsonResponse<SignupStartResponse>> {
let username = normalize_username(&payload.username)?;
let mut conn = self.state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let challenge = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
.start_signup_registration(&mut conn, user_id, username.as_str())?;
let signup_token = self
.state
.jwt
.generate_signup_token(user_id, challenge.challenge_id, username.clone())
.map_err(AppError::from)?;
ok_json(SignupStartResponse {
signup_token,
challenge,
})
}
pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult<Response> {
let claims = self
.state
.jwt
.verify_signup_token(&payload.signup_token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = self.state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&claims.username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let prepared_passkey = service.consume_signup_challenge(
&mut conn,
claims.challenge_id,
&payload.credential,
)?;
let state_clone = self.state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| {
insert_user(conn, claims.sub, &claims.username)?;
let tenant = state_clone.tenants.create_tenant_with_conn(
conn,
&claims.username,
None,
None,
TenantStatus::Creating,
&[claims.sub],
Some(claims.sub),
)?;
let passkey_insert =
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&passkey_insert)
.execute(conn)
.map_err(AppError::from)?;
let user: User = dsl::users.find(claims.sub).first(conn)?;
self.issue_session(conn, &user, tenant.id)
})?;
Ok(response)
}
pub fn refresh(&self, refresh_value: &str) -> AppResult<Response> {
let hashed = hash_session_token(refresh_value);
let mut conn = self.state.db_unscoped()?;
let now = Utc::now();
let now_naive = now.naive_utc();
apply_user_session_hash(&mut conn, &hashed)?;
let token = match session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(&hashed))
.filter(session_dsl::revoked_at.is_null())
.filter(session_dsl::expires_at.gt(now_naive))
.first::<UserSession>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
clear_user_session_hash(&mut conn)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
clear_user_guc(&mut conn)?;
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
.set((
session_dsl::revoked_at.eq(now_naive),
session_dsl::updated_at.eq(now_naive),
))
.execute(&mut conn)?;
let user: User = dsl::users
.find(token.user_id)
.first(&mut conn)
.map_err(AppError::from)?;
self.issue_session(&mut conn, &user, token.tenant_id)
}
pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult<Response> {
let user_id = match self.state.jwt.verify_tenant_selector_token(token) {
Ok(claims) => claims.sub,
Err(_) => self
.state
.jwt
.verify_token(token)
.map(|claims| claims.sub)
.map_err(|_| AppError::unauthorized())?,
};
let mut conn = self.state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(user_id)
.first(&mut conn)
.map_err(AppError::from)?;
self.issue_session(&mut conn, &user, tenant_id)
}
pub fn logout(
&self,
conn: &mut PgPooledConnection,
user: &AuthenticatedUser,
refresh_cookie: Option<&str>,
) -> AppResult<(HeaderMap, StatusCode)> {
let now = Utc::now().naive_utc();
let revoked = if let Some(value) = refresh_cookie {
let hashed = hash_session_token(value);
diesel::update(
session_dsl::user_sessions
.filter(session_dsl::token_hash.eq(hashed))
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(conn)?
} else {
0
};
if revoked == 0 {
diesel::update(
session_dsl::user_sessions
.filter(session_dsl::user_id.eq(user.user_id))
.filter(session_dsl::tenant_id.eq(user.tenant_id))
.filter(session_dsl::revoked_at.is_null()),
)
.set((
session_dsl::revoked_at.eq(now),
session_dsl::updated_at.eq(now),
))
.execute(conn)?;
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_session_cookie(self.state));
Ok((headers, StatusCode::NO_CONTENT))
}
pub fn list_tenants(
&self,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<TenantListResponse>> {
let mut conn = self.state.db_unscoped()?;
apply_user_guc(&mut conn, user.user_id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.user_id))
.select(memberships_dsl::tenant_id)
.load(&mut conn)?;
clear_user_guc(&mut conn)?;
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(&mut conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
ok_json(TenantListResponse { tenants })
}
pub fn passkey_register_start(
&self,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let challenge = service.start_registration(&mut conn, &current_user)?;
ok_json(challenge)
}
pub fn passkey_register_finish(
&self,
user: AuthenticatedUser,
payload: PasskeyRegistrationFinishPayload,
) -> AppResult<JsonResponse<PasskeySummary>> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
let PasskeyRegistrationFinishPayload {
challenge_id,
credential,
nickname,
} = payload;
let passkey = service.finish_registration(
&mut conn,
&current_user,
challenge_id,
credential,
nickname,
)?;
ok_json(PasskeySummary::from(passkey))
}
pub fn passkey_login_start(
&self,
username: &str,
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
let username = normalize_username(username)?;
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let user: User = dsl::users
.filter(dsl::username.eq(&username))
.first(&mut conn)?;
let challenge = service.start_authentication(&mut conn, &user)?;
ok_json(challenge)
}
pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult<Response> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let mut conn = self.state.db_unscoped()?;
let (user, _passkey, auth_result) =
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
if !auth_result.user_verified() {
return Err(AppError::unauthorized());
}
self.complete_login(&mut conn, &user, None)
}
fn complete_login(
&self,
conn: &mut PgConnection,
user: &User,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
apply_user_guc(conn, user.id)?;
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.select(memberships_dsl::tenant_id)
.load(conn)?;
clear_user_guc(conn)?;
if tenant_ids.is_empty() {
return Err(AppError::unauthorized());
}
if let Some(preferred_id) = preferred_tenant_id {
if tenant_ids.iter().any(|id| *id == preferred_id) {
return self.issue_session(conn, user, preferred_id);
}
}
if tenant_ids.len() == 1 {
return self.issue_session(conn, user, tenant_ids[0]);
}
let selection_token = self
.state
.jwt
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let mut tenants = Vec::with_capacity(tenant_ids.len());
for tenant_id in tenant_ids {
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
let name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(conn)
.map_err(AppError::from)?;
tenants.push(TenantSnippet {
id: tenant_id,
name,
});
}
let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse {
access_token: selection_token,
tenants,
}))?;
Ok(response.into_response())
}
fn magic_token_login(
&self,
conn: &mut PgConnection,
token_value: &str,
username_hint: Option<&str>,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
if token_value.is_empty() {
return Err(AppError::bad_request("magic_token must not be empty"));
}
let token_hash = hash_magic_token(token_value);
let now = Utc::now();
let now_naive = now.naive_utc();
conn.transaction::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::unauthorized(),
_ => AppError::from(err),
})?;
if let Some(limit) = magic.max_uses {
if magic.used_count >= limit {
return Err(AppError::unauthorized());
}
}
match magic.kind {
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
}
let user: User = dsl::users
.find(magic.user_id)
.first(conn)
.map_err(AppError::from)?;
if let Some(expected) = username_hint {
if expected != user.username {
return Err(AppError::unauthorized());
}
}
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
.set((
magic_dsl::used_count.eq(magic.used_count + 1),
magic_dsl::last_used_at.eq(Some(now_naive)),
))
.execute(conn)?;
self.complete_login(conn, &user, preferred_tenant_id)
})
}
fn issue_session(
&self,
conn: &mut PgConnection,
user: &User,
tenant_id: Uuid,
) -> AppResult<Response> {
apply_tenant_guc(conn, tenant_id)?;
clear_user_guc(conn)?;
clear_user_session_hash(conn)?;
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.first(conn)?;
let capability_set_id = membership.capability_set_id.ok_or_else(|| {
AppError::new(
StatusCode::FORBIDDEN,
"membership has no capability set assigned",
)
})?;
let capability_set = load_capability_set(conn, capability_set_id)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = self
.state
.jwt
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id,
username: user.username.clone(),
principal_kind: PrincipalKind::UserSession,
principal_id: session_id,
capability_set_id,
cap_version: capability_set.cap_version,
})
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
.find(tenant_id)
.select(tenant_dsl::name)
.first(conn)
.map_err(AppError::from)?;
let session_value = generate_session_token();
let session_hash = hash_session_token(&session_value);
let refresh_expires_at =
now + ChronoDuration::days(self.state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(user_sessions::table)
.values(&new_session)
.execute(conn)?;
let json = ok_json(LoginResponseVariants::Token(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: self.state.config.jwt_expiry_minutes * 60,
tenant: TenantSnippet {
id: tenant_id,
name: tenant_name,
},
}))?;
let mut response = json.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_session_cookie(self.state, &session_value, refresh_expires_at),
);
Ok(response)
}
}
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
let new_user = NewUser {
id,
username: username.to_string(),
};
diesel::insert_into(dsl::users)
.values(&new_user)
.execute(conn)
.map(|_| ())
.map_err(AppError::from)
}
fn hash_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn hash_magic_token(token: &str) -> String {
hash_session_token(token)
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn build_cookie(
state: &AppState,
token: Option<&str>,
expires_at: Option<chrono::DateTime<Utc>>,
max_age: i64,
) -> HeaderValue {
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push(format!("Max-Age={}", max_age));
if let Some(expires) = expires_at {
parts.push(format!("Expires={}", expires.to_rfc2822()));
}
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
}
fn build_session_cookie(
state: &AppState,
token: &str,
expires_at: chrono::DateTime<Utc>,
) -> HeaderValue {
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
build_cookie(state, Some(token), Some(expires_at), max_age)
}
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
build_cookie(state, None, Some(epoch), 0)
}
fn normalize_username(value: &str) -> AppResult<String> {
normalize_identifier(
value,
100,
"username must not be empty",
"username must not exceed 100 characters",
Some("username may only contain printable characters"),
|ch| !ch.is_control(),
)
}
+293
View File
@@ -0,0 +1,293 @@
use axum::http::StatusCode;
use chrono::Utc;
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
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,
},
error::{AppError, AppResult},
http::responders::{
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
},
models::{ApiCapability, CapabilitySet},
schema::{
api_tokens,
capability_sets::{self, dsl as cs_dsl},
user_memberships,
},
utils::text::normalize_identifier,
};
#[derive(Serialize, utoipa::ToSchema)]
pub struct CapabilitySetResponse {
pub id: Uuid,
pub slug: 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>>,
}
pub struct CapabilitySetService;
impl CapabilitySetService {
pub fn new() -> Self {
Self
}
pub fn list(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
.load::<CapabilitySet>(conn)?;
let mut responses = Vec::with_capacity(sets.len());
for set in sets {
let capabilities = load_capabilities_for_set(conn, set.id)?;
responses.push(to_response(set, capabilities));
}
ok_json(responses)
}
pub fn list_capabilities(&self) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants()
.iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect();
ok_json(capabilities)
}
pub fn get(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(conn)
.into_app_result()?;
let capabilities = load_capabilities_for_set(conn, set.id)?;
ok_json(to_response(set, capabilities))
}
pub fn create(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
payload: CreateCapabilitySetRequest,
) -> AppResult<JsonResponse<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(conn, tenant_id, &slug, original_caps)?;
let response = to_response(set, normalized_caps);
created_json(response)
}
pub fn update(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
payload: UpdateCapabilitySetRequest,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(conn)
.into_app_result()?;
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(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()
.into_app_result()?
.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)
.into_app_result()?;
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)
.into_app_result()
})?;
let capabilities = load_capabilities_for_set(conn, set.id)?;
ok_json(to_response(set, capabilities))
}
pub fn delete(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
) -> AppResult<StatusCode> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(conn)
.into_app_result()?;
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(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(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(conn)
.into_app_result()?
.or_not_found()?;
no_content()
}
}
fn normalize_slug(value: &str) -> AppResult<String> {
let base = normalize_identifier(
value,
64,
"slug must not be empty",
"slug must not exceed 64 characters",
Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"),
|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(),
)?;
let mut normalized = String::with_capacity(base.len());
for ch in base.chars() {
if ch.is_whitespace() {
normalized.push('-');
} else {
normalized.push(ch.to_ascii_lowercase());
}
}
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,
is_system: set.is_system,
cap_version: set.cap_version,
capabilities,
}
}
+261
View File
@@ -0,0 +1,261 @@
use chrono::Utc;
use diesel::{dsl::not, prelude::*, Connection};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::documents::correspondents::{
insert_document_correspondents, normalize_correspondent_ids,
};
use crate::error::{AppError, AppResult};
use crate::schema::{document_correspondents, documents};
use crate::services::helpers::load_active_document;
use crate::state::{AppState, PgPooledConnection};
use crate::utils::db::validate_bulk_ids;
#[derive(Serialize, ToSchema)]
pub struct BulkCorrespondentResponse {
pub assigned: usize,
pub removed: usize,
}
#[derive(Deserialize, ToSchema)]
pub struct CorrespondentAssignmentInput {
pub correspondent_id: Uuid,
}
#[derive(Deserialize, ToSchema)]
pub struct AssignCorrespondentsRequest {
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default)]
#[schema(default = false)]
pub replace: bool,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum BulkCorrespondentAction {
Add,
Remove,
}
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
BulkCorrespondentAction::Add
}
#[derive(Deserialize, ToSchema)]
pub struct BulkCorrespondentsRequest {
pub document_ids: Vec<Uuid>,
pub assignments: Vec<CorrespondentAssignmentInput>,
#[serde(default = "default_bulk_correspondent_action")]
pub action: BulkCorrespondentAction,
}
pub struct CorrespondentsService<'a> {
_state: &'a AppState,
}
impl<'a> CorrespondentsService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { _state: state }
}
pub fn assign_to_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
document_id: Uuid,
request: &AssignCorrespondentsRequest,
) -> AppResult<()> {
if request.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
let raw_ids: Vec<Uuid> = request
.assignments
.iter()
.map(|assignment| assignment.correspondent_id)
.collect();
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
conn.transaction::<_, AppError, _>(|conn| {
let document = load_active_document(conn, tenant_id, document_id)?;
let mut updated = false;
if request.replace {
let base = document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id));
let removed = if correspondent_ids.is_empty() {
diesel::delete(base).execute(conn)?
} else {
diesel::delete(base.filter(not(
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
)))
.execute(conn)?
};
if removed > 0 {
updated = true;
}
}
let inserted = insert_document_correspondents(
conn,
tenant_id,
document.id,
user_id,
&correspondent_ids,
)?;
if inserted > 0 {
updated = true;
}
if updated && inserted == 0 {
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(())
})
}
pub fn bulk_update(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
mut payload: BulkCorrespondentsRequest,
) -> AppResult<BulkCorrespondentResponse> {
if payload.assignments.is_empty() {
return Err(AppError::bad_request("assignments must not be empty"));
}
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
let raw_ids: Vec<Uuid> = payload
.assignments
.iter()
.map(|assignment| assignment.correspondent_id)
.collect();
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
let action = payload.action;
conn.transaction::<_, AppError, _>(|conn| {
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&payload.document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(conn)?;
if docs.len() != payload.document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign correspondents to deleted documents",
));
}
match action {
BulkCorrespondentAction::Add => {
let mut assigned_total = 0;
for (doc_id, _) in &docs {
assigned_total += insert_document_correspondents(
conn,
tenant_id,
*doc_id,
user_id,
&correspondent_ids,
)?;
}
Ok(BulkCorrespondentResponse {
assigned: assigned_total,
removed: 0,
})
}
BulkCorrespondentAction::Remove => {
if correspondent_ids.is_empty() {
return Ok(BulkCorrespondentResponse {
assigned: 0,
removed: 0,
});
}
let removed = diesel::delete(
document_correspondents::table
.filter(
document_correspondents::document_id.eq_any(&payload.document_ids),
)
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(
document_correspondents::correspondent_id
.eq_any(&correspondent_ids),
),
)
.execute(conn)?;
if removed > 0 {
diesel::update(
documents::table
.filter(documents::id.eq_any(&payload.document_ids))
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
}
Ok(BulkCorrespondentResponse {
assigned: 0,
removed,
})
}
}
})
}
pub fn remove_from_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
document_id: Uuid,
correspondent_id: Uuid,
) -> AppResult<()> {
load_active_document(conn, tenant_id, document_id)?;
let deleted = diesel::delete(
document_correspondents::table
.filter(document_correspondents::document_id.eq(document_id))
.filter(document_correspondents::tenant_id.eq(tenant_id))
.filter(document_correspondents::correspondent_id.eq(correspondent_id)),
)
.execute(conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set(documents::updated_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
use std::collections::HashMap;
use diesel::pg::PgConnection;
use diesel::{
dsl::{exists, sql},
prelude::*,
sql_types::Text,
Connection,
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
use crate::error::{AppError, AppResult};
use crate::http::responders::{IntoAppResult, RowsAffectedExt};
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::services::documents::{DocumentResponse, DocumentsService};
use crate::state::{AppState, PgPooledConnection};
use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso};
const MAX_FOLDER_NAME_LEN: usize = 255;
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Serialize, ToSchema)]
pub struct FolderTreeNode {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub children: Vec<FolderTreeNode>,
}
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")]
#[schema(default = true)]
pub include_documents: bool,
#[serde(default)]
#[schema(default = "title")]
pub sort: DocumentSortField,
#[serde(default)]
#[schema(default = "asc")]
pub dir: SortDirection,
}
#[derive(Default, Deserialize, ToSchema)]
pub struct UpdateFolderRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
pub struct FolderContentsData {
pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>,
pub documents: Vec<Document>,
}
pub struct FolderService<'a> {
state: &'a AppState,
}
impl<'a> FolderService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn get_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<FolderInfo> {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
Ok(folder_to_info(folder))
}
pub fn ensure_folder_path(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
payload: EnsureFolderPathRequest,
) -> AppResult<FolderInfo> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = normalize_folder_name(raw_name, "folder names must not be empty")?;
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&name))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.clone(),
parent_id: current_parent,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(conn)
.optional()?;
if let Some(id) = inserted_id {
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?
} else if let Some(parent_id) = current_parent {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&name))
.first(conn)?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&name))
.first(conn)?
}
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
})?;
Ok(folder_to_info(folder))
}
pub fn create_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
payload: CreateFolderRequest,
) -> AppResult<(FolderInfo, bool)> {
let name = normalize_folder_name(&payload.name, "name must not be empty")?;
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&name))
.first(conn)
.optional()?
};
let (folder, created) = if let Some(folder) = existing {
(folder, false)
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.clone(),
parent_id: payload.parent_id,
tenant_id,
};
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
.values(&new_folder)
.on_conflict_do_nothing()
.returning(folders::id)
.get_result(conn)
.optional()?;
if let Some(id) = inserted_id {
(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?,
true,
)
} else if let Some(parent_id) = payload.parent_id {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&name))
.first(conn)?,
false,
)
} else {
(
folders::table
.filter(folders::tenant_id.eq(tenant_id))
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&name))
.first(conn)?,
false,
)
}
};
Ok((folder_to_info(folder), created))
}
pub fn list_folder_contents(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Option<Uuid>,
sort: DocumentSortField,
dir: SortDirection,
include_documents: bool,
) -> AppResult<FolderContentsData> {
let folder = match folder_id {
Some(id) => Some(folder_to_info(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?,
)),
None => None,
};
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
folders::table
.filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let documents = if include_documents {
let mut docs_query = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.into_boxed();
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
docs_query = docs_query.order(sql::<Text>(primary_sql));
if let Some(second) = secondary_sql {
docs_query = docs_query.then_order_by(sql::<Text>(second));
}
if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load::<Document>(conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load::<Document>(conn)?
}
} else {
Vec::new()
};
Ok(FolderContentsData {
folder,
subfolders,
documents,
})
}
pub fn list_folder_tree(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
) -> AppResult<Vec<FolderTreeNode>> {
let folders: Vec<Folder> = folders::table
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?;
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
let mut roots: Vec<Uuid> = Vec::new();
for folder in folders {
let id = folder.id;
let parent_id = folder.parent_id;
let node = FolderTreeNode {
id,
name: folder.name,
parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
children: Vec::new(),
};
if let Some(parent) = parent_id {
children_map.entry(parent).or_default().push(id);
} else {
roots.push(id);
}
node_map.insert(id, node);
}
fn build_node(
id: Uuid,
nodes: &HashMap<Uuid, FolderTreeNode>,
child_map: &HashMap<Uuid, Vec<Uuid>>,
) -> FolderTreeNode {
let mut node = nodes.get(&id).cloned().expect("folder node must exist");
if let Some(children) = child_map.get(&id) {
node.children = children
.iter()
.map(|child_id| build_node(*child_id, nodes, child_map))
.collect();
}
node
}
let tree = roots
.iter()
.map(|root_id| build_node(*root_id, &node_map, &children_map))
.collect();
Ok(tree)
}
pub fn delete_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<()> {
conn.transaction::<_, AppError, _>(|conn| {
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
let has_child_folders: bool = diesel::select(exists(
folders::table
.filter(folders::parent_id.eq(Some(folder_id)))
.filter(folders::tenant_id.eq(tenant_id)),
))
.get_result(conn)?;
if has_child_folders {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
let has_documents: bool = diesel::select(exists(
documents::table
.filter(documents::folder_id.eq(Some(folder_id)))
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null()),
))
.get_result(conn)?;
if has_documents {
return Err(AppError::bad_request(
"folder must be empty before deletion",
));
}
diesel::delete(
folders::table
.filter(folders::id.eq(folder_id))
.filter(folders::tenant_id.eq(tenant_id)),
)
.execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(())
})
}
pub fn update_folder(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
folder_id: Uuid,
payload: UpdateFolderRequest,
) -> AppResult<()> {
conn.transaction::<_, AppError, _>(|conn| {
let folder: Folder = folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
match payload.parent_id {
None => {}
Some(None) => {
if folder.parent_id.is_some() {
parent_changed = true;
}
next_parent = None;
}
Some(Some(parent_id)) => {
if parent_id == folder_id {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
folders::table
.find(parent_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?;
if folder.parent_id != Some(parent_id) {
let descendant_ids =
gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
));
}
parent_changed = true;
}
next_parent = Some(parent_id);
}
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
match payload.name {
None => {}
Some(None) => {
return Err(AppError::bad_request("name cannot be null"));
}
Some(Some(value)) => {
let normalized = normalize_folder_name(&value, "name must not be empty")?;
if normalized != folder.name {
new_name = normalized;
name_changed = true;
}
}
}
if !parent_changed && !name_changed {
return Ok(());
}
let conflict = if let Some(parent_id) = next_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)
.optional()?
};
if conflict.is_some() {
return Err(AppError::bad_request(
"a folder with the same name already exists in the target",
));
}
diesel::update(
folders::table
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id)),
)
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)
.into_app_result()?
.or_not_found()?;
Ok(())
})
}
pub fn hydrate_documents(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
docs: Vec<Document>,
) -> AppResult<Vec<DocumentResponse>> {
DocumentsService::new(self.state).hydrate_documents(conn, user_id, docs)
}
}
pub fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = folders::table
.filter(folders::parent_id.eq(Some(current)))
.filter(folders::tenant_id.eq(tenant_id))
.select(folders::id)
.load(conn)?;
queue.extend(child_ids.iter().copied());
ids.extend(child_ids);
}
Ok(ids)
}
fn folder_to_info(folder: Folder) -> FolderInfo {
FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult<String> {
normalize_identifier(
value,
MAX_FOLDER_NAME_LEN,
empty_message,
"folder name must not exceed 255 characters",
Some("folder name may only contain printable characters"),
|ch| !ch.is_control(),
)
}
const fn default_include_documents() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::UpdateFolderRequest;
use serde_json::json;
#[test]
fn update_folder_request_deserializes_null_parent() {
let req: UpdateFolderRequest =
serde_json::from_value(json!({ "parent_id": null })).unwrap();
assert!(matches!(req.parent_id, Some(None)));
}
#[test]
fn update_folder_request_deserializes_absent_parent() {
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
assert!(req.parent_id.is_none());
}
#[test]
fn update_folder_request_deserializes_null_name() {
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
assert!(matches!(req.name, Some(None)));
}
}
+25
View File
@@ -0,0 +1,25 @@
use diesel::prelude::*;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::Document;
use crate::schema::documents;
use crate::state::PgPooledConnection;
/// Load a document that belongs to the tenant and is not soft-deleted.
pub fn load_active_document(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
document_id: Uuid,
) -> AppResult<Document> {
let doc: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
Ok(doc)
}
+8
View File
@@ -0,0 +1,8 @@
pub mod auth;
pub mod capability_sets;
pub mod correspondents;
pub mod documents;
pub mod folders;
pub mod helpers;
pub mod profile;
pub mod tags;
+222
View File
@@ -0,0 +1,222 @@
use axum::http::StatusCode;
use chrono::{DateTime, NaiveDateTime};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::auth::{
api_tokens::{
create_api_token as issue_token, list_api_tokens as load_tokens,
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
},
capability_sets::load_capability_set,
passkeys::PasskeySummary,
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{created_json, no_content, ok_json, JsonResponse};
use crate::models::ApiToken;
use crate::state::{AppState, PgPooledConnection};
use crate::utils::time::to_iso;
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub capability_set_id: Uuid,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiTokenCreatedResponse {
pub token: String,
pub token_info: ApiTokenResponse,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateApiTokenRequest {
#[schema(nullable)]
pub label: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
pub capability_set_id: Uuid,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RevokePasskeyQuery {
#[serde(default)]
#[schema(nullable)]
pub reason: Option<String>,
}
pub struct ProfileService<'a> {
state: &'a AppState,
}
impl<'a> ProfileService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub fn list_passkeys(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let passkeys = service.list_for_user(conn, user_id)?;
ok_json(passkeys)
}
pub fn list_api_tokens(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(conn, user_id, Some(tenant_id))?;
let responses = tokens.into_iter().map(api_token_to_response).collect();
ok_json(responses)
}
pub fn create_api_token(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
payload: CreateApiTokenRequest,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let expires_at = payload
.expires_at
.as_ref()
.map(|value| parse_timestamp(value))
.transpose()?;
let capability_set_id =
validate_capability_set(conn, tenant_id, payload.capability_set_id)?;
let issued = issue_token(
conn,
user_id,
tenant_id,
payload.label.clone(),
expires_at,
capability_set_id,
)?;
let token_info = api_token_to_response(issued.record);
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info,
};
created_json(response)
}
pub fn regenerate_api_token(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
token_id: Uuid,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?;
let token_info = api_token_to_response(issued.record);
ok_json(ApiTokenCreatedResponse {
token: issued.token,
token_info,
})
}
pub fn delete_api_token(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
token_id: Uuid,
) -> AppResult<StatusCode> {
revoke_token(conn, token_id, user_id)?;
no_content()
}
pub fn delete_passkey(
&self,
conn: &mut PgPooledConnection,
user_id: Uuid,
passkey_id: Uuid,
reason: Option<String>,
) -> AppResult<StatusCode> {
let service = self
.state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let active_count = service.active_passkey_count(conn, user_id)?;
if active_count <= 1 {
return Err(AppError::bad_request(
"cannot revoke the last remaining passkey",
));
}
service.revoke_passkey(conn, user_id, passkey_id, reason)?;
no_content()
}
}
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
let ApiToken {
id,
tenant_id,
label,
created_at,
last_used_at,
expires_at,
revoked_at,
capability_set_id,
..
} = token;
ApiTokenResponse {
id,
tenant_id,
label,
capability_set_id,
created_at: to_iso(created_at),
last_used_at: last_used_at.map(to_iso),
expires_at: expires_at.map(to_iso),
revoked_at: revoked_at.map(to_iso),
}
}
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
fn validate_capability_set(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
capability_set_id: Uuid,
) -> AppResult<Uuid> {
let set = load_capability_set(conn, capability_set_id)?;
if set.tenant_id != tenant_id {
return Err(AppError::bad_request(
"capability set does not belong to the tenant",
));
}
Ok(set.id)
}
+166
View File
@@ -0,0 +1,166 @@
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::documents::tags::assign_tags as assign_tags_to_document;
use crate::error::{AppError, AppResult};
use crate::models::{Document, NewDocumentTag, Tag};
use crate::schema::{document_tags, documents, tags};
use crate::state::{AppState, PgPooledConnection};
use crate::utils::db::validate_bulk_ids;
#[derive(Deserialize, ToSchema)]
pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum BulkTagAction {
Add,
Remove,
}
#[derive(Deserialize, ToSchema)]
pub struct BulkTagRequest {
pub document_ids: Vec<Uuid>,
pub tag_ids: Vec<Uuid>,
pub action: BulkTagAction,
}
#[derive(Serialize, ToSchema)]
pub struct BulkTagResponse {
pub added: usize,
pub removed: usize,
}
pub struct TagsService<'a> {
_state: &'a AppState,
}
impl<'a> TagsService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { _state: state }
}
pub fn assign_to_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
document_id: Uuid,
tag_ids: &[Uuid],
) -> AppResult<()> {
if tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
let document: Document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.first(conn)?;
assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?;
Ok(())
}
pub fn bulk_update(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
user_id: Uuid,
mut payload: BulkTagRequest,
) -> AppResult<BulkTagResponse> {
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
validate_bulk_ids(&mut payload.tag_ids, "tag_ids")?;
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
.filter(documents::id.eq_any(&payload.document_ids))
.filter(documents::tenant_id.eq(tenant_id))
.select((documents::id, documents::deleted_at))
.load(conn)?;
if docs.len() != payload.document_ids.len() {
return Err(AppError::bad_request(
"one or more documents do not exist or are inaccessible",
));
}
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
return Err(AppError::bad_request(
"cannot assign or remove tags from deleted documents",
));
}
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&payload.tag_ids))
.filter(tags::tenant_id.eq(tenant_id))
.load(conn)?;
if existing_tags.len() != payload.tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
match payload.action {
BulkTagAction::Add => {
let mut inserts =
Vec::with_capacity(payload.document_ids.len() * payload.tag_ids.len());
for doc_id in &payload.document_ids {
for tag_id in &payload.tag_ids {
inserts.push(NewDocumentTag {
document_id: *doc_id,
tag_id: *tag_id,
assigned_by: Some(user_id),
tenant_id,
});
}
}
let added = if inserts.is_empty() {
0
} else {
diesel::insert_into(document_tags::table)
.values(&inserts)
.on_conflict_do_nothing()
.execute(conn)?
};
Ok(BulkTagResponse { added, removed: 0 })
}
BulkTagAction::Remove => {
let removed = diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq_any(&payload.document_ids))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq_any(&payload.tag_ids)),
)
.execute(conn)?;
Ok(BulkTagResponse { added: 0, removed })
}
}
}
pub fn remove_from_document(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
document_id: Uuid,
tag_id: Uuid,
) -> AppResult<()> {
let deleted = diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq(document_id))
.filter(document_tags::tenant_id.eq(tenant_id))
.filter(document_tags::tag_id.eq(tag_id)),
)
.execute(conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
Ok(())
}
}
+4
View File
@@ -74,6 +74,8 @@ impl AppState {
pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> { pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
let mut conn = self.db_unscoped()?; let mut conn = self.db_unscoped()?;
let conn_ptr = &*conn as *const _;
tracing::trace!(target = "db_pool", ?conn_ptr, tenant_id = %tenant_id, "apply tenant context");
apply_tenant_guc(&mut conn, tenant_id)?; apply_tenant_guc(&mut conn, tenant_id)?;
clear_user_guc(&mut conn)?; clear_user_guc(&mut conn)?;
Ok(conn) Ok(conn)
@@ -84,6 +86,8 @@ impl AppState {
tracing::error!(error = ?err, "database pool error"); tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error") AppError::internal("database pool error")
})?; })?;
let conn_ptr = &*conn as *const _;
tracing::trace!(target = "db_pool", ?conn_ptr, "acquired connection");
clear_tenant_context(&mut conn)?; clear_tenant_context(&mut conn)?;
Ok(conn) Ok(conn)
} }
+15 -1
View File
@@ -8,6 +8,7 @@ use crate::{
jobs::{enqueue_job, JOB_PROVISION_TENANT}, jobs::{enqueue_job, JOB_PROVISION_TENANT},
models::{Tenant, TenantStatus}, models::{Tenant, TenantStatus},
schema::tenants::dsl, schema::tenants::dsl,
utils::text::normalize_identifier,
}; };
pub struct TenantRepository; pub struct TenantRepository;
@@ -96,6 +97,8 @@ impl TenantService {
return Err(AppError::bad_request("tenant name must not be empty")); return Err(AppError::bad_request("tenant name must not be empty"));
} }
let name = normalize_tenant_name(name)?;
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let storage_root = normalize_storage_root(storage_root, id); let storage_root = normalize_storage_root(storage_root, id);
let quickwit_index = normalize_quickwit_index(quickwit_index, id); let quickwit_index = normalize_quickwit_index(quickwit_index, id);
@@ -103,7 +106,7 @@ impl TenantService {
diesel::insert_into(dsl::tenants) diesel::insert_into(dsl::tenants)
.values(( .values((
dsl::id.eq(id), dsl::id.eq(id),
dsl::name.eq(name), dsl::name.eq(&name),
dsl::storage_root.eq(Some(storage_root.clone())), dsl::storage_root.eq(Some(storage_root.clone())),
dsl::quickwit_index.eq(Some(quickwit_index.clone())), dsl::quickwit_index.eq(Some(quickwit_index.clone())),
dsl::config.eq(json!({})), dsl::config.eq(json!({})),
@@ -186,6 +189,17 @@ pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResul
.map_err(AppError::from) .map_err(AppError::from)
} }
fn normalize_tenant_name(value: &str) -> AppResult<String> {
normalize_identifier(
value,
255,
"tenant name must not be empty",
"tenant name must not exceed 255 characters",
Some("tenant name may only contain printable characters"),
|ch| !ch.is_control(),
)
}
pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> { pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)") diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)")
.execute(conn) .execute(conn)
+1 -34
View File
@@ -1,4 +1,4 @@
use diesel::{pg::PgConnection, result::Error as DieselError}; use diesel::pg::PgConnection;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -6,25 +6,6 @@ use crate::{
state::AppState, state::AppState,
}; };
pub trait EnsureEntity<T> {
fn one(self) -> AppResult<T>;
fn maybe(self) -> AppResult<Option<T>>;
}
impl<T> EnsureEntity<T> for Result<T, DieselError> {
fn one(self) -> AppResult<T> {
self.map_err(AppError::from)
}
fn maybe(self) -> AppResult<Option<T>> {
match self {
Ok(value) => Ok(Some(value)),
Err(DieselError::NotFound) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
}
}
impl AppState { impl AppState {
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T> pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
where where
@@ -43,17 +24,3 @@ pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
ids.dedup(); ids.dedup();
Ok(()) Ok(())
} }
pub trait IntoJsonResponse<T> {
fn into_json(self) -> AppResult<axum::Json<T>>;
}
impl<T> IntoJsonResponse<T> for T {
fn into_json(self) -> AppResult<axum::Json<T>> {
Ok(axum::Json(self))
}
}
pub fn no_content() -> AppResult<axum::http::StatusCode> {
Ok(axum::http::StatusCode::NO_CONTENT)
}
+2
View File
@@ -4,7 +4,9 @@ pub mod error;
pub mod http; pub mod http;
pub mod json; pub mod json;
pub mod named_entity; pub mod named_entity;
pub mod setops;
pub mod storage_paths; pub mod storage_paths;
pub mod text;
pub mod time; pub mod time;
pub mod tracing; pub mod tracing;
pub mod validation; pub mod validation;
+41
View File
@@ -0,0 +1,41 @@
use std::collections::HashSet;
use std::hash::Hash;
use uuid::Uuid;
use crate::error::AppResult;
use crate::state::PgPooledConnection;
/// Intersect an optional base set with a new set, returning the resulting option.
pub fn intersect_option_sets<T>(base: Option<HashSet<T>>, next: HashSet<T>) -> Option<HashSet<T>>
where
T: Eq + Hash + Copy,
{
Some(match base {
Some(existing) => existing.intersection(&next).copied().collect(),
None => next,
})
}
/// Iteratively intersect documents linked via a join table loader.
pub fn load_linked_doc_ids<F>(
conn: &mut PgPooledConnection,
ids: &[Uuid],
mut loader: F,
) -> AppResult<HashSet<Uuid>>
where
F: FnMut(&mut PgPooledConnection, Uuid) -> AppResult<HashSet<Uuid>>,
{
let mut current: Option<HashSet<Uuid>> = None;
for id in ids {
let docs_set = loader(conn, *id)?;
current = intersect_option_sets(current, docs_set);
if current.as_ref().is_some_and(|set| set.is_empty()) {
break;
}
}
Ok(current.unwrap_or_default())
}
+31
View File
@@ -0,0 +1,31 @@
use crate::error::{AppError, AppResult};
/// Normalizes an identifier-like user input by trimming, enforcing length, and validating characters.
pub fn normalize_identifier<F>(
value: &str,
max_len: usize,
empty_message: &str,
length_message: &str,
invalid_message: Option<&str>,
mut validator: F,
) -> AppResult<String>
where
F: FnMut(char) -> bool,
{
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request(empty_message));
}
if trimmed.len() > max_len {
return Err(AppError::bad_request(length_message));
}
if let Some(msg) = invalid_message {
if !trimmed.chars().all(|ch| validator(ch)) {
return Err(AppError::bad_request(msg));
}
}
Ok(trimmed.to_string())
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub fn init_tracing(default_level: &str) {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(filter) .with_env_filter(filter)
.with_target(false) .with_target(true)
.compact() .compact()
.init(); .init();
} }
+2
View File
@@ -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()),
] ]
+4 -2
View File
@@ -19,6 +19,7 @@ use uuid::Uuid;
use crate::{ use crate::{
documents::asset::delete_asset, documents::asset::delete_asset,
error::AppResult,
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT}, jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{ models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
@@ -151,8 +152,9 @@ impl JobHandler for GenerateOcrTextJob {
let tenant_id = context.document.tenant_id; let tenant_id = context.document.tenant_id;
let asset_id = existing_asset.id; let asset_id = existing_asset.id;
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || { match task::spawn_blocking(move || -> AppResult<()> {
delete_asset(state_clone.as_ref(), tenant_id, asset_id) let mut conn = state_clone.db_for_tenant(tenant_id)?;
delete_asset(&mut conn, tenant_id, asset_id)
}) })
.await .await
{ {
+239
View File
@@ -0,0 +1,239 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use diesel::prelude::*;
use diesel::result::Error as DieselError;
use serde::Deserialize;
use tracing::{error, warn};
use uuid::Uuid;
use crate::jobs::JOB_PURGE_DOCUMENT;
use crate::models::{Document, DocumentVersion};
use crate::schema::{document_asset_objects, document_assets, document_versions};
use crate::state::AppState;
use crate::storage::TenantStorage;
use super::{JobExecution, JobHandler};
#[derive(Debug, Deserialize)]
struct PurgeDocumentPayload {
document_id: Uuid,
}
#[derive(Debug)]
struct PurgeContext {
document_id: Uuid,
version_keys: Vec<String>,
asset_keys: Vec<String>,
}
pub struct PurgeDocumentJob;
impl PurgeDocumentJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for PurgeDocumentJob {
fn job_type(&self) -> &'static str {
JOB_PURGE_DOCUMENT
}
async fn handle(
&self,
state: Arc<AppState>,
job: crate::models::Job,
storage: TenantStorage,
) -> JobExecution {
let payload: PurgeDocumentPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid purge payload: {err}"),
};
}
};
let tenant_id = job.tenant_id;
let document_id = payload.document_id;
let state_for_prepare = state.clone();
let preparation = tokio::task::spawn_blocking(move || {
prepare_purge_context(state_for_prepare, tenant_id, document_id)
})
.await;
let context = match preparation {
Ok(Ok(Some(ctx))) => ctx,
Ok(Ok(None)) => {
// Document already gone or restored; nothing to do.
return JobExecution::Success;
}
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "purge preparation failed");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "purge preparation task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("purge preparation panicked: {join_err}"),
};
}
};
if let Err(err) = delete_storage_objects(&storage, &context).await {
warn!(job_id = %job.id, error = %err, "failed to delete storage objects for purge");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
let PurgeContext { document_id, .. } = context;
let state_for_finalize = state.clone();
let finalize = tokio::task::spawn_blocking(move || {
finalize_purge(state_for_finalize, tenant_id, document_id)
})
.await;
match finalize {
Ok(Ok(())) => JobExecution::Success,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "failed to finalize purge");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
}
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "purge finalize task panicked");
JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("purge finalize panicked: {join_err}"),
}
}
}
}
}
fn prepare_purge_context(
state: Arc<AppState>,
tenant_id: Uuid,
document_id: Uuid,
) -> Result<Option<PurgeContext>, String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
conn.transaction(|conn| {
use crate::schema::documents::dsl as doc_dsl;
let doc_opt = doc_dsl::documents
.filter(doc_dsl::tenant_id.eq(tenant_id))
.find(document_id)
.for_update()
.first::<Document>(conn)
.optional()?;
let Some(document) = doc_opt else {
return Ok(None);
};
if document.deleted_at.is_none() {
return Ok(None);
}
let versions: Vec<DocumentVersion> = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::tenant_id.eq(tenant_id))
.load(conn)?;
let version_keys: Vec<String> = versions
.iter()
.map(|version| version.s3_key.clone())
.collect();
let version_ids: Vec<Uuid> = versions.iter().map(|version| version.id).collect();
let asset_keys = if version_ids.is_empty() {
Vec::new()
} else {
let asset_ids: Vec<Uuid> = document_assets::table
.filter(document_assets::document_version_id.eq_any(&version_ids))
.filter(document_assets::tenant_id.eq(tenant_id))
.select(document_assets::id)
.load(conn)?;
if asset_ids.is_empty() {
Vec::new()
} else {
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.select(document_asset_objects::s3_key)
.load(conn)?
}
};
Ok(Some(PurgeContext {
document_id,
version_keys,
asset_keys,
}))
})
.map_err(|err: DieselError| format!("failed to prepare purge: {err}"))
}
async fn delete_storage_objects(
storage: &TenantStorage,
context: &PurgeContext,
) -> Result<(), String> {
let mut keys = HashSet::new();
keys.extend(context.version_keys.iter().cloned());
keys.extend(context.asset_keys.iter().cloned());
for key in keys {
if let Err(err) = storage.delete_object(&key).await {
return Err(format!("failed to delete object {}: {err:?}", key));
}
}
Ok(())
}
fn finalize_purge(state: Arc<AppState>, tenant_id: Uuid, document_id: Uuid) -> Result<(), String> {
let mut conn = state
.db_for_tenant(tenant_id)
.map_err(|err| format!("failed to scope tenant connection: {err:?}"))?;
conn.transaction(|conn| {
use crate::schema::documents::dsl as doc_dsl;
let doc_opt = doc_dsl::documents
.filter(doc_dsl::tenant_id.eq(tenant_id))
.find(document_id)
.for_update()
.first::<Document>(conn)
.optional()?;
let Some(document) = doc_opt else {
return Ok(());
};
if document.deleted_at.is_none() {
return Ok(());
}
diesel::delete(doc_dsl::documents.filter(doc_dsl::id.eq(document_id))).execute(conn)?;
Ok(())
})
.map_err(|err: DieselError| format!("failed to finalize purge: {err}"))
}
+61
View File
@@ -8,6 +8,10 @@ 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, readonly_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 +132,69 @@ 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, readonly_capabilities()) {
warn!(
job_id = %job.id,
tenant_id = %tenant.id,
error = ?err,
"failed to ensure readonly capability set during provisioning"
);
return JobExecution::Retry {
delay: std::time::Duration::from_secs(30),
error: "readonly capability set unavailable".into(),
};
}
if let Err(err) = ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()) {
warn!(
job_id = %job.id,
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 -4
View File
@@ -13,6 +13,7 @@ use uuid::Uuid;
use crate::{ use crate::{
documents::asset::delete_asset, documents::asset::delete_asset,
error::AppResult,
jobs::JOB_GENERATE_THUMBNAILS, jobs::JOB_GENERATE_THUMBNAILS,
models::{ models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
@@ -184,8 +185,9 @@ impl JobHandler for GenerateThumbnailsJob {
let tenant_id = initial.document.tenant_id; let tenant_id = initial.document.tenant_id;
let asset_id = existing_preview.id; let asset_id = existing_preview.id;
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || { match task::spawn_blocking(move || -> AppResult<()> {
delete_asset(state_clone.as_ref(), tenant_id, asset_id) let mut conn = state_clone.db_for_tenant(tenant_id)?;
delete_asset(&mut conn, tenant_id, asset_id)
}) })
.await .await
{ {
@@ -224,8 +226,9 @@ impl JobHandler for GenerateThumbnailsJob {
let tenant_id = initial.document.tenant_id; let tenant_id = initial.document.tenant_id;
let asset_id = existing_thumbnail.id; let asset_id = existing_thumbnail.id;
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || { match task::spawn_blocking(move || -> AppResult<()> {
delete_asset(state_clone.as_ref(), tenant_id, asset_id) let mut conn = state_clone.db_for_tenant(tenant_id)?;
delete_asset(&mut conn, tenant_id, asset_id)
}) })
.await .await
{ {
+166 -38
View File
@@ -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::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,27 @@ 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 READ_ONLY_CAPS: &[&str] = &["documents:read"];
const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"];
#[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>, capability_set_id: Uuid,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -45,6 +59,12 @@ struct TenantView {
name: String, name: String,
} }
#[derive(Debug, Deserialize)]
struct CapabilitySetSummary {
id: Uuid,
slug: String,
}
#[tokio::test] #[tokio::test]
async fn api_token_crud_flow() -> Result<()> { async fn api_token_crud_flow() -> Result<()> {
let _guard = acquire_db_lock().await; let _guard = acquire_db_lock().await;
@@ -55,31 +75,25 @@ 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 legacy_set_id =
ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
.await?;
let created = create_token(&app, &access_token, Some("dav"), legacy_set_id, None).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_eq!(created.info.capability_set_id, legacy_set_id);
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);
assert_ne!(regenerated.token, created.token); assert_ne!(regenerated.token, created.token);
assert!(regenerated.info.last_used_at.is_none()); assert!(regenerated.info.last_used_at.is_none());
let updated = update_token_capabilities(
&app,
&access_token,
token_id,
json!({ "capabilities": ["webdav", "api"] }),
)
.await?;
assert_eq!(updated.capabilities.len(), 2);
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);
assert_eq!(listed[0].id, token_id); assert_eq!(listed[0].id, token_id);
assert_eq!(listed[0].capability_set_id, legacy_set_id);
let tenant_id_for_token = app let tenant_id_for_token = app
.with_conn(move |conn| { .with_conn(move |conn| {
@@ -91,6 +105,15 @@ async fn api_token_crud_flow() -> Result<()> {
}) })
.await?; .await?;
let readonly_set_id =
ensure_capability_set_slug(&app, &access_token, "readonly", READ_ONLY_CAPS).await?;
let readonly_token =
create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?;
let readonly_exchange = exchange_token(&app, &readonly_token.token).await?;
assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token);
delete_token(&app, &access_token, readonly_token.info.id).await?;
let exchange = exchange_token(&app, &regenerated.token).await?; let exchange = exchange_token(&app, &regenerated.token).await?;
assert_eq!(exchange.token_type, "Bearer"); assert_eq!(exchange.token_type, "Bearer");
assert!(!exchange.access_token.is_empty()); assert!(!exchange.access_token.is_empty());
@@ -101,9 +124,11 @@ async fn api_token_crud_flow() -> Result<()> {
delete_token(&app, &access_token, token_id).await?; delete_token(&app, &access_token, token_id).await?;
let listed_after = list_tokens(&app, &access_token).await?; let listed_after = list_tokens(&app, &access_token).await?;
assert_eq!(listed_after.len(), 1); let revoked_entry = listed_after
assert_eq!(listed_after[0].id, token_id); .iter()
assert!(listed_after[0].revoked_at.is_some()); .find(|entry| entry.id == token_id)
.expect("revoked token still listed");
assert!(revoked_entry.revoked_at.is_some());
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
@@ -119,7 +144,11 @@ 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 legacy_set_id =
ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
.await?;
let created = create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).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 +216,61 @@ 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 read_only_set_id =
ensure_capability_set_slug(&app, &access_token, "documents_read", READ_ONLY_CAPS).await?;
let limited_token =
create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?;
assert_eq!(limited_token.info.capability_set_id, read_only_set_id);
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 limited_set_id = ensure_capability_set_slug(
&app,
&access_token,
"documents_read_webdav",
LIMITED_WEBDAV_CAPS,
)
.await?;
let upgraded_token = create_token(
&app,
&access_token,
Some("limited-webdav"),
limited_set_id,
None,
)
.await?;
assert_eq!(upgraded_token.info.capability_set_id, limited_set_id);
let upgraded_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(
header::AUTHORIZATION,
format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, upgraded_token.token))
),
)
.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()
@@ -205,8 +289,22 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
async fn create_token( async fn create_token(
app: &TestApp, app: &TestApp,
access_token: &str, access_token: &str,
payload: serde_json::Value, label: Option<&str>,
capability_set_id: Uuid,
expires_at: Option<&str>,
) -> Result<CreateTokenResponse> { ) -> Result<CreateTokenResponse> {
let mut payload = json!({
"capability_set_id": capability_set_id,
});
if let Some(label) = label {
payload["label"] = json!(label);
}
if let Some(expires) = expires_at {
payload["expires_at"] = json!(expires);
}
let response = app let response = app
.post_json("/api/profile/api-tokens", &payload, Some(access_token)) .post_json("/api/profile/api-tokens", &payload, Some(access_token))
.await?; .await?;
@@ -232,24 +330,6 @@ async fn regenerate_token(
Ok(serde_json::from_slice(&body)?) Ok(serde_json::from_slice(&body)?)
} }
async fn update_token_capabilities(
app: &TestApp,
access_token: &str,
token_id: Uuid,
payload: serde_json::Value,
) -> Result<TokenInfo> {
let response = app
.patch_json(
&format!("/api/profile/api-tokens/{token_id}"),
&payload,
Some(access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> { async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
let response = app let response = app
.get("/api/profile/api-tokens", Some(access_token)) .get("/api/profile/api-tokens", Some(access_token))
@@ -269,6 +349,54 @@ 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(())
} }
async fn ensure_capability_set_slug(
app: &TestApp,
access_token: &str,
slug: &str,
capabilities: &[&str],
) -> Result<Uuid> {
if let Some(existing) = find_capability_set_slug(app, access_token, slug).await? {
return Ok(existing);
}
let response = app
.post_json(
"/api/capability-sets",
&json!({
"slug": slug,
"capabilities": capabilities,
}),
Some(access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_to_vec(response.into_body()).await?;
let summary: CapabilitySetSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn find_capability_set_slug(
app: &TestApp,
access_token: &str,
slug: &str,
) -> Result<Option<Uuid>> {
let sets = list_capability_sets(app, access_token).await?;
Ok(sets
.into_iter()
.find(|set| set.slug == slug)
.map(|set| set.id))
}
async fn list_capability_sets(
app: &TestApp,
access_token: &str,
) -> Result<Vec<CapabilitySetSummary>> {
let response = app.get("/api/capability-sets", Some(access_token)).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
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(
+29 -3
View File
@@ -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(),
+106
View File
@@ -0,0 +1,106 @@
mod common;
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
use common::TestApp;
use diesel::prelude::*;
use papercrate::models::ApiCapability;
use serde_json::json;
async fn set_user_capabilities(
app: &TestApp,
user_id: uuid::Uuid,
caps: &[ApiCapability],
) -> Result<()> {
let capabilities = caps.to_vec();
app.with_conn(move |conn| {
use papercrate::schema::user_memberships::dsl as memberships_dsl;
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.first::<papercrate::models::UserMembership>(conn)?;
let capability_set = papercrate::auth::capability_sets::ensure_capability_set(
conn,
membership.tenant_id,
&capabilities,
)
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
diesel::update(memberships_dsl::user_memberships.find(membership.id))
.set(memberships_dsl::capability_set_id.eq(Some(capability_set.id)))
.execute(conn)?;
Ok(())
})
.await
}
#[tokio::test]
async fn documents_routes_enforce_capabilities() -> Result<()> {
let _lock = common::acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "limited-docs";
let user_id = app.insert_user("limited-docs", password, "admin").await?;
set_user_capabilities(&app, user_id, &[ApiCapability::DocumentsRead]).await?;
let token = app.login_token("limited-docs", password).await?;
let list = app.get("/api/documents", Some(&token)).await?;
assert_eq!(list.status(), StatusCode::OK);
let upload = app
.upload_document(
"/api/documents",
"limited.txt",
"text/plain",
b"limited",
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::FORBIDDEN);
let upload_body = common::body_to_vec(upload.into_body()).await?;
assert!(String::from_utf8_lossy(&upload_body).contains("missing"));
let capability_sets = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN);
let caps_body = common::body_to_vec(capability_sets.into_body()).await?;
assert!(String::from_utf8_lossy(&caps_body).contains("missing"));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn capability_set_routes_require_write_privilege() -> Result<()> {
let _lock = common::acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "caps-reader";
let user_id = app.insert_user("caps-reader", password, "admin").await?;
set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?;
let token = app.login_token("caps-reader", password).await?;
let list = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(list.status(), StatusCode::OK);
let create = app
.post_json(
"/api/capability-sets",
&json!({
"slug": "should-fail",
"capabilities": ["documents:read"]
}),
Some(&token),
)
.await?;
assert_eq!(create.status(), StatusCode::FORBIDDEN);
let create_body = common::body_to_vec(create.into_body()).await?;
assert!(String::from_utf8_lossy(&create_body).contains("missing"));
app.cleanup().await?;
Ok(())
}
+116
View File
@@ -0,0 +1,116 @@
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 == "readonly"));
assert!(sets.iter().any(|set| set.slug == "webdav"));
let capabilities_resp = app.get("/api/capabilities", Some(&token)).await?;
assert_eq!(capabilities_resp.status(), StatusCode::OK);
let capabilities_body = body_to_vec(capabilities_resp.into_body()).await?;
let capabilities: Vec<String> = serde_json::from_slice(&capabilities_body)?;
assert!(capabilities.contains(&"documents:read".to_string()));
assert!(capabilities.contains(&"capability_sets:write".to_string()));
assert_eq!(
capabilities.len(),
papercrate::models::ApiCapability::variants().len()
);
// 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(())
}
+80 -14
View File
@@ -16,7 +16,11 @@ 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, readonly_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 +85,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 +226,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 +241,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 +263,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 +300,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 +350,23 @@ 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, readonly_capabilities())
.map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, webdav_capabilities())
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
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 +378,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 +395,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 +425,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 +600,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 +680,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 +734,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 +745,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,
} }
} }
} }
+363 -47
View File
@@ -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)]
@@ -271,6 +277,85 @@ async fn upload_document_with_custom_title_sets_filename() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn document_list_sorting_controls() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "passw0rd";
app.insert_user("sorting", password, "admin").await?;
let token = app.login_token("sorting", password).await?;
let first = app
.upload_document_with_options(
"/api/documents",
"alpha.txt",
"text/plain",
b"alpha",
None,
Some("Alpha"),
None,
&token,
)
.await?;
assert!(first.status().is_success());
let first_body = body_to_vec(first.into_body()).await?;
let first_doc: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document_with_options(
"/api/documents",
"zulu.txt",
"text/plain",
b"zulu",
None,
Some("Zulu"),
None,
&token,
)
.await?;
assert!(second.status().is_success());
let second_body = body_to_vec(second.into_body()).await?;
let second_doc: DocumentDetail = serde_json::from_slice(&second_body)?;
// Default sort should be title ASC => Alpha first.
let default_resp = app.get("/api/documents", Some(&token)).await?;
assert_eq!(default_resp.status(), StatusCode::OK);
let default_body = body_to_vec(default_resp.into_body()).await?;
let default_list: Vec<DocumentListItem> = serde_json::from_slice(&default_body)?;
assert_eq!(default_list.len(), 2);
assert_eq!(default_list[0].id, first_doc.document.id);
assert_eq!(default_list[1].id, second_doc.document.id);
// Sort by created_at DESC, expecting most recent (second) first.
let created_desc = app
.get("/api/documents?sort=created_at&dir=desc", Some(&token))
.await?;
assert_eq!(created_desc.status(), StatusCode::OK);
let created_body = body_to_vec(created_desc.into_body()).await?;
let created_list: Vec<DocumentListItem> = serde_json::from_slice(&created_body)?;
assert_eq!(created_list.len(), 2);
assert_eq!(created_list[0].id, second_doc.document.id);
assert_eq!(created_list[1].id, first_doc.document.id);
// Folder contents respects the same parameters.
let folder_resp = app
.get(
"/api/folders/root/contents?sort=created_at&dir=desc",
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder_contents: FolderContents = serde_json::from_slice(&folder_body)?;
assert_eq!(folder_contents.documents.len(), 2);
assert_eq!(folder_contents.documents[0].id, second_doc.document.id);
assert_eq!(folder_contents.documents[1].id, first_doc.document.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> { async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -291,15 +376,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 +398,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 +502,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 +552,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 +566,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(
@@ -498,6 +605,81 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn filter_documents_without_tags() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "tagfilter";
app.insert_user("tagfilter", password, "admin").await?;
let token = app.login_token("tagfilter", password).await?;
// Create a tag and upload a document that uses it.
let tag_payload = CreateTagPayload {
label: "with-tag",
color: None,
};
let tag_resp = app
.post_json("/api/tags", &tag_payload, Some(&token))
.await?;
assert!(tag_resp.status().is_success());
let tag_body = body_to_vec(tag_resp.into_body()).await?;
let tag: TagResponse = serde_json::from_slice(&tag_body)?;
let tag_json = format!("[\"{}\"]", tag.id);
let tagged_upload = app
.upload_document_with_extras(
"/api/documents",
"with-tag.txt",
"text/plain",
b"tagged",
None,
UploadExtras {
title: Some("With Tag"),
metadata_json: None,
tag_ids_json: Some(tag_json.as_str()),
correspondents_json: None,
issued_at: None,
skip_existing: None,
},
&token,
)
.await?;
assert!(tagged_upload.status().is_success());
let untagged_upload = app
.upload_document(
"/api/documents",
"without-tag.txt",
"text/plain",
b"untagged",
None,
&token,
)
.await?;
assert!(untagged_upload.status().is_success());
let untagged_body = body_to_vec(untagged_upload.into_body()).await?;
let untagged_detail: DocumentDetail = serde_json::from_slice(&untagged_body)?;
// Sanity: both documents appear in the default listing.
let all_resp = app.get("/api/documents", Some(&token)).await?;
assert_eq!(all_resp.status(), StatusCode::OK);
let all_body = body_to_vec(all_resp.into_body()).await?;
let all_docs: Vec<DocumentListItem> = serde_json::from_slice(&all_body)?;
assert_eq!(all_docs.len(), 2);
// Filter for documents without tags.
let none_resp = app.get("/api/documents?tags=none", Some(&token)).await?;
assert_eq!(none_resp.status(), StatusCode::OK);
let none_body = body_to_vec(none_resp.into_body()).await?;
let none_docs: Vec<DocumentListItem> = serde_json::from_slice(&none_body)?;
assert_eq!(none_docs.len(), 1);
assert_eq!(none_docs[0].id, untagged_detail.document.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> { async fn bulk_move_documents_to_folder() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -1407,8 +1589,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 +1620,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 +1775,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 +1818,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?;
+71
View File
@@ -32,6 +32,12 @@ struct DocSummary {
id: Uuid, id: Uuid,
} }
#[derive(Deserialize)]
struct FolderTreeNodeResponse {
name: String,
children: Vec<FolderTreeNodeResponse>,
}
#[derive(Serialize)] #[derive(Serialize)]
struct CreateFolder<'a> { struct CreateFolder<'a> {
name: &'a str, name: &'a str,
@@ -144,6 +150,71 @@ async fn folder_move_and_delete_flow() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn folder_tree_lists_hierarchy() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "folderpass";
app.insert_user("folder-tree", password, "admin").await?;
let token = app.login_token("folder-tree", password).await?;
let alpha_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Alpha",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(alpha_resp.status(), StatusCode::CREATED);
let alpha_body = body_to_vec(alpha_resp.into_body()).await?;
let alpha: FolderResponse = serde_json::from_slice(&alpha_body)?;
let archive_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Archive",
parent_id: Some(alpha.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(archive_resp.status(), StatusCode::CREATED);
let beta_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Beta",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(beta_resp.status(), StatusCode::CREATED);
let tree_resp = app.get("/api/folders/tree", Some(&token)).await?;
assert_eq!(tree_resp.status(), StatusCode::OK);
let tree_body = body_to_vec(tree_resp.into_body()).await?;
let tree: Vec<FolderTreeNodeResponse> = serde_json::from_slice(&tree_body)?;
assert_eq!(tree.len(), 2);
assert_eq!(tree[0].name, "Alpha");
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].name, "Archive");
assert!(tree[0].children[0].children.is_empty());
assert_eq!(tree[1].name, "Beta");
assert!(tree[1].children.is_empty());
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn update_folder_parent_to_root() -> Result<()> { async fn update_folder_parent_to_root() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
+8 -1
View File
@@ -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)
+2 -1
View File
@@ -25,7 +25,8 @@ Documents
- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents. - POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents.
- GET /api/documents/:id - Retrieve metadata and current version details for a document. - GET /api/documents/:id - Retrieve metadata and current version details for a document.
- PATCH /api/documents/:id - Update document metadata (currently title). - PATCH /api/documents/:id - Update document metadata (currently title).
- DELETE /api/documents/:id - Soft-delete a document. - POST /api/documents/:id/trash - Move a document to trash (soft delete, reversible).
- DELETE /api/documents/:id - Permanently erase a trashed document. Returns 202 Accepted, queues a purge job, and fails with 409 if the document is still active.
- PATCH /api/documents/:id/folder - Move a document to another folder. - PATCH /api/documents/:id/folder - Move a document to another folder.
- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing. - POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing.
- GET /api/documents/:id/versions - List version history for a document. - GET /api/documents/:id/versions - List version history for a document.
+17
View File
@@ -0,0 +1,17 @@
# API response helpers
The backend now exposes `crate::http::responders`, which wraps common success and
error patterns for routes:
- `ok_json`, `created_json`, `accepted_json` return `JsonResponse<T>` with the
respective status codes.
- `no_content`/`empty` provide shared empty responses.
- `JsonResponse<T>` implements `IntoResponse`, so any handler can return
`AppResult<JsonResponse<T>>` without pairing tuples manually.
- `IntoAppResult`, `RowsAffectedExt`, and friends convert Diesel results into
`AppResult<T>` with consistent `AppError` handling.
When adding new routes, import from `crate::http::responders` instead of
constructing `(StatusCode, Json<T>)` tuples directly. The folders, documents,
auth, capability-set, correspondent, tag, and profile routers now all share
these helpers; WebDAV keeps its bespoke streaming responses for now.
+100
View File
@@ -0,0 +1,100 @@
# 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 four system capability sets per tenant:
- `owner` — contains the full set above. Tenant owners, admin users, and freshly minted API tokens effectively get unrestricted access.
- `user` — the default interactive role: full document/tag/correspondent/profile access, but no capability-set or WebDAV write privileges.
- `readonly` — interactive but read-only: document/folder/tag/correspondent reads plus WebDAV downloads, but no modifying routes.
- `webdav` — contains only `webdav:read`. WebDAV backup scripts can bind to this set for read-only access.
System sets are flagged with `is_system = true` and cannot be modified or deleted via the API.
## 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**: `POST /api/profile/api-tokens` requires a `capability_set_id`. Tokens are bound to the selected set; raw capability arrays are no longer accepted.
## 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`).