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
+45 -157
View File
@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
// ════════════════════════════════════════════════════════════════════════════
// Subject / Resource / Permission DTOs
@@ -130,166 +130,50 @@ impl From<Permission> for PermissionDto {
// Roles — the load-bearing model for ReBAC grants
// ════════════════════════════════════════════════════════════════════════════
//
// Today a role is "DTO-layer sugar" — every grant write expands a role into
// N rows in `storage.access_grants`. The D-Prep refactor (see
// `docs/plan/drive.md` §Prerequisite + migration `20260730000000_role_grants.sql`)
// pushes the role down to storage (`storage.role_grants.role TEXT`); the
// engine reads the role and expands the bundle at query time via this same
// `expand()` function. Adding a role is now schema-free — one variant + one
// match arm here.
// One row per role assignment in `storage.role_grants.role` (a
// `storage.grant_role` ENUM). The engine expands the bundle at query time
// via `Role::expand()` on the domain enum. Adding a role is two edits:
// the variant + match arm on `Role`, and an `ALTER TYPE
// storage.grant_role ADD VALUE 'name'` migration.
/// Wire-format wrapper around the domain `Role` enum. Carries the
/// serde/utoipa derives. Maps 1:1 to/from `Role` via `From`.
///
/// The historical `"admin"` alias for `Owner` (used during the D-Prep
/// dual-write window for cached clients) has been retired in the cleanup
/// PR — the OxiCloud UI emits `"owner"` exclusively. Stragglers receive
/// a 422 on POST/PUT, which surfaces the upgrade cleanly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// Read access only. The default "anyone can look but not touch".
pub enum RoleDto {
Viewer,
/// Read + comment. Useful for review-only stakeholders.
Commenter,
/// Read + create. The "drop-zone" role — uploads allowed, existing
/// content untouchable. Common for support-ticket attachments and
/// photo-submission folders.
Contributor,
/// Read + create + update + comment. The standard collaboration role.
Editor,
/// Full bundle: read, create, update, comment, delete, share — and
/// `Manage` for resource types that support it (drives, groups). This
/// is the highest user-grantable role; renamed from the historical
/// `Admin` to disambiguate from `UserRole::Admin` (the user-account
/// privilege) and to match Drive plan terminology.
///
/// **Wire-format compat shim** (one release): also deserialises from
/// the legacy `"admin"` string so cached frontend clients keep
/// working until they refresh. Serialisation always emits `"owner"`.
/// Drop the alias in the cleanup PR.
#[serde(alias = "admin")]
Owner,
}
impl Role {
/// Expand the role into its permission bundle. Single source of truth —
/// any code that needs "does this role include Permission X?" routes
/// through here (or its inverse, `roles_implying`).
///
/// After D-Prep this is called at engine read time (1 row → bundle
/// expanded server-side). Pre-D-Prep it was called at API write time
/// (1 role → N rows fanned out).
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Contributor => &[Permission::Read, Permission::Create],
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
Role::Owner => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
Permission::Delete,
Permission::Manage,
],
impl From<RoleDto> for Role {
fn from(r: RoleDto) -> Self {
match r {
RoleDto::Viewer => Role::Viewer,
RoleDto::Commenter => Role::Commenter,
RoleDto::Contributor => Role::Contributor,
RoleDto::Editor => Role::Editor,
RoleDto::Owner => Role::Owner,
}
}
/// Lowercase string discriminator — matches the SQL `role` column values
/// in `storage.role_grants` and the JSON wire format.
pub fn as_str(self) -> &'static str {
match self {
Role::Viewer => "viewer",
Role::Commenter => "commenter",
Role::Contributor => "contributor",
Role::Editor => "editor",
Role::Owner => "owner",
}
}
/// Parse a role from its SQL / JSON string discriminator. Returns
/// `None` for unknown values. Accepts the legacy `"admin"` spelling
/// for one release of API compat (clients that cached the old name
/// keep working; new responses always emit `"owner"`).
pub fn parse(s: &str) -> Option<Self> {
match s {
"viewer" => Some(Role::Viewer),
"commenter" => Some(Role::Commenter),
"contributor" => Some(Role::Contributor),
"editor" => Some(Role::Editor),
"owner" => Some(Role::Owner),
// Legacy compat: drop after one release once all clients are
// updated. Emits a debug log so we can track stragglers.
"admin" => {
tracing::debug!(
target: "oxicloud::grants",
"Role::parse: accepted legacy 'admin' string as Role::Owner"
);
Some(Role::Owner)
}
_ => None,
}
}
/// Every role, in a stable order. Used by `roles_implying` and exposed
/// to the UI so the share modal can render the full picker without
/// hardcoding the list.
///
/// **UI scope today**: the share dialog renders only `Viewer`,
/// `Editor`, and `Owner` (matches the existing 3-button UX). The
/// `Commenter` and `Contributor` variants are implemented server-
/// side and accepted on the API surface, reserved for future UI
/// exposure when a real use case asks for them. Until then they
/// stay invisible to end users — no picker option, no documentation
/// surface.
///
/// Any role can be granted on any resource type. Permission bundles
/// that include capabilities the resource type doesn't check for
/// (e.g. `Manage` on a folder, `Create` on a file) simply produce
/// harmless no-ops — no separate validation layer is needed.
pub const ALL: [Role; 5] = [
Role::Viewer,
Role::Commenter,
Role::Contributor,
Role::Editor,
Role::Owner,
];
}
/// Inverse of [`Role::expand`]: returns every role whose bundle contains
/// the given permission. Used by the engine to build the SQL
/// `WHERE role IN (...)` filter on hot-path queries like "what drives can
/// this caller read?":
///
/// ```ignore
/// SELECT resource_id FROM role_grants
/// WHERE subject_id = $1
/// AND resource_type = 'drive'
/// AND role IN (roles_implying(Permission::Read));
/// ```
///
/// Precomputed in code rather than stored in the DB — `Role` and
/// `Permission` are both small fixed enums, the table can never grow
/// beyond a handful of rows, and keeping it in-code makes "what changes
/// when I add a Permission?" a single grep target.
pub fn roles_implying(permission: Permission) -> &'static [Role] {
use Permission::*;
match permission {
// Every role grants Read — viewer is the floor.
Read => &[
Role::Viewer,
Role::Commenter,
Role::Contributor,
Role::Editor,
Role::Owner,
],
Comment => &[Role::Commenter, Role::Editor, Role::Owner],
Create => &[Role::Contributor, Role::Editor, Role::Owner],
Update => &[Role::Editor, Role::Owner],
Delete => &[Role::Owner],
Share => &[Role::Owner],
Manage => &[Role::Owner],
impl From<Role> for RoleDto {
fn from(r: Role) -> Self {
match r {
Role::Viewer => RoleDto::Viewer,
Role::Commenter => RoleDto::Commenter,
Role::Contributor => RoleDto::Contributor,
Role::Editor => RoleDto::Editor,
Role::Owner => RoleDto::Owner,
}
}
}
@@ -325,17 +209,18 @@ pub enum SubjectInputDto {
},
}
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
/// `POST /api/grants` — create or refresh a role assignment.
///
/// Strictly role-keyed since the cleanup PR: callers send exactly one
/// role; the engine writes a single row in `storage.role_grants`. The
/// historical per-permission shape (`permissions: [...]`) was dropped —
/// the OxiCloud UI is the only known caller and it already sends `role`.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectInputDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
pub role: RoleDto,
/// Optional expiry for the grant. RFC 3339 / ISO 8601.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
@@ -345,7 +230,7 @@ pub struct CreateGrantDto {
pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
pub role: RoleDto,
/// Optional expiry applied to every grant written or updated by this call.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
@@ -360,7 +245,10 @@ pub struct GrantDto {
pub id: Uuid,
pub subject: SubjectDto,
pub resource: ResourceDto,
pub permission: PermissionDto,
/// Role-keyed since D-Prep cleanup — one row in `storage.role_grants`
/// is one `GrantDto`. The bundle of underlying permissions is implied
/// by the role and recomputed client-side from the same lookup table.
pub role: RoleDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -373,7 +261,7 @@ impl From<Grant> for GrantDto {
id: g.id,
subject: g.subject.into(),
resource: g.resource.into(),
permission: g.permission.into(),
role: g.role.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
expires_at: g.expires_at,
+16 -48
View File
@@ -13,7 +13,7 @@ use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
ResourceKind, Subject,
ResourceKind, Role, Subject,
};
pub trait AuthorizationEngine: Send + Sync + 'static {
@@ -90,11 +90,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// Resources explicitly granted to `subject`. Direct grants only — no
/// cascade expansion. Used by `GET /api/grants/incoming`.
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError>;
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError>;
/// Cursor-paginated list of resources explicitly granted to `subject`,
/// optionally filtered by resource kind. Multiple permission rows for the
@@ -139,38 +135,20 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
reverse: bool,
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint; if the row already exists its `expires_at` is updated.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Grant, DomainError>;
/// Update `expires_at` on every grant row for the given subject.
/// Used when a share's expiry is changed — one call updates all
/// permission rows for that token in a single UPDATE.
/// Update `expires_at` for every role grant belonging to `subject`.
/// Used by `share_service` when a token-share's expiry is refreshed —
/// the subject (token) maps to a small fixed set of role grants, so a
/// single UPDATE covers them. Resource-scoped expiry changes go through
/// `set_role` (which carries `expires_at` as part of its UPSERT).
async fn set_expiry_for_subject(
&self,
subject: Subject,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Update `expires_at` on every grant row for the given `(subject, resource)`
/// pair. Used by `set_role` to sync the expiry of retained grants when the
/// caller changes expiry without changing permissions.
async fn set_expiry_on_resource(
&self,
subject: Subject,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
/// the row existed (idempotent revoke).
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
/// whether or not the row existed. The id comes from a prior listing
/// or `find_grant_full_by_id` lookup.
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
/// Removes every grant whose `resource` matches. Called by lifecycle
@@ -182,20 +160,10 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// /group is deleted. Returns the count of rows removed.
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
// ── Role-keyed grant operations (D-Prep dual-write) ────────────────────
// These manage `storage.role_grants`, the role-keyed table introduced
// by the D-Prep refactor (see `docs/plan/drive.md` §Prerequisite).
//
// During the dual-write window both tables stay populated; the engine
// reads from `access_grants` until the read-path pivot lands. After
// the cleanup PR (which drops `access_grants`), these two methods
// become the ONLY grant write path — the per-permission `grant` /
// `revoke` above are removed at that point.
//
// The handler layer drives these (it knows the Role); lifecycle hook
// bulk-deletes (`revoke_all_for_*` above) wipe role_grants in lockstep
// inside their own implementation, so callers using those paths don't
// need to invoke `clear_role` separately.
// ── Role-keyed grant operations ────────────────────────────────────────
// These are the only grant write path. Lifecycle hook bulk-deletes
// (`revoke_all_for_*` above) wipe matching rows directly, so callers
// using those paths don't need to invoke `clear_role` separately.
/// Set the role for a `(subject, resource)` pair. Idempotent via the
/// UNIQUE `(subject_type, subject_id, resource_type, resource_id)`
@@ -207,10 +175,10 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
&self,
granted_by: Uuid,
subject: Subject,
role: crate::application::dtos::grant_dto::Role,
role: Role,
resource: Resource,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<(), DomainError>;
) -> Result<Grant, DomainError>;
/// Remove the role for a `(subject, resource)` pair. Idempotent —
/// succeeds whether or not the row existed. Called after `revoke`
@@ -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)