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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct Share {
|
||||
item_type: ShareItemType,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
|
||||
/// Derived from `storage.role_grants.expires_at` — not stored on the share row.
|
||||
expires_at: Option<u64>,
|
||||
created_at: u64,
|
||||
created_by: Uuid,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Subject groups are root-owned (no `owner_id`), globally named with an
|
||||
//! RFC 5321 local-part shape, and able to contain users *or* other groups.
|
||||
//! Grants in `storage.access_grants` with `subject_type = 'group'` reference
|
||||
//! Grants in `storage.role_grants` with `subject_type = 'group'` reference
|
||||
//! a row in `auth.subject_groups`.
|
||||
//!
|
||||
//! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the
|
||||
|
||||
@@ -93,8 +93,8 @@ pub trait SubjectGroupRepository: Send + Sync + 'static {
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Delete the group. Cascades to `subject_group_members` and to
|
||||
/// `storage.access_grants` rows referencing this group as subject (via
|
||||
/// the application service — there is no FK between `access_grants` and
|
||||
/// `storage.role_grants` rows referencing this group as subject (via
|
||||
/// the application service — there is no FK between `role_grants` and
|
||||
/// `subject_groups`, so the service performs the cascade explicitly in
|
||||
/// the same transaction).
|
||||
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! These types are storage-agnostic — they describe the relationship between
|
||||
//! a subject (who), a resource (what), and a permission (action). The
|
||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||
//! maps them to / from `storage.access_grants` rows.
|
||||
//! maps them to / from `storage.role_grants` rows.
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use std::fmt;
|
||||
@@ -49,7 +49,7 @@ impl Subject {
|
||||
/// `"external"` is no longer accepted: PR-2 of the external-users
|
||||
/// work folded the federated-identity case into `Subject::User(uuid)`
|
||||
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
|
||||
/// on `storage.access_grants.subject_type` was narrowed to match.
|
||||
/// on `storage.role_grants.subject_type` was narrowed to match.
|
||||
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
|
||||
match subject_type {
|
||||
"user" => Some(Subject::User(id)),
|
||||
@@ -200,7 +200,7 @@ impl fmt::Display for Permission {
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Grant — a row in storage.access_grants
|
||||
// Grant — a row in storage.role_grants
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -208,12 +208,132 @@ pub struct Grant {
|
||||
pub id: Uuid,
|
||||
pub subject: Subject,
|
||||
pub resource: Resource,
|
||||
pub permission: Permission,
|
||||
/// Role-keyed since D-Prep cleanup: one `Grant` represents the role
|
||||
/// row in `storage.role_grants` rather than a single permission. The
|
||||
/// engine and HTTP surface no longer carry per-permission rows;
|
||||
/// callers that need permissions use `role.expand()`.
|
||||
pub role: Role,
|
||||
pub granted_by: Uuid,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Role — a named bundle of permissions
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Roles are the load-bearing model for ReBAC grants since D-Prep. Each
|
||||
// `storage.role_grants` row stores one role; the engine expands the bundle
|
||||
// at read time via `Role::expand()`. Adding a role is two edits:
|
||||
// 1. a variant here + match arm in `expand()` / `as_str()` / `parse()`
|
||||
// 2. an `ALTER TYPE storage.grant_role ADD VALUE 'name'` migration
|
||||
//
|
||||
// `RoleDto` (DTO layer) carries the wire-format derives + the legacy
|
||||
// `"admin"` alias for backwards compat.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Role {
|
||||
Viewer,
|
||||
Commenter,
|
||||
Contributor,
|
||||
Editor,
|
||||
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`).
|
||||
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,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase discriminator — matches the SQL `role` ENUM values in
|
||||
/// `storage.role_grants` (after the `::text` cast).
|
||||
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 discriminator. Returns `None` for unknown
|
||||
/// values. The `"admin"` legacy alias is handled by `RoleDto` at the
|
||||
/// wire boundary — the database only ever stores the canonical names.
|
||||
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),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every role, in declaration order. Mirrors the `storage.grant_role`
|
||||
/// ENUM order in PG, which is weakest-to-strongest as written here for
|
||||
/// historical reasons (`storage.grant_role` declares strongest first).
|
||||
pub const ALL: [Role; 5] = [
|
||||
Role::Viewer,
|
||||
Role::Commenter,
|
||||
Role::Contributor,
|
||||
Role::Editor,
|
||||
Role::Owner,
|
||||
];
|
||||
}
|
||||
|
||||
impl fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?".
|
||||
pub fn roles_implying(permission: Permission) -> &'static [Role] {
|
||||
use Permission::*;
|
||||
match permission {
|
||||
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 Grant {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at.is_some_and(|exp| exp < chrono::Utc::now())
|
||||
@@ -225,7 +345,7 @@ impl Grant {
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Resource type without an id — used to filter paginated grant queries by
|
||||
/// type. Mirrors the `resource_type` column values in `storage.access_grants`.
|
||||
/// type. Mirrors the `resource_type` column values in `storage.role_grants`.
|
||||
/// Add new variants here when new resource types are supported.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ResourceKind {
|
||||
|
||||
@@ -38,7 +38,7 @@ impl SharePgRepository {
|
||||
|
||||
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
||||
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
|
||||
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
|
||||
/// expires_at (derived from role_grants subquery), created_at, created_by, access_count.
|
||||
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
||||
let id: Uuid = row
|
||||
.try_get("id")
|
||||
@@ -54,7 +54,7 @@ impl SharePgRepository {
|
||||
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
|
||||
})?;
|
||||
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
|
||||
// expires_at derived from access_grants subquery (unix seconds as i64)
|
||||
// expires_at derived from role_grants subquery (unix seconds as i64)
|
||||
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
|
||||
let created_at: i64 = row.try_get("created_at").map_err(|e| {
|
||||
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
|
||||
@@ -98,7 +98,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
RETURNING
|
||||
id, item_id, item_name, item_type, token, password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
|
||||
created_at, created_by, access_count
|
||||
"#,
|
||||
@@ -127,7 +127,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count
|
||||
FROM storage.shares s
|
||||
@@ -160,7 +160,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count
|
||||
FROM storage.shares s
|
||||
@@ -218,7 +218,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count
|
||||
FROM storage.shares s
|
||||
@@ -250,7 +250,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
RETURNING
|
||||
id, item_id, item_name, item_type, token, password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
|
||||
created_at, created_by, access_count
|
||||
"#,
|
||||
@@ -286,7 +286,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count,
|
||||
COUNT(*) OVER() AS total_count
|
||||
|
||||
@@ -272,8 +272,8 @@ impl SubjectGroupRepository for SubjectGroupPgRepository {
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> {
|
||||
// The application service is responsible for clearing related
|
||||
// `storage.access_grants` rows in the same transaction (there's no
|
||||
// FK between access_grants and subject_groups). The subject_group_members
|
||||
// `storage.role_grants` rows in the same transaction (there's no
|
||||
// FK between role_grants and subject_groups). The subject_group_members
|
||||
// rows cascade automatically via FK.
|
||||
let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
|
||||
.bind(id)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! PostgreSQL-backed implementation of `AuthorizationEngine`.
|
||||
//!
|
||||
//! Stores grants in `storage.access_grants` (see migration
|
||||
//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check
|
||||
//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`,
|
||||
//! using the existing GiST index for O(log N) traversal.
|
||||
//! Stores grants in `storage.role_grants` (one role per (subject, resource)
|
||||
//! pair; the role's permission bundle is expanded in code via
|
||||
//! `Role::expand()`). Cascading is resolved at check time via PostgreSQL
|
||||
//! `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, using the
|
||||
//! existing GiST index for O(log N) traversal.
|
||||
//!
|
||||
//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id`
|
||||
//! are checked first via dedicated helpers; if the caller is the owner, no
|
||||
//! SQL against `access_grants` happens.
|
||||
//! SQL against `role_grants` happens.
|
||||
//!
|
||||
//! ## Lifecycle cleanup
|
||||
//!
|
||||
@@ -37,14 +38,13 @@ use uuid::Uuid;
|
||||
use moka::future::Cache;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::application::dtos::grant_dto::roles_implying;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
|
||||
Permission, Resource, ResourceKind, Subject,
|
||||
Permission, Resource, ResourceKind, Role, Subject, roles_implying,
|
||||
};
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
@@ -206,7 +206,7 @@ impl PgAclEngine {
|
||||
}
|
||||
|
||||
/// Expand a caller's `Subject` into the `(subject_types, subject_ids)`
|
||||
/// pair that should be matched in `storage.access_grants`. For User
|
||||
/// pair that should be matched in `storage.role_grants`. For User
|
||||
/// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`;
|
||||
/// for any non-user subject (Token / External / Group as direct caller)
|
||||
/// it's a single-element pair with no cascade.
|
||||
@@ -239,8 +239,8 @@ impl PgAclEngine {
|
||||
}
|
||||
|
||||
/// Convert a `Permission` into the array of role strings whose bundle
|
||||
/// includes it — used to bind the `g.role = ANY($N::text[])` filter on
|
||||
/// every cascade / lookup query that reads `storage.role_grants`.
|
||||
/// includes it — bound as `ANY($N::storage.grant_role[])` so the
|
||||
/// ENUM-typed `role` column compares without an implicit text cast.
|
||||
///
|
||||
/// This is the inverse of `Role::expand()`, precomputed via
|
||||
/// `grant_dto::roles_implying()`. The mapping is small and static (≤5
|
||||
@@ -264,11 +264,10 @@ impl PgAclEngine {
|
||||
/// `subject_ids` is the expanded set returned by `expand_user` (or a
|
||||
/// single-element vec for non-user callers).
|
||||
///
|
||||
/// **D-Prep**: now reads `storage.role_grants` (1 row per role
|
||||
/// assignment) instead of `storage.access_grants` (N rows per role).
|
||||
/// The permission filter `g.permission = $3` becomes
|
||||
/// `g.role = ANY($3::text[])` where the array is the set of roles whose
|
||||
/// bundle includes the requested permission — see `roles_implying()`.
|
||||
/// Reads `storage.role_grants` (1 row per role assignment); a permission
|
||||
/// filter `g.permission = $3` becomes `g.role = ANY($3::storage.grant_role[])` where
|
||||
/// the array is the set of roles whose bundle includes the requested
|
||||
/// permission — see `roles_implying()`.
|
||||
///
|
||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
||||
async fn folder_cascade_grant_exists(
|
||||
@@ -288,7 +287,7 @@ impl PgAclEngine {
|
||||
JOIN storage.folders gf ON gf.id = g.resource_id
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.role = ANY($3::text[])
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
|
||||
@@ -329,7 +328,7 @@ impl PgAclEngine {
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND role = ANY($3::text[])
|
||||
AND role = ANY($3::storage.grant_role[])
|
||||
AND resource_type = 'file' AND resource_id = $4
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
UNION ALL
|
||||
@@ -340,7 +339,7 @@ impl PgAclEngine {
|
||||
JOIN storage.files target_f ON target_f.id = $4
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.role = ANY($3::text[])
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND target_f.folder_id IS NOT NULL
|
||||
@@ -361,39 +360,16 @@ impl PgAclEngine {
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// Look up a single grant by id. Returns `(resource, granted_by)` so
|
||||
/// the REST `DELETE /api/grants/{id}` handler can decide authorization
|
||||
/// without a second round-trip. Returns `Ok(None)` if no such grant.
|
||||
pub async fn find_grant_by_id(
|
||||
&self,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Resource, Uuid)>, DomainError> {
|
||||
let row: Option<(String, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1",
|
||||
)
|
||||
.bind(grant_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?;
|
||||
|
||||
let Some((rt, rid, granter)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let res = Resource::from_parts(&rt, rid)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
|
||||
Ok(Some((res, granter)))
|
||||
}
|
||||
|
||||
/// Variant of `find_grant_by_id` that also returns the subject —
|
||||
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
|
||||
/// Returns `(subject, resource, granted_by)` or `None`.
|
||||
/// Look up a single role grant by id, returning the actors a revoke /
|
||||
/// notify handler needs to make a decision without a second round-trip.
|
||||
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
|
||||
pub async fn find_grant_full_by_id(
|
||||
&self,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
|
||||
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
|
||||
FROM storage.access_grants WHERE id = $1",
|
||||
FROM storage.role_grants WHERE id = $1",
|
||||
)
|
||||
.bind(grant_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
@@ -410,8 +386,14 @@ impl PgAclEngine {
|
||||
Ok(Some((subject, resource, granter)))
|
||||
}
|
||||
|
||||
/// Row type for all full-grant SELECT queries:
|
||||
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
|
||||
/// Row type for `storage.role_grants` SELECTs:
|
||||
/// (id, subject_type, subject_id, resource_type, resource_id, role, granted_by, granted_at, expires_at).
|
||||
///
|
||||
/// Builds a single role-keyed `Grant` per row. `Grant` is role-keyed
|
||||
/// since the D-Prep cleanup PR — every listing method returns role
|
||||
/// rows directly; bundle expansion to per-permission Grants no longer
|
||||
/// happens here. Callers that need the permission set use
|
||||
/// `grant.role.expand()` at the call site.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn row_to_grant(
|
||||
row: (
|
||||
@@ -430,13 +412,13 @@ impl PgAclEngine {
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
|
||||
let resource = Resource::from_parts(&row.3, row.4)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
|
||||
let permission = Permission::parse(&row.5)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?;
|
||||
let role = Role::parse(&row.5)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown role"))?;
|
||||
Ok(Grant {
|
||||
id: row.0,
|
||||
subject,
|
||||
resource,
|
||||
permission,
|
||||
role,
|
||||
granted_by: row.6,
|
||||
granted_at: row.7,
|
||||
expires_at: row.8,
|
||||
@@ -554,15 +536,14 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
result
|
||||
}
|
||||
|
||||
async fn list_incoming_grants(
|
||||
&self,
|
||||
subject: Subject,
|
||||
permission_filter: Option<Permission>,
|
||||
) -> Result<Vec<Grant>, DomainError> {
|
||||
let perm_str = permission_filter.map(|p| p.as_str().to_string());
|
||||
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError> {
|
||||
let counters = QueryCounters::default();
|
||||
let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?;
|
||||
|
||||
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM
|
||||
// declared as `(owner, editor, contributor, commenter, viewer)`,
|
||||
// so the sort order matches the UX requirement ("Owner > Editor
|
||||
// > Contributor > Commenter > Viewer") without a per-row CASE.
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -579,18 +560,16 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
>(
|
||||
r#"
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND ($3::text IS NULL OR permission = $3)
|
||||
ORDER BY granted_at DESC
|
||||
LIMIT $4
|
||||
ORDER BY role ASC, granted_at DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(&subject_types)
|
||||
.bind(&subject_ids)
|
||||
.bind(perm_str)
|
||||
.bind(MAX_GRANT_ROWS + 1)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
@@ -621,7 +600,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// NULL otherwise. This lets every sort mode share a single query_as call.
|
||||
// 0 resource_type String
|
||||
// 1 resource_id Uuid
|
||||
// 2 permissions Vec<String>
|
||||
// 2 roles Vec<String> — every distinct role granting access to this
|
||||
// resource (post-D-Prep). Expanded to permissions
|
||||
// in `IncomingGrantSummary` via `Role::expand()`.
|
||||
// Multiple entries possible when a user has both
|
||||
// a direct grant and a group-mediated grant on
|
||||
// the same resource.
|
||||
// 3 granted_at DateTime<Utc>
|
||||
// 4 granted_by Uuid
|
||||
// 5 sort_str Option<String> — resource_name (name/type) or owner_name (granted_by)
|
||||
@@ -653,14 +637,20 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the
|
||||
// listing includes every resource the user can reach via a group
|
||||
// grant (matching what `check()` allows). See `subject_match_set`.
|
||||
//
|
||||
// Post-D-Prep this reads `storage.role_grants` and aggregates the
|
||||
// ENUM-typed `role` column into a text array. Multiple roles can
|
||||
// appear per resource when the caller reaches it via both a direct
|
||||
// grant and a group-mediated grant — the union of role bundles
|
||||
// produces the displayed permission set in Rust below.
|
||||
const AGG: &str = r#"agg AS (
|
||||
SELECT
|
||||
resource_type,
|
||||
resource_id,
|
||||
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
|
||||
array_agg(DISTINCT role::text ORDER BY role::text) AS roles,
|
||||
MIN(granted_at) AS granted_at,
|
||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
||||
FROM storage.access_grants
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||
@@ -725,7 +715,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder'
|
||||
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
|
||||
FROM named
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
@@ -765,7 +755,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
FROM agg
|
||||
LEFT JOIN auth.users u ON u.id = agg.granted_by
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
|
||||
FROM owner_named
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
@@ -793,7 +783,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG}
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
||||
SELECT resource_type, resource_id, roles, granted_at, granted_by,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
@@ -875,14 +865,21 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
};
|
||||
|
||||
// ── Convert rows to domain summaries ──────────────────────────────────
|
||||
// Post-D-Prep: the SQL aggregate produces a `roles` text array. We
|
||||
// expand each role's bundle and union them — direct grants and
|
||||
// group-mediated grants on the same resource collapse to a single
|
||||
// deduplicated permission set, matching the pre-pivot behaviour.
|
||||
let summaries = rows
|
||||
.into_iter()
|
||||
.filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| {
|
||||
.filter_map(|(rt, rid, roles_str, granted_at, granted_by, _, _)| {
|
||||
let resource_type = ResourceKind::parse(&rt)?;
|
||||
let permissions = perms_str
|
||||
let mut permissions: Vec<Permission> = roles_str
|
||||
.into_iter()
|
||||
.filter_map(|s| Permission::parse(&s))
|
||||
.filter_map(|s| Role::parse(&s))
|
||||
.flat_map(|r| r.expand().iter().copied())
|
||||
.collect();
|
||||
permissions.sort_by_key(|p| p.as_str());
|
||||
permissions.dedup();
|
||||
Some(IncomingGrantSummary {
|
||||
resource_type,
|
||||
resource_id: rid,
|
||||
@@ -897,6 +894,14 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
}
|
||||
|
||||
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
|
||||
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
|
||||
// Each role row expands to N permission-keyed `Grant` rows via
|
||||
// `role_row_to_grants` until the public `Grant` shape becomes
|
||||
// role-keyed.
|
||||
//
|
||||
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM's
|
||||
// declaration order (owner first → viewer last) so the share
|
||||
// dialog's "who has access" list shows strongest grants on top.
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -913,11 +918,11 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
>(
|
||||
r#"
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
FROM storage.role_grants
|
||||
WHERE resource_type = $1
|
||||
AND resource_id = $2
|
||||
ORDER BY granted_at DESC
|
||||
ORDER BY role ASC, granted_at DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
@@ -942,7 +947,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError> {
|
||||
let fetch_limit = (limit as i64) + 1;
|
||||
|
||||
// Row shape — one row per (resource, subject, permission).
|
||||
// Row shape — post-D-Prep, one row per (resource, subject) since
|
||||
// `storage.role_grants` carries exactly one role per pair (UNIQUE
|
||||
// constraint). Permission bundles are expanded in the row consumer
|
||||
// via `Role::expand()`.
|
||||
// Columns:
|
||||
// 0 resource_type String
|
||||
// 1 resource_id Uuid
|
||||
@@ -951,9 +959,9 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// 4 subject_id Uuid
|
||||
// 5 subject_display String — username or share item_name
|
||||
// 6 grant_id Uuid
|
||||
// 7 granted_at DateTime<Utc> — this (subject, perm) row
|
||||
// 7 granted_at DateTime<Utc> — this (subject, role) row
|
||||
// 8 expires_at Option<DateTime<Utc>>
|
||||
// 9 permission String
|
||||
// 9 role String — `grant_role` ENUM as text
|
||||
// 10 sort_str Option<String>
|
||||
// 11 sort_int Option<i64>
|
||||
// 12 has_password bool — token: shares.password_hash IS NOT NULL
|
||||
@@ -1040,7 +1048,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
CASE WHEN ag.resource_type = 'file' THEN fi.name END
|
||||
) AS sort_str,
|
||||
{sort_int_expr} AS sort_int
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder'
|
||||
LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file'
|
||||
WHERE ag.granted_by = $1
|
||||
@@ -1055,12 +1063,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
|
||||
rp.sort_str, rp.sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
AND ag.granted_by = $1
|
||||
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
@@ -1129,7 +1137,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ELSE 3
|
||||
END)::bigint AS sort_int,
|
||||
MIN(ag.granted_at) AS first_granted_at
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
LEFT JOIN auth.users u
|
||||
ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
LEFT JOIN auth.subject_groups sg
|
||||
@@ -1160,13 +1168,13 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.id AS grant_id,
|
||||
ag.granted_at,
|
||||
ag.expires_at,
|
||||
ag.permission,
|
||||
ag.role::text AS role,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
AND ag.resource_id = rp.resource_id
|
||||
AND ag.subject_type = rp.subject_type
|
||||
@@ -1180,7 +1188,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// Page on (role_order, subject_display, resource_id) triples so that all
|
||||
// of one person's grants within a role are contiguous — enabling aggregation
|
||||
// ("Bob on Folder A, Folder B") to work correctly across cursor pages.
|
||||
// role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer
|
||||
//
|
||||
// role_order matches the `storage.grant_role` ENUM declaration
|
||||
// order (strongest first) via `array_position`, so
|
||||
// `sort_int ASC` matches the UX requirement: 1 = owner,
|
||||
// 2 = editor, 3 = contributor, 4 = commenter, 5 = viewer.
|
||||
// 1-based because `array_position` is.
|
||||
// Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id
|
||||
let (page_where, page_order) = if reverse {
|
||||
(
|
||||
@@ -1209,15 +1222,20 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
|
||||
CASE
|
||||
WHEN BOOL_OR(ag.permission = 'delete')
|
||||
AND BOOL_OR(ag.permission = 'share') THEN 0
|
||||
WHEN BOOL_OR(ag.permission = 'create')
|
||||
OR BOOL_OR(ag.permission = 'update') THEN 1
|
||||
ELSE 2
|
||||
END::bigint AS sort_int,
|
||||
-- One role per (resource, subject) post-D-Prep
|
||||
-- (UNIQUE constraint on role_grants), so MAX
|
||||
-- returns that single row's role. `array_position`
|
||||
-- against the ENUM's declaration order produces a
|
||||
-- 1-based rank: owner=1 → viewer=5. Strength
|
||||
-- ordering tracks the ENUM declaration — adding
|
||||
-- a new role between owner and viewer doesn't
|
||||
-- need a parallel CASE update here.
|
||||
array_position(
|
||||
enum_range(NULL::storage.grant_role),
|
||||
MAX(ag.role)
|
||||
)::bigint AS sort_int,
|
||||
MIN(ag.granted_at) AS first_granted_at
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
LEFT JOIN auth.users u
|
||||
ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
LEFT JOIN storage.shares sh
|
||||
@@ -1246,13 +1264,13 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.id AS grant_id,
|
||||
ag.granted_at,
|
||||
ag.expires_at,
|
||||
ag.permission,
|
||||
ag.role::text AS role,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
AND ag.resource_id = rp.resource_id
|
||||
AND ag.subject_type = rp.subject_type
|
||||
@@ -1284,7 +1302,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM storage.access_grants
|
||||
FROM storage.role_grants
|
||||
WHERE granted_by = $1
|
||||
GROUP BY resource_type, resource_id
|
||||
),
|
||||
@@ -1297,12 +1315,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
|
||||
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
AND ag.granted_by = $1
|
||||
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
@@ -1380,7 +1398,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
grant_id,
|
||||
granted_at,
|
||||
expires_at,
|
||||
perm_str,
|
||||
role_str,
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
@@ -1389,7 +1407,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(perm) = Permission::parse(&perm_str) else {
|
||||
let Some(role) = Role::parse(&role_str) else {
|
||||
continue;
|
||||
};
|
||||
let key = (resource_id, subj_id);
|
||||
@@ -1409,8 +1427,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
},
|
||||
)
|
||||
});
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
for &perm in role.expand() {
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1498,7 +1518,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
grant_id,
|
||||
granted_at,
|
||||
expires_at,
|
||||
perm_str,
|
||||
role_str,
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
@@ -1507,7 +1527,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(perm) = Permission::parse(&perm_str) else {
|
||||
let Some(role) = Role::parse(&role_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -1531,8 +1551,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
has_password,
|
||||
is_external,
|
||||
});
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
for &perm in role.expand() {
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1578,6 +1600,11 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
}
|
||||
|
||||
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
|
||||
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
|
||||
// Group membership doesn't apply on the outgoing side — we
|
||||
// filter by `granted_by` directly. Bundle expansion still
|
||||
// happens at read time via `role_row_to_grants` until the
|
||||
// public `Grant` shape becomes role-keyed.
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -1594,10 +1621,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
>(
|
||||
r#"
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
FROM storage.role_grants
|
||||
WHERE granted_by = $1
|
||||
ORDER BY granted_at DESC
|
||||
ORDER BY role ASC, granted_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(granted_by)
|
||||
@@ -1608,11 +1635,68 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
rows.into_iter().map(Self::row_to_grant).collect()
|
||||
}
|
||||
|
||||
async fn grant(
|
||||
async fn set_expiry_for_subject(
|
||||
&self,
|
||||
subject: Subject,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.role_grants SET expires_at = $3 \
|
||||
WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(expires_at)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM storage.role_grants WHERE id = $1")
|
||||
.bind(grant_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2",
|
||||
)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
// ── D-Prep role_grants writes ──────────────────────────────────────────
|
||||
|
||||
async fn set_role(
|
||||
&self,
|
||||
granted_by: Uuid,
|
||||
subject: Subject,
|
||||
permission: Permission,
|
||||
role: Role,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<Grant, DomainError> {
|
||||
@@ -1630,149 +1714,17 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO storage.access_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
|
||||
DO UPDATE SET expires_at = EXCLUDED.expires_at
|
||||
RETURNING id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.bind(permission.as_str())
|
||||
.bind(granted_by)
|
||||
.bind(expires_at)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
|
||||
|
||||
Self::row_to_grant(row)
|
||||
}
|
||||
|
||||
async fn set_expiry_for_subject(
|
||||
&self,
|
||||
subject: Subject,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(expires_at)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_expiry_on_resource(
|
||||
&self,
|
||||
subject: Subject,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.access_grants SET expires_at = $3 \
|
||||
WHERE subject_type = $1 AND subject_id = $2 \
|
||||
AND resource_type = $4 AND resource_id = $5",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(expires_at)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
|
||||
.bind(grant_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
|
||||
// D-Prep dual-write: wipe role_grants for this resource too.
|
||||
// Idempotent — succeeds whether or not any row existed.
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2",
|
||||
)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("revoke role_grants for resource: {e}"))
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2",
|
||||
)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
|
||||
// D-Prep dual-write: wipe role_grants for this subject too.
|
||||
sqlx::query("DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2")
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("revoke role_grants for subject: {e}"))
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
// ── D-Prep role_grants writes ──────────────────────────────────────────
|
||||
|
||||
async fn set_role(
|
||||
&self,
|
||||
granted_by: Uuid,
|
||||
subject: Subject,
|
||||
role: crate::application::dtos::grant_dto::Role,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id,
|
||||
role, granted_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6, $7)
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
|
||||
DO UPDATE SET role = EXCLUDED.role,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
granted_by = EXCLUDED.granted_by
|
||||
RETURNING id, subject_type, subject_id, resource_type, resource_id,
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
@@ -1782,11 +1734,11 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
.bind(role.as_str())
|
||||
.bind(granted_by)
|
||||
.bind(expires_at)
|
||||
.execute(self.pool.as_ref())
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_role: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
Self::row_to_grant(row)
|
||||
}
|
||||
|
||||
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError> {
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn test_db_url() -> String {
|
||||
/// OnceCell so concurrent test threads block until the first caller
|
||||
/// finishes; subsequent calls are zero-cost.
|
||||
///
|
||||
/// Order matters: `storage.access_grants` rows go first because there's
|
||||
/// Order matters: `storage.role_grants` rows go first because there's
|
||||
/// no FK from there to `auth.subject_groups` (the service's `delete`
|
||||
/// path does this transactionally; here we bypass the service).
|
||||
static CLEANUP_ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
|
||||
@@ -51,7 +51,7 @@ pub async fn ensure_clean_test_db(pool: &PgPool) {
|
||||
CLEANUP_ONCE
|
||||
.get_or_init(|| async {
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM storage.access_grants
|
||||
"DELETE FROM storage.role_grants
|
||||
WHERE subject_type = 'group'
|
||||
AND subject_id IN (
|
||||
SELECT id FROM auth.subject_groups WHERE name LIKE 'rust-test-%'
|
||||
|
||||
@@ -21,9 +21,9 @@ use uuid::Uuid;
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
|
||||
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
|
||||
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
|
||||
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||||
OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto,
|
||||
ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto,
|
||||
SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -35,7 +35,7 @@ use crate::common::errors::DomainError;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::domain::services::authorization::{
|
||||
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
||||
Subject,
|
||||
Role, Subject,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
@@ -66,41 +66,7 @@ pub async fn create_grant(
|
||||
let authz = &state.authorization;
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
// Validate: exactly one of permissions/role. Capture BOTH the
|
||||
// permission list (for the per-permission access_grants writes that
|
||||
// keep the old engine read path working) AND the role (for the new
|
||||
// role_grants `set_role` dual-write that lands after the per-
|
||||
// permission loop).
|
||||
let (permissions, role): (Vec<Permission>, Role) = match (dto.permissions, dto.role) {
|
||||
(Some(perms), None) if !perms.is_empty() => {
|
||||
let perms: Vec<Permission> = perms.into_iter().map(Into::into).collect();
|
||||
// Derive the closest matching role from the raw permission set
|
||||
// so we have ONE role to mirror into role_grants. `Role::parse`
|
||||
// always succeeds here because `role_from_permissions` only
|
||||
// emits known role strings.
|
||||
let role = Role::parse(role_from_permissions(&perms))
|
||||
.expect("role_from_permissions returns a known role string");
|
||||
(perms, role)
|
||||
}
|
||||
(None, Some(role)) => (role.expand().to_vec(), role),
|
||||
(Some(_), Some(_)) => {
|
||||
return AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provide either 'permissions' or 'role', not both",
|
||||
"InvalidInput",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {
|
||||
return AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Either 'permissions' (non-empty) or 'role' is required",
|
||||
"InvalidInput",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let role: Role = dto.role.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let expires_at = dto.expires_at;
|
||||
|
||||
@@ -165,41 +131,20 @@ pub async fn create_grant(
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
// `storage.access_grants.permission` CHECK constraint predates
|
||||
// `Permission::Manage`; it accepts only the original 6 values.
|
||||
// Manage exists in the Owner bundle for engine read-path use
|
||||
// (via `roles_implying`) and gets persisted via the role_grants
|
||||
// dual-write below. Skipping it here keeps the access_grants
|
||||
// safety net populated without tripping the CHECK; the cleanup
|
||||
// PR that drops access_grants also drops this skip.
|
||||
if perm == Permission::Manage {
|
||||
continue;
|
||||
}
|
||||
match authz
|
||||
.grant(caller_id, subject, perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(grant) => results.push(grant.into()),
|
||||
Err(err) => {
|
||||
error!("grant insert failed for {perm:?}: {err}");
|
||||
return AppError::from(err).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D-Prep dual-write: mirror the role assignment into storage.role_grants.
|
||||
// ON CONFLICT UPDATE makes this idempotent — repeated POSTs with the
|
||||
// same (subject, resource) update the role in place, matching the
|
||||
// PATCH-style semantics callers will get after the engine read pivot.
|
||||
if let Err(err) = authz
|
||||
// Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in
|
||||
// the engine makes repeated POSTs with the same (subject, resource)
|
||||
// a role refresh, matching the PATCH-style semantics callers expect.
|
||||
let grant = match authz
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
error!("set_role dual-write failed: {err}");
|
||||
return AppError::from(err).into_response();
|
||||
}
|
||||
Ok(g) => g,
|
||||
Err(err) => {
|
||||
error!("set_role write failed: {err}");
|
||||
return AppError::from(err).into_response();
|
||||
}
|
||||
};
|
||||
let grants = vec![GrantDto::from(grant)];
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
@@ -210,7 +155,6 @@ pub async fn create_grant(
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
role = role.as_str(),
|
||||
permission_count = results.len(),
|
||||
expires_at = ?expires_at,
|
||||
"🤝 grant created with role '{}'", role.as_str(),
|
||||
);
|
||||
@@ -282,7 +226,7 @@ pub async fn create_grant(
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(CreateGrantResponseDto {
|
||||
grants: results,
|
||||
grants,
|
||||
notification,
|
||||
}),
|
||||
)
|
||||
@@ -560,9 +504,8 @@ pub async fn set_role(
|
||||
let caller_id = auth_user.id;
|
||||
let subject: Subject = dto.subject.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let role: Role = dto.role.into();
|
||||
let expires_at = dto.expires_at;
|
||||
let target_perms: std::collections::HashSet<Permission> =
|
||||
dto.role.expand().iter().copied().collect();
|
||||
|
||||
// Caller must have Share on the resource.
|
||||
if let Err(e) = authz
|
||||
@@ -572,86 +515,16 @@ pub async fn set_role(
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Fetch current grants on the resource for this subject.
|
||||
let current = match authz.list_grants_on_resource(resource).await {
|
||||
// Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT
|
||||
// UPDATE in `set_role` turns this into a single UPSERT — no diff,
|
||||
// no race window. Returns the resulting role row.
|
||||
let grant = match authz
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(g) => g,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
let current_perms: std::collections::HashSet<Permission> = current
|
||||
.iter()
|
||||
.filter(|g| g.subject == subject)
|
||||
.map(|g| g.permission)
|
||||
.collect();
|
||||
|
||||
// Diff and apply. `Permission::Manage` is excluded from both sides
|
||||
// because the historical `access_grants.permission` CHECK doesn't
|
||||
// accept it — see the matching skip in `create_grant`. The role
|
||||
// assignment captures Manage via the role_grants `set_role` call
|
||||
// further down; the engine's read-path uses `roles_implying(Manage)`
|
||||
// → `[Owner]` and never goes through per-permission rows.
|
||||
let to_add: Vec<Permission> = target_perms
|
||||
.difference(¤t_perms)
|
||||
.copied()
|
||||
.filter(|p| *p != Permission::Manage)
|
||||
.collect();
|
||||
let to_remove: Vec<Permission> = current_perms
|
||||
.difference(&target_perms)
|
||||
.copied()
|
||||
.filter(|p| *p != Permission::Manage)
|
||||
.collect();
|
||||
|
||||
for perm in &to_remove {
|
||||
if let Some(g) = current
|
||||
.iter()
|
||||
.find(|g| g.subject == subject && g.permission == *perm)
|
||||
&& let Err(e) = authz.revoke(g.id).await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
for perm in &to_add {
|
||||
if let Err(e) = authz
|
||||
.grant(caller_id, subject, *perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync expiry on all remaining grants for this (subject, resource) pair —
|
||||
// includes newly added ones and any that were already present (retained).
|
||||
// Callers that omit expires_at will clear any existing expiry; this is
|
||||
// intentional: it keeps all permission rows for the pair consistent.
|
||||
if let Err(e) = authz
|
||||
.set_expiry_on_resource(subject, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// D-Prep dual-write: mirror the resulting role into storage.role_grants.
|
||||
// The per-permission diff above keeps access_grants converged; this
|
||||
// single UPSERT keeps role_grants in sync with the OVERALL outcome
|
||||
// (one row carrying the role + expiry). After the engine read pivot
|
||||
// and the access_grants drop, the per-permission diff above goes
|
||||
// away and this call becomes the only mutation the handler performs.
|
||||
if let Err(e) = authz
|
||||
.set_role(caller_id, subject, dto.role, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Return the new full set.
|
||||
let after = match authz.list_grants_on_resource(resource).await {
|
||||
Ok(g) => g,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
let mine: Vec<GrantDto> = after
|
||||
.into_iter()
|
||||
.filter(|g| g.subject == subject)
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
@@ -661,34 +534,22 @@ pub async fn set_role(
|
||||
subject_id = %subject.id(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
role = dto.role.as_str(),
|
||||
permissions_added = to_add.len(),
|
||||
permissions_removed = to_remove.len(),
|
||||
role = role.as_str(),
|
||||
expires_at = ?expires_at,
|
||||
"🔁 role set to '{}' (+{} -{})",
|
||||
dto.role.as_str(),
|
||||
to_add.len(),
|
||||
to_remove.len(),
|
||||
"🔁 role set to '{}'", role.as_str(),
|
||||
);
|
||||
(StatusCode::OK, Json(mine)).into_response()
|
||||
(StatusCode::OK, Json(vec![GrantDto::from(grant)])).into_response()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/incoming
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct IncomingQuery {
|
||||
#[serde(default)]
|
||||
pub permission: Option<PermissionDto>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/incoming",
|
||||
params(IncomingQuery),
|
||||
responses(
|
||||
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
|
||||
(status = 200, description = "Direct role grants targeting the caller", body = Vec<GrantDto>),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "grants"
|
||||
@@ -696,12 +557,11 @@ pub struct IncomingQuery {
|
||||
pub async fn list_incoming(
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<IncomingQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
match state
|
||||
.authorization
|
||||
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
||||
.list_incoming_grants(Subject::User(caller_id))
|
||||
.await
|
||||
{
|
||||
Ok(grants) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::application::dtos::folder_dto::{
|
||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
|
||||
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
|
||||
ResourceDto, ResourceTypeDto, RoleDto, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
|
||||
SubjectTypeDto, UpdateRoleDto,
|
||||
};
|
||||
use crate::application::dtos::i18n_dto::{
|
||||
@@ -351,7 +351,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
ResourceTypeDto,
|
||||
ResourceDto,
|
||||
PermissionDto,
|
||||
Role,
|
||||
RoleDto,
|
||||
CreateGrantDto,
|
||||
UpdateRoleDto,
|
||||
GrantDto,
|
||||
|
||||
Reference in New Issue
Block a user