feat(breadcrumb): show the granter (sharer) in the breadcrumb
This commit is contained in:
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use crate::application::dtos::display_helpers::intern_display;
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto, RoleDto};
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -437,13 +437,21 @@ pub struct AccessSourceDto {
|
||||
/// Populated when `kind == Drive`. Null otherwise.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drive: Option<AccessSourceDriveDto>,
|
||||
/// Populated when a `role_grants` row identifies the grantee (self
|
||||
/// or a group). MVP leaves this null — subject enrichment (grantor
|
||||
/// name / group name lookup) is a follow-up. Once populated the FE
|
||||
/// tooltip becomes "shared with **your team**" / "shared with **you
|
||||
/// by X**" instead of the generic "shared with you".
|
||||
/// SHARER — the user who created the grant that gave the caller
|
||||
/// access at the boundary (`storage.role_grants.granted_by`). Kind
|
||||
/// is always `User` today: `granted_by` references `auth.users` and
|
||||
/// a group can't perform an action. Null when the boundary can't be
|
||||
/// resolved to a single grant (e.g. `token` access).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<AccessSourceSubjectDto>,
|
||||
/// Caller's own role via the boundary grant (`role_grants.role` on
|
||||
/// the same row that carries `granted_by`). Lets the FE render
|
||||
/// permission-aware affordances — "you can Edit / Comment /
|
||||
/// View this share" — without a second lookup. Reflects the boundary
|
||||
/// grant only: aggregate effective role via other channels may be
|
||||
/// stronger. Null on `token` access.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caller_role: Option<RoleDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::drive_dto::DriveKindDto;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto,
|
||||
FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
||||
MoveFolderDto, RenameFolderDto,
|
||||
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
|
||||
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
|
||||
FolderResourceCursor, FolderResourceRow, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::grant_dto::RoleDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::external_mount_ports::MountEntry;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
@@ -17,7 +18,7 @@ use crate::application::services::mount_dto::{
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Role, Subject};
|
||||
use crate::domain::services::external_mount_id::NodeId;
|
||||
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
@@ -1069,6 +1070,46 @@ impl FolderService {
|
||||
// The topmost surviving row is the root of the caller's view.
|
||||
// Its grant profile drives `AccessSource`.
|
||||
let top = &rows[0];
|
||||
|
||||
// Subject enrichment: identify the specific grant that gave the
|
||||
// caller access to `top`, then resolve its subject's display
|
||||
// name in the same query. Drives the tooltip on the breadcrumb
|
||||
// root chip ("Shared with you by Alice" / "Shared with your
|
||||
// team via Design"). No `expires_at` filter — the ancestor
|
||||
// walk's guard already proved the caller is authorized to see
|
||||
// this ancestor, so the follow-up name lookup is display-only
|
||||
// (see `feedback_trust_grant_janitor_no_expires_at_read`).
|
||||
let (grant_resource_type, grant_resource_id) = if top.has_drive_grant {
|
||||
("drive", top.drive_id)
|
||||
} else {
|
||||
("folder", top.id)
|
||||
};
|
||||
let grant_by = self
|
||||
.folder_storage
|
||||
.fetch_grant_by(caller_id, grant_resource_type, grant_resource_id)
|
||||
.await?;
|
||||
let subject = grant_by
|
||||
.as_ref()
|
||||
.map(
|
||||
|(subject_type_str, subject_id, name, _role)| AccessSourceSubjectDto {
|
||||
kind: match subject_type_str.as_str() {
|
||||
"group" => AccessSourceSubjectKind::Group,
|
||||
_ => AccessSourceSubjectKind::User,
|
||||
},
|
||||
id: *subject_id,
|
||||
name: name.clone(),
|
||||
},
|
||||
);
|
||||
// Caller's role via the boundary grant. `Role::parse` returns
|
||||
// None only if the SQL stored a role we don't understand — the
|
||||
// ENUM constraint makes that a schema drift, not a runtime case
|
||||
// to chase. Silent None keeps the endpoint working with an older
|
||||
// deployment if a future role is added ahead of the code.
|
||||
let caller_role = grant_by
|
||||
.as_ref()
|
||||
.and_then(|(_, _, _, role_str)| Role::parse(role_str))
|
||||
.map(RoleDto::from);
|
||||
|
||||
let access_source = if top.has_drive_grant {
|
||||
// Drive-membership Read — even if a direct folder grant also
|
||||
// exists, the drive channel is the more useful "how did I
|
||||
@@ -1092,16 +1133,18 @@ impl FolderService {
|
||||
AccessSourceDto {
|
||||
kind: AccessSourceKind::Drive,
|
||||
drive,
|
||||
subject: None,
|
||||
subject,
|
||||
caller_role,
|
||||
}
|
||||
} else {
|
||||
// Direct folder-level grant (share). Subject enrichment is a
|
||||
// follow-up (see the DTO comment) — MVP surfaces the kind and
|
||||
// lets the FE render a generic "shared with you" tooltip.
|
||||
// Direct folder-level grant (share). Subject carries who
|
||||
// shared it (user or group), enabling "shared with you by X"
|
||||
// in the FE tooltip.
|
||||
AccessSourceDto {
|
||||
kind: AccessSourceKind::DirectShare,
|
||||
drive: None,
|
||||
subject: None,
|
||||
subject,
|
||||
caller_role,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1549,6 +1549,67 @@ impl FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}")))
|
||||
}
|
||||
|
||||
/// Boundary-grant SHARER (`granted_by`) for `AccessSourceDto.subject`.
|
||||
/// Given the resource (drive or folder) the caller reached the topmost
|
||||
/// accessible ancestor through, find one active grant THAT CONCERNS
|
||||
/// THE CALLER (user grant on caller, or group grant on one of the
|
||||
/// caller's groups) and return `(kind, id, name)` for the user who
|
||||
/// CREATED that grant — the sharer, not the grantee. The breadcrumb
|
||||
/// consumer wants "who shared this with me?" not "who has permission?"
|
||||
/// (Ed 2026-07-27).
|
||||
///
|
||||
/// Kind is always `user`: `granted_by` references `auth.users` and
|
||||
/// is never a group (a group can't perform an action). Name is
|
||||
/// looked up from `auth.users.username` in the same round-trip.
|
||||
///
|
||||
/// Grant selection prefers a user grant on the caller over a group
|
||||
/// grant on one of their groups when both exist on the same resource
|
||||
/// (the more specific one is likely the truer "who shared this with
|
||||
/// me"). Only the OUTPUT pivots to the grantor.
|
||||
///
|
||||
/// No `expires_at` filter — the ancestor-walk guard has already
|
||||
/// established the caller is authorized to see this ancestor
|
||||
/// (visibility decision made upstream). See
|
||||
/// `feedback_trust_grant_janitor_no_expires_at_read` — this is the
|
||||
/// narrow exception where skipping is safe.
|
||||
pub async fn fetch_grant_by(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
resource_type: &str,
|
||||
resource_id: Uuid,
|
||||
) -> Result<Option<(String, Uuid, Option<String>, String)>, DomainError> {
|
||||
// Same row also carries the caller's role — piggyback the lookup
|
||||
// so consumers can render "who shared this" AND "what can I do
|
||||
// with it" from one round-trip (Ed 2026-07-27). The role is the
|
||||
// grant's own role, i.e. the caller's effective role via THIS
|
||||
// specific boundary grant. If the caller has additional grants
|
||||
// via other channels the aggregate effective role may differ;
|
||||
// `caller_role` on the boundary DTO reflects the boundary grant
|
||||
// only.
|
||||
let sql = r#"
|
||||
SELECT
|
||||
'user'::text AS kind,
|
||||
g.granted_by AS id,
|
||||
(SELECT u.username FROM auth.users u WHERE u.id = g.granted_by) AS name,
|
||||
g.role::text AS role
|
||||
FROM storage.role_grants g
|
||||
WHERE g.resource_type = $2
|
||||
AND g.resource_id = $3::uuid
|
||||
AND ( (g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1))) )
|
||||
ORDER BY g.subject_type = 'user' DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
sqlx::query_as::<_, (String, Uuid, Option<String>, String)>(sql)
|
||||
.bind(caller_id)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("grant by lookup: {e}")))
|
||||
}
|
||||
|
||||
/// Drive header (`id + name + kind`) for the drive-source arm of
|
||||
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
|
||||
/// proved drive-membership via the ancestor walk before invoking.
|
||||
|
||||
Reference in New Issue
Block a user