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 uuid::Uuid;
use crate::application::dtos::grant_dto::RoleDto;
use crate::domain::entities::drive::DriveKind;
use crate::domain::repositories::drive_repository::DriveWithRootName;
@@ -62,6 +63,24 @@ pub struct DriveDto {
pub policies: serde_json::Value,
pub created_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 {
@@ -77,6 +96,7 @@ impl From<DriveWithRootName> for DriveDto {
policies: d.drive.policies,
created_at: d.drive.created_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`
/// of the root folder via JOIN at read time.
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]
@@ -44,6 +44,9 @@ impl DrivePgRepository {
/// 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.
/// `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(
row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> {
@@ -63,8 +66,25 @@ impl DrivePgRepository {
Ok(DriveWithRootName {
drive,
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]
@@ -260,12 +280,20 @@ impl DriveRepository for DrivePgRepository {
// group-mediated) and sidesteps PostgreSQL's "ORDER BY
// expression must appear in select list" rule that SELECT
// 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(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
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
JOIN storage.folders f ON f.id = d.root_folder_id
JOIN storage.role_grants g
@@ -292,6 +320,8 @@ impl DriveRepository for DrivePgRepository {
.await
.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()
}
}