readonly role
This commit is contained in:
@@ -55,6 +55,12 @@ user_sets AS (
|
|||||||
FROM shared.tenants
|
FROM shared.tenants
|
||||||
RETURNING id, tenant_id
|
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 (
|
webdav_sets AS (
|
||||||
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
INSERT INTO tenant.capability_sets (tenant_id, slug, is_system)
|
||||||
SELECT id, 'webdav', TRUE
|
SELECT id, 'webdav', TRUE
|
||||||
@@ -109,6 +115,16 @@ FROM (
|
|||||||
]) AS capability
|
]) AS capability
|
||||||
FROM user_sets us
|
FROM user_sets us
|
||||||
UNION ALL
|
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,
|
SELECT ws.id,
|
||||||
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
|
UNNEST(ARRAY['webdav:read'::api_capability]) AS capability
|
||||||
FROM webdav_sets ws
|
FROM webdav_sets ws
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ const USER_CAPABILITIES: [ApiCapability; 16] = [
|
|||||||
ApiCapability::WebdavRead,
|
ApiCapability::WebdavRead,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const READONLY_CAPABILITIES: [ApiCapability; 5] = [
|
||||||
|
ApiCapability::CorrespondentsRead,
|
||||||
|
ApiCapability::DocumentsRead,
|
||||||
|
ApiCapability::FoldersRead,
|
||||||
|
ApiCapability::TagsRead,
|
||||||
|
ApiCapability::WebdavRead,
|
||||||
|
];
|
||||||
|
|
||||||
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
|
const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead];
|
||||||
|
|
||||||
pub fn owner_capabilities() -> &'static [ApiCapability] {
|
pub fn owner_capabilities() -> &'static [ApiCapability] {
|
||||||
@@ -63,12 +71,16 @@ pub fn user_capabilities() -> &'static [ApiCapability] {
|
|||||||
&USER_CAPABILITIES
|
&USER_CAPABILITIES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn readonly_capabilities() -> &'static [ApiCapability] {
|
||||||
|
&READONLY_CAPABILITIES
|
||||||
|
}
|
||||||
|
|
||||||
pub fn webdav_capabilities() -> &'static [ApiCapability] {
|
pub fn webdav_capabilities() -> &'static [ApiCapability] {
|
||||||
&WEBDAV_CAPABILITIES
|
&WEBDAV_CAPABILITIES
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_system_slug(slug: &str) -> bool {
|
pub fn is_system_slug(slug: &str) -> bool {
|
||||||
matches!(slug, "owner" | "user" | "webdav")
|
matches!(slug, "owner" | "user" | "readonly" | "webdav")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_capability_set<C>(
|
pub fn create_capability_set<C>(
|
||||||
@@ -175,7 +187,7 @@ where
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
slug: slug.clone(),
|
slug: slug.clone(),
|
||||||
cap_version: 1,
|
cap_version: 1,
|
||||||
is_system: slug == "owner" || slug == "user" || slug == "webdav",
|
is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(capability_sets::table)
|
diesel::insert_into(capability_sets::table)
|
||||||
@@ -243,6 +255,10 @@ pub fn compute_slug(capabilities: &[ApiCapability]) -> String {
|
|||||||
return "user".to_string();
|
return "user".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if capabilities == readonly_capabilities() {
|
||||||
|
return "readonly".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
if capabilities == webdav_capabilities() {
|
if capabilities == webdav_capabilities() {
|
||||||
return "webdav".to_string();
|
return "webdav".to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-4
@@ -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,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use tracing::warn;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::capability_sets::{
|
use crate::auth::capability_sets::{
|
||||||
ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities,
|
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;
|
||||||
@@ -161,6 +162,19 @@ impl JobHandler for ProvisionTenantJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()) {
|
if let Err(err) = ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()) {
|
||||||
warn!(
|
warn!(
|
||||||
job_id = %job.id,
|
job_id = %job.id,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async fn capability_set_crud_flow() -> Result<()> {
|
|||||||
.map(|set| set.id)
|
.map(|set| set.id)
|
||||||
.expect("owner set present");
|
.expect("owner set present");
|
||||||
assert!(sets.iter().any(|set| set.slug == "user"));
|
assert!(sets.iter().any(|set| set.slug == "user"));
|
||||||
|
assert!(sets.iter().any(|set| set.slug == "readonly"));
|
||||||
assert!(sets.iter().any(|set| set.slug == "webdav"));
|
assert!(sets.iter().any(|set| set.slug == "webdav"));
|
||||||
|
|
||||||
// Create a new capability set.
|
// Create a new capability set.
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ 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::capability_sets::{
|
use papercrate::auth::capability_sets::{
|
||||||
ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities,
|
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
|
||||||
|
webdav_capabilities,
|
||||||
};
|
};
|
||||||
use papercrate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind};
|
use papercrate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind};
|
||||||
use papercrate::config::AppConfig;
|
use papercrate::config::AppConfig;
|
||||||
@@ -360,6 +361,8 @@ impl TestApp {
|
|||||||
.map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?;
|
.map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?;
|
||||||
ensure_capability_set(&mut conn, tenant_id, user_capabilities())
|
ensure_capability_set(&mut conn, tenant_id, user_capabilities())
|
||||||
.map_err(|err| anyhow!("ensure user capability set: {err:?}"))?;
|
.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())
|
ensure_capability_set(&mut conn, tenant_id, webdav_capabilities())
|
||||||
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
|
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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.
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ All capabilities live in the `ApiCapability` enum. The current list is:
|
|||||||
|
|
||||||
## Default Sets
|
## Default Sets
|
||||||
|
|
||||||
Provisioning (and the test harness) seed three system capability sets per tenant:
|
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.
|
- `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.
|
- `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.
|
- `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.
|
System sets are flagged with `is_system = true` and cannot be modified or deleted via the API.
|
||||||
|
|||||||
Reference in New Issue
Block a user