feat(drive): tell UI the caller role per Drive

This commit is contained in:
Edouard Vanbelle
2026-06-23 00:09:12 +02:00
parent a82bae4136
commit 2fe9c9ffeb
3 changed files with 63 additions and 2 deletions
+20
View File
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::application::dtos::grant_dto::RoleDto;
use crate::domain::entities::drive::DriveKind; use crate::domain::entities::drive::DriveKind;
use crate::domain::repositories::drive_repository::DriveWithRootName; use crate::domain::repositories::drive_repository::DriveWithRootName;
@@ -62,6 +63,24 @@ pub struct DriveDto {
pub policies: serde_json::Value, pub policies: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
/// Highest role the **calling** user holds on this drive (direct OR
/// group-mediated). Populated by `GET /api/drives` and the drive
/// grants in `/api/grants/incoming/resources`. Omitted (`None`) in
/// contexts where the caller isn't naturally a member — e.g. the
/// outgoing-grants listing where the caller is the granter and may
/// not have a current role on the drive themselves.
///
/// **UI gating**:
/// - `Some(Owner)` → mutation controls (add/edit/remove members, change policies, delete drive)
/// - `Some(Editor/Contributor/Commenter)` → mutate drive contents, no membership UI
/// - `Some(Viewer)` → read-only
/// - `None` → show neither member list nor mutation controls
///
/// See `project_caller_role_on_file_folder_dto` memory for the
/// pattern extension to FileDto / FolderDto (deferred, perf-sensitive).
#[serde(skip_serializing_if = "Option::is_none")]
pub caller_role: Option<RoleDto>,
} }
impl From<DriveWithRootName> for DriveDto { impl From<DriveWithRootName> for DriveDto {
@@ -77,6 +96,7 @@ impl From<DriveWithRootName> for DriveDto {
policies: d.drive.policies, policies: d.drive.policies,
created_at: d.drive.created_at, created_at: d.drive.created_at,
updated_at: d.drive.updated_at, updated_at: d.drive.updated_at,
caller_role: d.caller_role.map(RoleDto::from),
} }
} }
} }
@@ -51,6 +51,17 @@ pub struct DriveWithRootName {
/// The drive's display name. Sourced from `storage.folders.name` /// The drive's display name. Sourced from `storage.folders.name`
/// of the root folder via JOIN at read time. /// of the root folder via JOIN at read time.
pub root_folder_name: String, pub root_folder_name: String,
/// Highest role the calling user holds on this drive (direct OR
/// group-mediated). Populated by `list_for_subjects` (which already
/// JOINs `role_grants` for accessibility, so the role is in scope at
/// query time). `None` for repo methods called without a caller
/// context (`get_by_id`, `get_by_ids`, `find_default_for_user`,
/// `create_personal_drive_atomic`) — the DTO layer omits the field
/// via `#[serde(skip_serializing_if = "Option::is_none")]`.
///
/// See [[project-caller-role-on-file-folder-dto]] for the pattern
/// extension to FileDto/FolderDto with a perf warning.
pub caller_role: Option<crate::domain::services::authorization::Role>,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -44,6 +44,9 @@ impl DrivePgRepository {
/// Map a row carrying both the drive's columns AND a `root_folder_name` /// Map a row carrying both the drive's columns AND a `root_folder_name`
/// column (sourced via JOIN with `storage.folders`) into the view-model. /// column (sourced via JOIN with `storage.folders`) into the view-model.
/// `caller_role` is left `None` — only the listing path (which
/// JOINs `role_grants` for accessibility) has it in scope; see
/// `row_to_drive_with_name_and_role`.
fn row_to_drive_with_name( fn row_to_drive_with_name(
row: &sqlx::postgres::PgRow, row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> { ) -> Result<DriveWithRootName, DriveRepositoryError> {
@@ -63,8 +66,25 @@ impl DrivePgRepository {
Ok(DriveWithRootName { Ok(DriveWithRootName {
drive, drive,
root_folder_name: row.get("root_folder_name"), root_folder_name: row.get("root_folder_name"),
caller_role: None,
}) })
} }
/// Same as `row_to_drive_with_name` but reads `caller_role` from the
/// listing query — `MIN(g.role)::text`. The `storage.grant_role` ENUM
/// is declared owner→viewer (strongest→weakest), so `MIN` picks the
/// strongest of the caller's grants on the drive (direct +
/// group-mediated collapsed by GROUP BY). Used only by
/// `list_for_subjects`.
fn row_to_drive_with_name_and_role(
row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> {
use crate::domain::services::authorization::Role;
let mut dwr = Self::row_to_drive_with_name(row)?;
let role_str: Option<String> = row.try_get("caller_role").ok();
dwr.caller_role = role_str.as_deref().and_then(Role::parse);
Ok(dwr)
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -260,12 +280,20 @@ impl DriveRepository for DrivePgRepository {
// group-mediated) and sidesteps PostgreSQL's "ORDER BY // group-mediated) and sidesteps PostgreSQL's "ORDER BY
// expression must appear in select list" rule that SELECT // expression must appear in select list" rule that SELECT
// DISTINCT imposes. // DISTINCT imposes.
// `MIN(g.role)` picks the caller's strongest role on each drive:
// `storage.grant_role` is declared `owner → viewer` (strongest →
// weakest), so MIN returns the strongest. Cast `::text` matches
// the codebase convention for reading enum columns into Rust
// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back.
// Collapses direct + group-mediated grants on the same drive
// into one row alongside the existing GROUP BY.
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies, d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at, d.created_at, d.updated_at,
f.name AS root_folder_name f.name AS root_folder_name,
MIN(g.role)::text AS caller_role
FROM storage.drives d FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id JOIN storage.folders f ON f.id = d.root_folder_id
JOIN storage.role_grants g JOIN storage.role_grants g
@@ -292,6 +320,8 @@ impl DriveRepository for DrivePgRepository {
.await .await
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?; .map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
rows.iter().map(Self::row_to_drive_with_name).collect() rows.iter()
.map(Self::row_to_drive_with_name_and_role)
.collect()
} }
} }