refactor(role): use grant only

- remove permission centric mode
    - finalize migration drop all tables with permissions
    - ensure roles are ENUM (owner is always displayed first)
This commit is contained in:
Edouard Vanbelle
2026-06-18 01:42:32 +02:00
parent cc6f53528b
commit 72129af0bd
26 changed files with 800 additions and 744 deletions
@@ -1388,7 +1388,7 @@ impl AuthApplicationService {
/// Visibility rule, evaluated top-to-bottom:
/// 1. **Self lookup** — `caller_id == target_id` always succeeds.
/// 2. **Shared-grant relationship** — caller and target appear
/// together on at least one row of `storage.access_grants`,
/// together on at least one row of `storage.role_grants`,
/// either direction (caller-as-granter / target-as-subject,
/// or target-as-granter / caller-as-subject). Applies to both
/// internal and external callers. This is what lets an
@@ -1454,7 +1454,7 @@ impl AuthApplicationService {
let related: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants
FROM storage.role_grants
WHERE (granted_by = $1 AND subject_type = 'user' AND subject_id = $2)
OR (granted_by = $2 AND subject_type = 'user' AND subject_id = $1)
LIMIT 1
+6 -6
View File
@@ -5,7 +5,7 @@ use tokio::sync::Semaphore;
use uuid::Uuid;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::authorization::{Resource, Role, Subject};
use crate::infrastructure::repositories::pg::SharePgRepository;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
@@ -254,9 +254,9 @@ impl ShareUseCase for ShareService {
.await
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
// Create one Read-only grant for the token subject, carrying expires_at.
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
// cleans up this grant when the share is later deleted.
// Anonymous link tokens always get the Viewer role (read-only).
// The `trg_cleanup_grants_token` trigger cleans up this grant when
// the share row is later deleted.
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
let resource = match saved_share.item_type() {
@@ -267,10 +267,10 @@ impl ShareUseCase for ShareService {
.expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
self.authorization
.grant(
.set_role(
user_id,
Subject::Token(saved_share.id()),
Permission::Read,
Role::Viewer,
resource,
expires_dt,
)
@@ -5,7 +5,7 @@
//! - Name validation runs (defence-in-depth alongside the DB CHECK).
//! - Virtual groups (e.g. `Internal`) are protected from mutation.
//! - Audit events are emitted via `tracing::info!(target = "audit", ...)`.
//! - Cascading delete of `storage.access_grants` rows referencing this
//! - Cascading delete of `storage.role_grants` rows referencing this
//! group runs in the same transaction as the group delete.
//!
//! See `migrations/20260612000000_subject_groups.sql` for the schema.
@@ -172,9 +172,9 @@ impl SubjectGroupService {
/// Delete the group; cascades to:
/// - `auth.subject_group_members` rows (FK CASCADE).
/// - `storage.access_grants` rows where `subject_type='group'` and
/// - `storage.role_grants` rows where `subject_type='group'` and
/// `subject_id = id` (handled here, no FK exists between
/// `access_grants` and `subject_groups`).
/// `role_grants` and `subject_groups`).
pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> {
let existing = self.get_by_id(id).await?;
if existing.is_virtual {
@@ -196,7 +196,7 @@ impl SubjectGroupService {
})?;
let grants_deleted = sqlx::query(
"DELETE FROM storage.access_grants
"DELETE FROM storage.role_grants
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(id)
@@ -519,9 +519,9 @@ mod integration_tests {
// ── 13. Grants are revoked atomically when a group is deleted ──────────
//
// The plan said "FK CASCADE", but there's no FK between `access_grants`
// and `subject_groups` (different schemas; the cascade is handled by the
// service's transactional DELETE). This test pins that behaviour.
// There is no FK between `storage.role_grants` and `auth.subject_groups`
// (different schemas); the cascade is handled by the service's
// transactional DELETE. This test pins that behaviour.
#[tokio::test]
async fn test_grants_revoked_when_group_deleted() {
let svc = make_service().await;
@@ -534,21 +534,21 @@ mod integration_tests {
.unwrap();
let resource_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO storage.access_grants \
"INSERT INTO storage.role_grants \
(subject_type, subject_id, resource_type, resource_id, \
permission, granted_by) \
VALUES ('group', $1, 'folder', $2, 'read', $3)",
role, granted_by) \
VALUES ('group', $1, 'folder', $2, 'viewer', $3)",
)
.bind(group.id)
.bind(resource_id)
.bind(admin)
.execute(svc.pool.as_ref())
.await
.expect("insert grant row");
.expect("insert role_grants row");
// Sanity: the grant exists.
let pre: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
"SELECT COUNT(*) FROM storage.role_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)
@@ -561,7 +561,7 @@ mod integration_tests {
svc.delete(group.id, admin).await.unwrap();
let post: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM storage.access_grants \
"SELECT COUNT(*) FROM storage.role_grants \
WHERE subject_type = 'group' AND subject_id = $1",
)
.bind(group.id)