2026-05-20 22:56:00 +02:00
|
|
|
|
//! DTOs for the ReBAC `/api/grants` REST endpoints.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! The wire shapes are intentionally separate from the domain types
|
|
|
|
|
|
//! (`Subject`, `Resource`, `Permission`, `Grant`) so that domain stays
|
|
|
|
|
|
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
|
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-24 01:05:13 +02:00
|
|
|
|
use utoipa::{IntoParams, ToSchema};
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
2026-05-26 17:52:28 +02:00
|
|
|
|
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
2026-05-24 01:05:13 +02:00
|
|
|
|
use crate::application::dtos::file_dto::FileDto;
|
|
|
|
|
|
use crate::application::dtos::folder_dto::FolderDto;
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Subject / Resource / Permission DTOs
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum SubjectTypeDto {
|
|
|
|
|
|
User,
|
|
|
|
|
|
Group,
|
|
|
|
|
|
Token,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct SubjectDto {
|
|
|
|
|
|
#[serde(rename = "type")]
|
|
|
|
|
|
pub kind: SubjectTypeDto,
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<SubjectDto> for Subject {
|
|
|
|
|
|
fn from(dto: SubjectDto) -> Self {
|
|
|
|
|
|
match dto.kind {
|
|
|
|
|
|
SubjectTypeDto::User => Subject::User(dto.id),
|
|
|
|
|
|
SubjectTypeDto::Group => Subject::Group(dto.id),
|
|
|
|
|
|
SubjectTypeDto::Token => Subject::Token(dto.id),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Subject> for SubjectDto {
|
|
|
|
|
|
fn from(s: Subject) -> Self {
|
|
|
|
|
|
let (kind, id) = match s {
|
|
|
|
|
|
Subject::User(id) => (SubjectTypeDto::User, id),
|
|
|
|
|
|
Subject::Group(id) => (SubjectTypeDto::Group, id),
|
|
|
|
|
|
Subject::Token(id) => (SubjectTypeDto::Token, id),
|
|
|
|
|
|
};
|
|
|
|
|
|
SubjectDto { kind, id }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum ResourceTypeDto {
|
|
|
|
|
|
Folder,
|
|
|
|
|
|
File,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct ResourceDto {
|
|
|
|
|
|
#[serde(rename = "type")]
|
|
|
|
|
|
pub kind: ResourceTypeDto,
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<ResourceDto> for Resource {
|
|
|
|
|
|
fn from(dto: ResourceDto) -> Self {
|
|
|
|
|
|
match dto.kind {
|
|
|
|
|
|
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
|
|
|
|
|
ResourceTypeDto::File => Resource::File(dto.id),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Resource> for ResourceDto {
|
|
|
|
|
|
fn from(r: Resource) -> Self {
|
|
|
|
|
|
let (kind, id) = match r {
|
|
|
|
|
|
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
|
|
|
|
|
Resource::File(id) => (ResourceTypeDto::File, id),
|
|
|
|
|
|
};
|
|
|
|
|
|
ResourceDto { kind, id }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum PermissionDto {
|
|
|
|
|
|
Read,
|
|
|
|
|
|
Create,
|
|
|
|
|
|
Share,
|
|
|
|
|
|
Comment,
|
|
|
|
|
|
Delete,
|
|
|
|
|
|
Update,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
Manage,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<PermissionDto> for Permission {
|
|
|
|
|
|
fn from(p: PermissionDto) -> Self {
|
|
|
|
|
|
match p {
|
|
|
|
|
|
PermissionDto::Read => Permission::Read,
|
|
|
|
|
|
PermissionDto::Create => Permission::Create,
|
|
|
|
|
|
PermissionDto::Share => Permission::Share,
|
|
|
|
|
|
PermissionDto::Comment => Permission::Comment,
|
|
|
|
|
|
PermissionDto::Delete => Permission::Delete,
|
|
|
|
|
|
PermissionDto::Update => Permission::Update,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
PermissionDto::Manage => Permission::Manage,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Permission> for PermissionDto {
|
|
|
|
|
|
fn from(p: Permission) -> Self {
|
|
|
|
|
|
match p {
|
|
|
|
|
|
Permission::Read => PermissionDto::Read,
|
|
|
|
|
|
Permission::Create => PermissionDto::Create,
|
|
|
|
|
|
Permission::Share => PermissionDto::Share,
|
|
|
|
|
|
Permission::Comment => PermissionDto::Comment,
|
|
|
|
|
|
Permission::Delete => PermissionDto::Delete,
|
|
|
|
|
|
Permission::Update => PermissionDto::Update,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
Permission::Manage => PermissionDto::Manage,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
2026-06-17 23:14:25 +02:00
|
|
|
|
// Roles — the load-bearing model for ReBAC grants
|
2026-05-20 22:56:00 +02:00
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
2026-06-17 23:14:25 +02:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
|
2026-05-20 22:56:00 +02:00
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum Role {
|
2026-06-17 23:14:25 +02:00
|
|
|
|
/// Read access only. The default "anyone can look but not touch".
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Viewer,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
/// 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.
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Editor,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
/// 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,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Role {
|
2026-06-17 23:14:25 +02:00
|
|
|
|
/// 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).
|
2026-05-20 22:56:00 +02:00
|
|
|
|
pub fn expand(self) -> &'static [Permission] {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
Role::Viewer => &[Permission::Read],
|
|
|
|
|
|
Role::Commenter => &[Permission::Read, Permission::Comment],
|
2026-06-17 23:14:25 +02:00
|
|
|
|
Role::Contributor => &[Permission::Read, Permission::Create],
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Role::Editor => &[
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Permission::Comment,
|
|
|
|
|
|
Permission::Create,
|
|
|
|
|
|
Permission::Update,
|
|
|
|
|
|
],
|
2026-06-17 23:14:25 +02:00
|
|
|
|
Role::Owner => &[
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Permission::Comment,
|
|
|
|
|
|
Permission::Create,
|
|
|
|
|
|
Permission::Update,
|
|
|
|
|
|
Permission::Share,
|
|
|
|
|
|
Permission::Delete,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
Permission::Manage,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 23:14:25 +02:00
|
|
|
|
|
|
|
|
|
|
/// 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],
|
|
|
|
|
|
}
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Request DTOs
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-06-02 10:47:37 +02:00
|
|
|
|
/// Subject shape accepted by `POST /api/grants`. Internally-tagged enum
|
|
|
|
|
|
/// so the existing `{type:"user", id:"..."}` payload keeps working
|
|
|
|
|
|
/// alongside the new `{type:"email", email:"..."}` variant that feeds
|
|
|
|
|
|
/// the invite-by-email flow. The response-side [`SubjectDto`] stays
|
|
|
|
|
|
/// unchanged — externals resolve to `Subject::User(uuid)` with
|
|
|
|
|
|
/// `is_external = TRUE` on the user row, never a distinct subject type.
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
|
|
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
|
|
|
|
pub enum SubjectInputDto {
|
|
|
|
|
|
User {
|
|
|
|
|
|
id: Uuid,
|
|
|
|
|
|
},
|
|
|
|
|
|
Group {
|
|
|
|
|
|
id: Uuid,
|
|
|
|
|
|
},
|
|
|
|
|
|
Token {
|
|
|
|
|
|
id: Uuid,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// Invite-by-email. Lazily provisions an external user with the
|
|
|
|
|
|
/// normalised address as both username and email when no match
|
|
|
|
|
|
/// exists; otherwise reuses the existing user. Triggers a magic-link
|
|
|
|
|
|
/// invitation email when the resolved user has no other login
|
|
|
|
|
|
/// credential.
|
|
|
|
|
|
Email {
|
|
|
|
|
|
email: String,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 22:56:00 +02:00
|
|
|
|
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
|
|
|
|
|
|
/// Server-side validation requires exactly one of the two to be present.
|
|
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct CreateGrantDto {
|
2026-06-02 10:47:37 +02:00
|
|
|
|
pub subject: SubjectInputDto,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
pub resource: ResourceDto,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub permissions: Option<Vec<PermissionDto>>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub role: Option<Role>,
|
2026-05-28 19:56:31 +02:00
|
|
|
|
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
|
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct UpdateRoleDto {
|
|
|
|
|
|
pub subject: SubjectDto,
|
|
|
|
|
|
pub resource: ResourceDto,
|
|
|
|
|
|
pub role: Role,
|
2026-05-28 19:56:31 +02:00
|
|
|
|
/// Optional expiry applied to every grant written or updated by this call.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Response DTOs
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct GrantDto {
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
pub subject: SubjectDto,
|
|
|
|
|
|
pub resource: ResourceDto,
|
|
|
|
|
|
pub permission: PermissionDto,
|
|
|
|
|
|
pub granted_by: Uuid,
|
|
|
|
|
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
2026-05-28 19:56:31 +02:00
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Grant> for GrantDto {
|
|
|
|
|
|
fn from(g: Grant) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
id: g.id,
|
|
|
|
|
|
subject: g.subject.into(),
|
|
|
|
|
|
resource: g.resource.into(),
|
|
|
|
|
|
permission: g.permission.into(),
|
|
|
|
|
|
granted_by: g.granted_by,
|
|
|
|
|
|
granted_at: g.granted_at,
|
2026-05-28 19:56:31 +02:00
|
|
|
|
expires_at: g.expires_at,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-24 01:05:13 +02:00
|
|
|
|
|
2026-06-05 09:46:51 +02:00
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Notification DTOs (PR N1) — surfaced in the create-grant and /notify
|
|
|
|
|
|
// responses so the frontend can show actionable toasts ("Notified Carol",
|
|
|
|
|
|
// "Carol already notified recently", "Notified 8 of 10 group members").
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/// One per resolved recipient. `kind` discriminates; sibling fields are
|
|
|
|
|
|
/// only meaningful for the matching variant. Tagged JSON shape:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// ```json
|
|
|
|
|
|
/// { "kind": "sent", "detail": "magic_link" }
|
|
|
|
|
|
/// { "kind": "sent", "detail": "plain_notification" }
|
|
|
|
|
|
/// { "kind": "coalesced", "last_sent_at": "2026-06-04T12:00:00Z" }
|
|
|
|
|
|
/// { "kind": "rate_limited", "retry_after_secs": 1800 }
|
|
|
|
|
|
/// { "kind": "not_applicable", "reason": "recipient_opted_out" }
|
|
|
|
|
|
/// ```
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
|
|
|
|
pub enum NotifyOutcomeDto {
|
|
|
|
|
|
/// An email actually went out for this recipient. `detail` is
|
|
|
|
|
|
/// `"magic_link"` (external invitation with a fresh token) or
|
|
|
|
|
|
/// `"plain_notification"` (internal "you got a new grant" mail).
|
|
|
|
|
|
Sent { detail: String },
|
|
|
|
|
|
/// Skipped silently because this (granter, recipient) pair was
|
|
|
|
|
|
/// notified less than the coalesce-window ago. The grant is still
|
|
|
|
|
|
/// recorded; the recipient sees it next time they log in.
|
|
|
|
|
|
Coalesced {
|
|
|
|
|
|
last_sent_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
|
},
|
|
|
|
|
|
/// Per-recipient hard cap (5/h) reached. The caller may retry after
|
|
|
|
|
|
/// `retry_after_secs`.
|
|
|
|
|
|
RateLimited { retry_after_secs: u32 },
|
|
|
|
|
|
/// No mail was dispatched for this recipient. `reason` is one of:
|
|
|
|
|
|
/// - `"recipient_opted_out"` — user toggled `notify_on_share = false`
|
|
|
|
|
|
/// - `"operator_disabled"` — `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=false`
|
|
|
|
|
|
/// - `"no_email"` — user row has no email on file
|
|
|
|
|
|
/// - `"oidc_only_no_email"` — OIDC-only user with no email claim
|
|
|
|
|
|
/// - `"subject_is_token"` — anonymous link share (the surface
|
|
|
|
|
|
/// that creates the grant or the `/notify` endpoint maps this to 409)
|
|
|
|
|
|
NotApplicable { reason: String },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The aggregated result of dispatching share notifications for ONE grant
|
|
|
|
|
|
/// action (one `create_grant` request OR one `/notify` call). Carries
|
|
|
|
|
|
/// per-recipient outcomes so the frontend can render a single
|
|
|
|
|
|
/// summary-style toast:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - `total_recipients = 1`, `outcomes[0] = Sent` → "Notified Carol"
|
|
|
|
|
|
/// - `total_recipients = 1`, `outcomes[0] = Coalesced` → "Carol already
|
|
|
|
|
|
/// notified recently"
|
|
|
|
|
|
/// - `total_recipients = N`, all `Sent` → "Notified all N group members"
|
|
|
|
|
|
/// - `total_recipients = N`, mix → "Notified 8 of 10 — 2 opted out"
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `total_recipients` equals `outcomes.len()` after resolution. For
|
|
|
|
|
|
/// token-subject grants it is `0` (no human recipient — no toast).
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct NotifyOutcomeSetDto {
|
|
|
|
|
|
pub total_recipients: usize,
|
|
|
|
|
|
pub outcomes: Vec<NotifyOutcomeDto>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl NotifyOutcomeSetDto {
|
|
|
|
|
|
/// Construct an empty set (token subjects, no recipients to notify).
|
|
|
|
|
|
pub fn empty() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
total_recipients: 0,
|
|
|
|
|
|
outcomes: Vec::new(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Construct from a list of outcomes, deriving `total_recipients`
|
|
|
|
|
|
/// from the list length. Use this from `RecipientNotificationService`
|
|
|
|
|
|
/// after the per-member loop completes.
|
|
|
|
|
|
pub fn from_outcomes(outcomes: Vec<NotifyOutcomeDto>) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
total_recipients: outcomes.len(),
|
|
|
|
|
|
outcomes,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Response body for `POST /api/grants`. Wraps the array of created
|
|
|
|
|
|
/// grants (one per `permission` in the request) together with the
|
|
|
|
|
|
/// aggregated notification result. Replaces the previous bare
|
|
|
|
|
|
/// `Vec<GrantDto>` shape; the frontend share modal is updated in
|
|
|
|
|
|
/// lockstep.
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct CreateGrantResponseDto {
|
|
|
|
|
|
pub grants: Vec<GrantDto>,
|
|
|
|
|
|
pub notification: NotifyOutcomeSetDto,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 01:05:13 +02:00
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Shared-with-me DTOs (GET /api/grants/incoming/resources)
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/// Query parameters for `GET /api/grants/incoming/resources`.
|
2026-05-26 17:52:28 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// `limit`, `cursor`, and `sort_by` follow the standard [`CursorQuery`]
|
|
|
|
|
|
/// contract. They are declared directly here rather than via
|
|
|
|
|
|
/// `#[serde(flatten)]` because `serde_urlencoded` (Axum's query extractor)
|
|
|
|
|
|
/// does not support flattening.
|
2026-05-24 01:05:13 +02:00
|
|
|
|
#[derive(Debug, Deserialize, IntoParams)]
|
|
|
|
|
|
pub struct SharedWithMeQuery {
|
|
|
|
|
|
/// Maximum number of items to return (1–200, default 50).
|
2026-05-26 17:52:28 +02:00
|
|
|
|
#[serde(default = "CursorQuery::default_limit")]
|
2026-05-24 01:05:13 +02:00
|
|
|
|
pub limit: u32,
|
2026-05-26 17:52:28 +02:00
|
|
|
|
/// Opaque cursor from a previous response. Omit to start from the
|
|
|
|
|
|
/// most-recently-granted item.
|
|
|
|
|
|
pub cursor: Option<String>,
|
|
|
|
|
|
/// Sort dimension. Supported values: `"granted_at"` (default),
|
|
|
|
|
|
/// `"granted_by"` (for swimlane grouping).
|
|
|
|
|
|
pub sort_by: Option<String>,
|
2026-05-24 01:05:13 +02:00
|
|
|
|
/// Comma-separated resource types to include, e.g. `file,folder`.
|
|
|
|
|
|
/// Omit to return all known types.
|
|
|
|
|
|
pub resource_types: Option<String>,
|
2026-05-28 00:44:20 +02:00
|
|
|
|
/// Reverse the sort order. Default `false` (normal order).
|
|
|
|
|
|
/// Must be the same on all pages of the same result set — the cursor
|
|
|
|
|
|
/// carries this flag so the server can validate consistency.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub reverse: bool,
|
2026-05-24 01:05:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:52:28 +02:00
|
|
|
|
impl SharedWithMeQuery {
|
|
|
|
|
|
/// Returns `limit` clamped to `[1, 200]`.
|
|
|
|
|
|
pub fn limit_clamped(&self) -> usize {
|
|
|
|
|
|
self.limit.clamp(1, 200) as usize
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Decode the optional cursor string. Invalid cursor → start from top.
|
|
|
|
|
|
pub fn decode_cursor<C: PageCursor>(&self) -> Option<C> {
|
|
|
|
|
|
self.cursor.as_deref().and_then(C::decode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The resource payload for one item in the shared-with-me list.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The variant is discriminated by `resource_type` on the parent
|
|
|
|
|
|
/// [`SharedWithMeItemDto`]. Serialised as the inner object (no wrapper key)
|
|
|
|
|
|
/// via `#[serde(untagged)]`, so consumers see the file/folder fields directly
|
|
|
|
|
|
/// under the `resource` key.
|
|
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
|
|
|
|
#[serde(untagged)]
|
|
|
|
|
|
pub enum ResourceContentDto {
|
|
|
|
|
|
File(FileDto),
|
|
|
|
|
|
Folder(FolderDto),
|
2026-05-24 01:05:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:52:28 +02:00
|
|
|
|
/// One item in the shared-with-me list.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `resource_type` indicates whether `resource` contains a file or a folder.
|
|
|
|
|
|
/// Using a single `resource` field (instead of nullable `file`/`folder` pairs)
|
|
|
|
|
|
/// makes adding new resource types backward-compatible — only `resource_type`
|
|
|
|
|
|
/// gains a new variant; the wrapper shape stays the same.
|
2026-05-24 01:05:13 +02:00
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct SharedWithMeItemDto {
|
|
|
|
|
|
pub resource_type: ResourceTypeDto,
|
|
|
|
|
|
/// All permissions the caller holds on this resource (aggregated).
|
|
|
|
|
|
pub permissions: Vec<PermissionDto>,
|
|
|
|
|
|
/// Earliest grant date for this resource.
|
|
|
|
|
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
|
/// UUID of the user who created the (earliest) grant.
|
|
|
|
|
|
pub granted_by: Uuid,
|
2026-05-26 17:52:28 +02:00
|
|
|
|
/// Full resource details. Shape is determined by `resource_type`.
|
|
|
|
|
|
pub resource: ResourceContentDto,
|
2026-05-24 01:05:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-29 02:16:15 +02:00
|
|
|
|
/// Derive the closest-matching role label from a set of permissions.
|
2026-06-17 23:14:25 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// **Legacy helper for the dual-write window.** Once D-Prep ships and the
|
|
|
|
|
|
/// engine reads `role_grants.role` directly, this function becomes unused
|
|
|
|
|
|
/// and is dropped in the cleanup PR. Kept here so callers that still hit
|
|
|
|
|
|
/// `access_grants` and reconstruct a role for display can stay working
|
|
|
|
|
|
/// during the transition.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Emits the new five-role roster on output (`"viewer"` / `"commenter"` /
|
|
|
|
|
|
/// `"contributor"` / `"editor"` / `"owner"`). Note this is **lossy** for
|
|
|
|
|
|
/// permission sets that don't match a bundle exactly — but D-Prep's
|
|
|
|
|
|
/// pre-flight refuses to migrate any such cluster, so post-migration data
|
|
|
|
|
|
/// only contains bundle-shaped sets.
|
2026-05-29 02:16:15 +02:00
|
|
|
|
pub fn role_from_permissions(perms: &[Permission]) -> &'static str {
|
2026-06-17 23:14:25 +02:00
|
|
|
|
let has_read = perms.contains(&Permission::Read);
|
|
|
|
|
|
let has_comment = perms.contains(&Permission::Comment);
|
|
|
|
|
|
let has_create = perms.contains(&Permission::Create);
|
|
|
|
|
|
let has_update = perms.contains(&Permission::Update);
|
|
|
|
|
|
let has_delete = perms.contains(&Permission::Delete);
|
|
|
|
|
|
let has_share = perms.contains(&Permission::Share);
|
|
|
|
|
|
|
|
|
|
|
|
if has_delete && has_share {
|
|
|
|
|
|
"owner"
|
|
|
|
|
|
} else if has_create && has_update {
|
2026-05-29 02:16:15 +02:00
|
|
|
|
"editor"
|
2026-06-17 23:14:25 +02:00
|
|
|
|
} else if has_read && has_create && !has_update {
|
|
|
|
|
|
"contributor"
|
|
|
|
|
|
} else if has_read && has_comment && !has_create && !has_update {
|
|
|
|
|
|
"commenter"
|
2026-05-29 02:16:15 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
"viewer"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 01:05:13 +02:00
|
|
|
|
/// Response for `GET /api/grants/incoming/resources`.
|
2026-05-26 17:52:28 +02:00
|
|
|
|
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
|
2026-05-29 02:16:15 +02:00
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// My-Shares DTOs (GET /api/grants/outgoing/resources)
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/// One (subject, permissions) entry within an outgoing resource item.
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct OutgoingResourceGrantDto {
|
|
|
|
|
|
pub grant_id: Uuid,
|
|
|
|
|
|
/// `"user"` | `"token"`
|
|
|
|
|
|
pub subject_type: String,
|
|
|
|
|
|
pub subject_id: Uuid,
|
|
|
|
|
|
/// Human-readable label (username for users, share name for tokens).
|
|
|
|
|
|
pub subject_display: String,
|
2026-06-17 23:14:25 +02:00
|
|
|
|
/// Role label: `"viewer"` | `"commenter"` | `"contributor"` | `"editor"`
|
|
|
|
|
|
/// | `"owner"`. Emitted by `role_from_permissions()` during the dual-write
|
|
|
|
|
|
/// window; once D-Prep cleanup lands this is read directly from
|
|
|
|
|
|
/// `storage.role_grants.role`. The legacy `"admin"` spelling is no longer
|
|
|
|
|
|
/// emitted — clients that cached it must accept `"owner"` too (the API
|
|
|
|
|
|
/// `Role::parse` still accepts `"admin"` on input for one release).
|
2026-05-29 02:16:15 +02:00
|
|
|
|
pub role: String,
|
|
|
|
|
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
|
|
|
|
/// Whether the token has a password set. Always `false` for user subjects.
|
|
|
|
|
|
pub has_password: bool,
|
2026-06-05 09:46:51 +02:00
|
|
|
|
/// True when the subject is a magic-link-only external user
|
|
|
|
|
|
/// (PR N2). Always `false` for token and group subjects, and for
|
|
|
|
|
|
/// internal users. Used by the My Shares per-row menu to choose
|
|
|
|
|
|
/// between "Resend invitation email" (external) and "Notify by
|
|
|
|
|
|
/// email" (internal).
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub is_external: bool,
|
2026-05-29 02:16:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// One item in the my-shares list.
|
|
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct OutgoingResourceItemDto {
|
|
|
|
|
|
pub resource_type: ResourceTypeDto,
|
|
|
|
|
|
/// Earliest grant date across all subjects on this resource.
|
|
|
|
|
|
pub first_shared_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
|
/// Full resource details. Shape is determined by `resource_type`.
|
|
|
|
|
|
pub resource: ResourceContentDto,
|
|
|
|
|
|
/// One entry per (subject, permissions) pair.
|
|
|
|
|
|
pub grants: Vec<OutgoingResourceGrantDto>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Response for `GET /api/grants/outgoing/resources`.
|
|
|
|
|
|
pub type MySharesDto = CursorListResponse<OutgoingResourceItemDto>;
|