feat(breadcrumb): show the granter (sharer) in the breadcrumb
This commit is contained in:
@@ -298,8 +298,34 @@ export interface SearchResourcesResponse {
|
|||||||
|
|
||||||
export type DriveKind = 'personal' | 'shared';
|
export type DriveKind = 'personal' | 'shared';
|
||||||
|
|
||||||
/** Role-keyed share strength. Matches `Role` in the backend authz model. */
|
/**
|
||||||
export type DriveRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer';
|
* Full role set from `storage.grant_role` — every value that can appear
|
||||||
|
* on a `role_grants` row regardless of `resource_type` (drive, folder,
|
||||||
|
* file, playlist, calendar, address_book, …). Use this for folder-level
|
||||||
|
* and file-level `caller_role` fields where all five values are valid.
|
||||||
|
* Matches `RoleDto` in the backend.
|
||||||
|
*/
|
||||||
|
export type GrantRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role assignable at DRIVE scope — a strict subset of `GrantRole`.
|
||||||
|
* Drives only meaningfully take the three management-ladder tiers:
|
||||||
|
* - `owner` — full control (rename, delete, quota, membership).
|
||||||
|
* - `editor` — can create/modify content anywhere in the drive.
|
||||||
|
* - `viewer` — read-only access to the whole drive.
|
||||||
|
*
|
||||||
|
* `contributor` (create-in-folder-without-touching-siblings) and
|
||||||
|
* `commenter` (react without modifying) are folder/file-scope
|
||||||
|
* semantics: they describe fine-grained access to a specific item,
|
||||||
|
* not to a whole drive. Grants of those roles happen at folder or
|
||||||
|
* file scope via a separate `role_grants` row, not at the drive
|
||||||
|
* boundary. Do NOT widen this type without a matching backend
|
||||||
|
* check — the DB ENUM permits all 5 today, so the constraint is
|
||||||
|
* conventional.
|
||||||
|
*
|
||||||
|
* Use `GrantRole` for folder/file-level `caller_role` fields.
|
||||||
|
*/
|
||||||
|
export type DriveRole = 'owner' | 'editor' | 'viewer';
|
||||||
|
|
||||||
/** Subject of a grant. Mirrors `SubjectDto`. */
|
/** Subject of a grant. Mirrors `SubjectDto`. */
|
||||||
export type SubjectKind = 'user' | 'group' | 'token';
|
export type SubjectKind = 'user' | 'group' | 'token';
|
||||||
@@ -465,8 +491,26 @@ export interface AccessSource {
|
|||||||
kind: AccessSourceKind;
|
kind: AccessSourceKind;
|
||||||
/** Populated when `kind === 'drive'`. */
|
/** Populated when `kind === 'drive'`. */
|
||||||
drive?: AccessSourceDrive;
|
drive?: AccessSourceDrive;
|
||||||
/** Optional grantee info for shares / group grants. */
|
/**
|
||||||
|
* SHARER — the user who created the grant that gave the caller
|
||||||
|
* access at the boundary (`role_grants.granted_by`). Kind is always
|
||||||
|
* `'user'` today (a group can't perform an action), but the type
|
||||||
|
* stays open in case a future model permits it. Null when the
|
||||||
|
* boundary can't be resolved to a single grant (e.g. `token`).
|
||||||
|
*/
|
||||||
subject?: AccessSourceSubject;
|
subject?: AccessSourceSubject;
|
||||||
|
/**
|
||||||
|
* Caller's role via the boundary grant (`role_grants.role` on the
|
||||||
|
* same row that carries `granted_by`). Lets the FE render permission-
|
||||||
|
* aware affordances at the ancestor scope. Reflects the boundary grant
|
||||||
|
* only — aggregate effective role via other channels may be stronger.
|
||||||
|
* Null on `token` access.
|
||||||
|
*
|
||||||
|
* Typed as `GrantRole` (not `DriveRole`): the boundary can be a
|
||||||
|
* folder-level share where all five role_grant values are valid,
|
||||||
|
* not just the drive-scoped subset.
|
||||||
|
*/
|
||||||
|
caller_role?: GrantRole | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getFolderAncestors } from '$lib/api/endpoints/folders';
|
import { getFolderAncestors } from '$lib/api/endpoints/folders';
|
||||||
import type { AccessSource, FolderAncestor, FolderAncestorsResponse } from '$lib/api/types';
|
import type { AccessSource, FolderAncestor, FolderAncestorsResponse } from '$lib/api/types';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
import UserAvatar from '$lib/components/UserAvatar.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +88,26 @@
|
|||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the crumb that carries the ACTUAL grant giving the
|
||||||
|
* caller access — the "boundary" crumb. Rendered with an inline
|
||||||
|
* sharer avatar (or group chip) so the breadcrumb answers both
|
||||||
|
* "how did I get here?" (root chip icon) AND "who shared this?"
|
||||||
|
* (avatar on the specific granted folder) — Ed's 2026-07-27 UX call.
|
||||||
|
*
|
||||||
|
* - `direct_share`: always index 0 of `visibleCrumbs` (the
|
||||||
|
* endpoint's walk stops at the granted folder, so the topmost
|
||||||
|
* accessible ancestor IS the share boundary).
|
||||||
|
* - `drive`: null — the grant lives on the drive itself, not on
|
||||||
|
* any visible folder. The drive-root chip already carries the
|
||||||
|
* drive name; adding a subject chip to a sub-folder would be
|
||||||
|
* semantically misleading (that sub-folder wasn't the grant).
|
||||||
|
* - `token`: null — no user-facing subject to render.
|
||||||
|
*/
|
||||||
|
const boundaryCrumbIndex = $derived<number | null>(
|
||||||
|
chain?.access_source.kind === 'direct_share' && visibleCrumbs.length > 0 ? 0 : null
|
||||||
|
);
|
||||||
|
|
||||||
// ── Root-icon derivation ────────────────────────────────────────────
|
// ── Root-icon derivation ────────────────────────────────────────────
|
||||||
// One icon per `access_source.kind`. Personal drives use the home
|
// One icon per `access_source.kind`. Personal drives use the home
|
||||||
// glyph (they're the caller's own storage — signalling "home base");
|
// glyph (they're the caller's own storage — signalling "home base");
|
||||||
@@ -285,10 +306,12 @@
|
|||||||
`onDrop` handler. Absent everywhere except `/files`.
|
`onDrop` handler. Absent everywhere except `/files`.
|
||||||
-->
|
-->
|
||||||
{@const isLeaf = i === visibleCrumbs.length - 1}
|
{@const isLeaf = i === visibleCrumbs.length - 1}
|
||||||
|
{@const isBoundary = i === boundaryCrumbIndex}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/files/${c.id}`)}
|
href={resolve(`/files/${c.id}`)}
|
||||||
class="breadcrumb-item breadcrumb-link"
|
class="breadcrumb-item breadcrumb-link"
|
||||||
class:breadcrumb-current={isLeaf}
|
class:breadcrumb-current={isLeaf}
|
||||||
|
class:breadcrumb-boundary={isBoundary}
|
||||||
class:drop-target={onDrop != null && dropTargetId === c.id}
|
class:drop-target={onDrop != null && dropTargetId === c.id}
|
||||||
data-testid={isLeaf ? `folder-breadcrumb-current-${c.id}` : `folder-breadcrumb-${c.id}`}
|
data-testid={isLeaf ? `folder-breadcrumb-current-${c.id}` : `folder-breadcrumb-${c.id}`}
|
||||||
ondragover={onDrop
|
ondragover={onDrop
|
||||||
@@ -311,7 +334,35 @@
|
|||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
{c.name}
|
<!--
|
||||||
|
Sharer decoration on the BOUNDARY crumb (the topmost
|
||||||
|
accessible ancestor that carries the actual grant).
|
||||||
|
Only rendered for `direct_share` — drive-kind grants
|
||||||
|
live on the drive itself and the drive-root chip
|
||||||
|
already carries that context. Ed's 2026-07-27 design:
|
||||||
|
[share-alt] > folder[avatar] > sub …
|
||||||
|
so the root chip keeps the access-CHANNEL semantic
|
||||||
|
(share / drive / link) and the person/group chip
|
||||||
|
attaches to the folder that WAS shared.
|
||||||
|
-->
|
||||||
|
{#if isBoundary && chain?.access_source.subject}
|
||||||
|
{@const subj = chain.access_source.subject}
|
||||||
|
{#if subj.kind === 'user'}
|
||||||
|
<UserAvatar userId={subj.id} size={18} />
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="breadcrumb-group-chip"
|
||||||
|
title={t(
|
||||||
|
'breadcrumb.subject.group_tooltip',
|
||||||
|
{ name: subj.name ?? '' },
|
||||||
|
'Shared with group: {{name}}'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name="users" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span class="breadcrumb-crumb-name">{c.name}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -335,9 +386,38 @@
|
|||||||
anchor → highlight clears (Ed's 2026-07-26 report). Pointer-events
|
anchor → highlight clears (Ed's 2026-07-26 report). Pointer-events
|
||||||
off on children collapses the whole chip to a single drag target;
|
off on children collapses the whole chip to a single drag target;
|
||||||
drop still lands because the anchor's own handlers stay live.
|
drop still lands because the anchor's own handlers stay live.
|
||||||
Intermediate crumbs don't need this (they contain only a text
|
Boundary crumbs (with an inline avatar/group chip child) get the
|
||||||
node — no child element to cross into). */
|
same treatment — same drop-flicker mechanism, one child element to
|
||||||
.breadcrumb-home > * {
|
cross into. Plain intermediate crumbs are pure text nodes and
|
||||||
|
don't need it, but the selector is harmless there. */
|
||||||
|
.breadcrumb-home > *,
|
||||||
|
.breadcrumb-link > * {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boundary crumbs align the inline sharer decoration (avatar or
|
||||||
|
group chip) with the folder-name text on the vertical midline —
|
||||||
|
inline-flex + baseline gap. Non-boundary crumbs stay inline (no
|
||||||
|
flex overhead) so wide breadcrumbs still wrap the same way. */
|
||||||
|
.breadcrumb-boundary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group-subject chip — small `users` glyph in a subtle badge, sits
|
||||||
|
next to the folder name on the boundary crumb. Matches the size
|
||||||
|
footprint of the inline UserAvatar (18px) so user- vs group-shared
|
||||||
|
crumbs feel visually consistent. */
|
||||||
|
.breadcrumb-group-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
|
||||||
// A drive accepts new items only if the caller can Create on its root.
|
// A drive accepts new items only if the caller can Create on its
|
||||||
// Owner / Editor / Contributor cover that; Commenter + Viewer cannot.
|
// root. Drive-scope roles are the management-ladder subset —
|
||||||
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const;
|
// Owner / Editor / Viewer — so writability collapses to the top two;
|
||||||
|
// Viewer cannot. `contributor`/`commenter` don't appear at drive
|
||||||
|
// scope (folder/file-scope semantics), so they're not in `DriveRole`.
|
||||||
|
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor'] as const;
|
||||||
function isWritable(d: Drive): boolean {
|
function isWritable(d: Drive): boolean {
|
||||||
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
|
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,10 +125,6 @@
|
|||||||
return t('drive.role.owner', 'Owner');
|
return t('drive.role.owner', 'Owner');
|
||||||
case 'editor':
|
case 'editor':
|
||||||
return t('drive.role.editor', 'Editor');
|
return t('drive.role.editor', 'Editor');
|
||||||
case 'contributor':
|
|
||||||
return t('drive.role.contributor', 'Contributor');
|
|
||||||
case 'commenter':
|
|
||||||
return t('drive.role.commenter', 'Commenter');
|
|
||||||
case 'viewer':
|
case 'viewer':
|
||||||
return t('drive.role.viewer', 'Viewer');
|
return t('drive.role.viewer', 'Viewer');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||||
use crate::application::dtos::display_helpers::intern_display;
|
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::entities::folder::Folder;
|
||||||
use crate::domain::services::authorization::ResourceKind;
|
use crate::domain::services::authorization::ResourceKind;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -437,13 +437,21 @@ pub struct AccessSourceDto {
|
|||||||
/// Populated when `kind == Drive`. Null otherwise.
|
/// Populated when `kind == Drive`. Null otherwise.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub drive: Option<AccessSourceDriveDto>,
|
pub drive: Option<AccessSourceDriveDto>,
|
||||||
/// Populated when a `role_grants` row identifies the grantee (self
|
/// SHARER — the user who created the grant that gave the caller
|
||||||
/// or a group). MVP leaves this null — subject enrichment (grantor
|
/// access at the boundary (`storage.role_grants.granted_by`). Kind
|
||||||
/// name / group name lookup) is a follow-up. Once populated the FE
|
/// is always `User` today: `granted_by` references `auth.users` and
|
||||||
/// tooltip becomes "shared with **your team**" / "shared with **you
|
/// a group can't perform an action. Null when the boundary can't be
|
||||||
/// by X**" instead of the generic "shared with you".
|
/// resolved to a single grant (e.g. `token` access).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub subject: Option<AccessSourceSubjectDto>,
|
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)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::application::dtos::cursor::PageCursor;
|
use crate::application::dtos::cursor::PageCursor;
|
||||||
use crate::application::dtos::drive_dto::DriveKindDto;
|
use crate::application::dtos::drive_dto::DriveKindDto;
|
||||||
use crate::application::dtos::folder_dto::{
|
use crate::application::dtos::folder_dto::{
|
||||||
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto,
|
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
|
||||||
FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
|
||||||
MoveFolderDto, RenameFolderDto,
|
FolderResourceCursor, FolderResourceRow, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
|
||||||
};
|
};
|
||||||
|
use crate::application::dtos::grant_dto::RoleDto;
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::external_mount_ports::MountEntry;
|
use crate::application::ports::external_mount_ports::MountEntry;
|
||||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
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::application::services::mount_registry::MountConfig;
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
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::external_mount_id::NodeId;
|
||||||
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
|
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
|
||||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
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.
|
// The topmost surviving row is the root of the caller's view.
|
||||||
// Its grant profile drives `AccessSource`.
|
// Its grant profile drives `AccessSource`.
|
||||||
let top = &rows[0];
|
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 {
|
let access_source = if top.has_drive_grant {
|
||||||
// Drive-membership Read — even if a direct folder grant also
|
// Drive-membership Read — even if a direct folder grant also
|
||||||
// exists, the drive channel is the more useful "how did I
|
// exists, the drive channel is the more useful "how did I
|
||||||
@@ -1092,16 +1133,18 @@ impl FolderService {
|
|||||||
AccessSourceDto {
|
AccessSourceDto {
|
||||||
kind: AccessSourceKind::Drive,
|
kind: AccessSourceKind::Drive,
|
||||||
drive,
|
drive,
|
||||||
subject: None,
|
subject,
|
||||||
|
caller_role,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Direct folder-level grant (share). Subject enrichment is a
|
// Direct folder-level grant (share). Subject carries who
|
||||||
// follow-up (see the DTO comment) — MVP surfaces the kind and
|
// shared it (user or group), enabling "shared with you by X"
|
||||||
// lets the FE render a generic "shared with you" tooltip.
|
// in the FE tooltip.
|
||||||
AccessSourceDto {
|
AccessSourceDto {
|
||||||
kind: AccessSourceKind::DirectShare,
|
kind: AccessSourceKind::DirectShare,
|
||||||
drive: None,
|
drive: None,
|
||||||
subject: None,
|
subject,
|
||||||
|
caller_role,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1549,6 +1549,67 @@ impl FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}")))
|
.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
|
/// Drive header (`id + name + kind`) for the drive-source arm of
|
||||||
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
|
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
|
||||||
/// proved drive-membership via the ancestor walk before invoking.
|
/// proved drive-membership via the ancestor walk before invoking.
|
||||||
|
|||||||
Reference in New Issue
Block a user