2026-05-20 22:56:00 +02:00
|
|
|
//! Authorization port — the trait every service depends on for permission
|
|
|
|
|
//! decisions. Implementations: `PgAclEngine` (v1 default), `OpenFgaEngine`
|
|
|
|
|
//! (future). A `CachedAuthorizationEngine` decorator over either is planned
|
|
|
|
|
//! as a future optimization.
|
|
|
|
|
//!
|
|
|
|
|
//! Architectural rule (see CLAUDE.md):
|
|
|
|
|
//! **AuthZ is enforced exclusively in the application service layer.**
|
|
|
|
|
//! Handlers authenticate the caller and pass `caller_id` to the service;
|
|
|
|
|
//! they never call this trait directly.
|
|
|
|
|
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use crate::common::errors::DomainError;
|
2026-05-24 01:05:13 +02:00
|
|
|
use crate::domain::services::authorization::{
|
2026-05-29 02:16:15 +02:00
|
|
|
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
|
|
|
|
|
ResourceKind, Subject,
|
2026-05-24 01:05:13 +02:00
|
|
|
};
|
2026-05-20 22:56:00 +02:00
|
|
|
|
|
|
|
|
pub trait AuthorizationEngine: Send + Sync + 'static {
|
|
|
|
|
/// Returns true if `subject` has `permission` on `resource`, considering
|
|
|
|
|
/// owner short-circuit AND cascading from folder ancestors.
|
|
|
|
|
///
|
|
|
|
|
/// `check` never errors for "permission denied" — that's a `false` return.
|
|
|
|
|
/// `Err` is reserved for infrastructure failures (DB down, etc.).
|
|
|
|
|
async fn check(
|
|
|
|
|
&self,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
permission: Permission,
|
|
|
|
|
resource: Resource,
|
|
|
|
|
) -> Result<bool, DomainError>;
|
|
|
|
|
|
|
|
|
|
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
|
|
|
|
|
/// `DomainError::not_found` when denied (anti-enumeration — same error as
|
|
|
|
|
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
|
|
|
|
|
async fn require(
|
|
|
|
|
&self,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
permission: Permission,
|
|
|
|
|
resource: Resource,
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
if self.check(subject, permission, resource).await? {
|
2026-06-02 13:26:25 +02:00
|
|
|
// Granted path: high-traffic (every authorized request hits
|
|
|
|
|
// this), so kept at `debug` and structured for grep-friendly
|
|
|
|
|
// filtering. Not an audit event — the audit trail focuses
|
|
|
|
|
// on denials and explicit mutations elsewhere.
|
2026-05-21 11:07:04 +02:00
|
|
|
tracing::debug!(
|
2026-06-02 13:26:25 +02:00
|
|
|
target: "oxicloud::authz",
|
|
|
|
|
event = "authz.allowed",
|
|
|
|
|
subject_type = subject.type_str(),
|
|
|
|
|
subject_id = %subject.id(),
|
|
|
|
|
permission = permission.as_str(),
|
|
|
|
|
resource_type = resource.type_str(),
|
|
|
|
|
resource_id = %resource.id(),
|
2026-05-21 11:07:04 +02:00
|
|
|
"👮🏻♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
|
|
|
|
|
subject,
|
|
|
|
|
permission,
|
|
|
|
|
resource
|
|
|
|
|
);
|
2026-05-20 22:56:00 +02:00
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
let (kind, id) = match resource {
|
|
|
|
|
Resource::Folder(id) => ("Folder", id),
|
|
|
|
|
Resource::File(id) => ("File", id),
|
|
|
|
|
};
|
2026-06-02 13:26:25 +02:00
|
|
|
// Audit-worthy: denials are the interesting signal. Routed
|
|
|
|
|
// through the `audit` tracing target so log aggregators can
|
|
|
|
|
// surface them separately from operational debug traffic.
|
|
|
|
|
// Span context (request_id, client_ip, user_id) is attached
|
|
|
|
|
// automatically by the request-scope span set in
|
|
|
|
|
// `interfaces/middleware/trace_span.rs`, so this log line
|
|
|
|
|
// doesn't need to duplicate those fields — they appear in
|
|
|
|
|
// the structured output of every log written inside the
|
|
|
|
|
// request span.
|
2026-05-21 11:07:04 +02:00
|
|
|
tracing::info!(
|
2026-06-02 13:26:25 +02:00
|
|
|
target: "audit",
|
|
|
|
|
event = "authz.denied",
|
|
|
|
|
subject_type = subject.type_str(),
|
|
|
|
|
subject_id = %subject.id(),
|
|
|
|
|
permission = permission.as_str(),
|
|
|
|
|
resource_type = resource.type_str(),
|
|
|
|
|
resource_id = %resource.id(),
|
2026-05-21 11:07:04 +02:00
|
|
|
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
|
|
|
|
subject,
|
|
|
|
|
permission,
|
|
|
|
|
resource
|
|
|
|
|
);
|
2026-05-20 22:56:00 +02:00
|
|
|
Err(DomainError::not_found(kind, id.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resources explicitly granted to `subject`. Direct grants only — no
|
|
|
|
|
/// cascade expansion. Used by `GET /api/grants/incoming`.
|
|
|
|
|
async fn list_incoming_grants(
|
|
|
|
|
&self,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
permission_filter: Option<Permission>,
|
|
|
|
|
) -> Result<Vec<Grant>, DomainError>;
|
|
|
|
|
|
2026-05-24 01:05:13 +02:00
|
|
|
/// Cursor-paginated list of resources explicitly granted to `subject`,
|
|
|
|
|
/// optionally filtered by resource kind. Multiple permission rows for the
|
|
|
|
|
/// same resource are collapsed into one `IncomingGrantSummary`.
|
|
|
|
|
///
|
|
|
|
|
/// Ordered by `MIN(granted_at) DESC, resource_id DESC` — stable across
|
|
|
|
|
/// concurrent inserts because the cursor encodes both fields.
|
|
|
|
|
///
|
|
|
|
|
/// Pass `kinds = &[]` to return all resource kinds.
|
|
|
|
|
/// Returns `(summaries, next_cursor)` — `next_cursor` is `None` when the
|
|
|
|
|
/// last page has been reached.
|
|
|
|
|
async fn list_incoming_resources_paged(
|
|
|
|
|
&self,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
kinds: &[ResourceKind],
|
|
|
|
|
limit: u32,
|
|
|
|
|
cursor: Option<GrantCursor>,
|
2026-05-27 00:32:10 +02:00
|
|
|
sort_by: &str,
|
2026-05-28 00:44:20 +02:00
|
|
|
reverse: bool,
|
2026-05-24 01:05:13 +02:00
|
|
|
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError>;
|
|
|
|
|
|
2026-05-20 22:56:00 +02:00
|
|
|
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
|
|
|
|
/// must verify the caller has `Share` on the resource before invoking.
|
|
|
|
|
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError>;
|
|
|
|
|
|
|
|
|
|
/// Grants Outgoing — grants created by `granted_by`. Used by
|
|
|
|
|
/// `GET /api/grants/outgoing` ("things I've shared with others").
|
|
|
|
|
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
|
|
|
|
|
|
2026-05-29 02:16:15 +02:00
|
|
|
/// Cursor-paginated list of resources that `granted_by` has shared with
|
|
|
|
|
/// others. Multiple permission rows for the same (subject, resource) pair
|
|
|
|
|
/// are collapsed into one `OutgoingGrantEntry`; multiple subjects on the
|
|
|
|
|
/// same resource are grouped into one `OutgoingResourceSummary`.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `(summaries, next_cursor)`.
|
|
|
|
|
async fn list_outgoing_resources_paged(
|
|
|
|
|
&self,
|
|
|
|
|
granted_by: Uuid,
|
|
|
|
|
limit: u32,
|
|
|
|
|
cursor: Option<GrantCursor>,
|
|
|
|
|
sort_by: &str,
|
|
|
|
|
reverse: bool,
|
|
|
|
|
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError>;
|
|
|
|
|
|
2026-05-20 22:56:00 +02:00
|
|
|
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
|
2026-05-28 19:56:31 +02:00
|
|
|
/// constraint; if the row already exists its `expires_at` is updated.
|
2026-05-20 22:56:00 +02:00
|
|
|
async fn grant(
|
|
|
|
|
&self,
|
|
|
|
|
granted_by: Uuid,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
permission: Permission,
|
|
|
|
|
resource: Resource,
|
2026-05-28 19:56:31 +02:00
|
|
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
2026-05-20 22:56:00 +02:00
|
|
|
) -> Result<Grant, DomainError>;
|
|
|
|
|
|
2026-05-28 19:56:31 +02:00
|
|
|
/// Update `expires_at` on every grant row for the given subject.
|
|
|
|
|
/// Used when a share's expiry is changed — one call updates all
|
|
|
|
|
/// permission rows for that token in a single UPDATE.
|
|
|
|
|
async fn set_expiry_for_subject(
|
|
|
|
|
&self,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
|
|
|
) -> Result<(), DomainError>;
|
|
|
|
|
|
|
|
|
|
/// Update `expires_at` on every grant row for the given `(subject, resource)`
|
|
|
|
|
/// pair. Used by `set_role` to sync the expiry of retained grants when the
|
|
|
|
|
/// caller changes expiry without changing permissions.
|
|
|
|
|
async fn set_expiry_on_resource(
|
|
|
|
|
&self,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
resource: Resource,
|
|
|
|
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
|
|
|
) -> Result<(), DomainError>;
|
|
|
|
|
|
2026-05-20 22:56:00 +02:00
|
|
|
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
|
|
|
|
|
/// the row existed (idempotent revoke).
|
|
|
|
|
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
|
|
|
|
|
|
|
|
|
|
/// Removes every grant whose `resource` matches. Called by lifecycle
|
|
|
|
|
/// hooks when a resource is permanently deleted. Returns the count of
|
|
|
|
|
/// rows removed.
|
|
|
|
|
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError>;
|
|
|
|
|
|
|
|
|
|
/// Removes every grant whose `subject` matches. Called when a user/token
|
|
|
|
|
/// /group is deleted. Returns the count of rows removed.
|
|
|
|
|
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
|
2026-06-17 23:14:25 +02:00
|
|
|
|
|
|
|
|
// ── Role-keyed grant operations (D-Prep dual-write) ────────────────────
|
|
|
|
|
// These manage `storage.role_grants`, the role-keyed table introduced
|
|
|
|
|
// by the D-Prep refactor (see `docs/plan/drive.md` §Prerequisite).
|
|
|
|
|
//
|
|
|
|
|
// During the dual-write window both tables stay populated; the engine
|
|
|
|
|
// reads from `access_grants` until the read-path pivot lands. After
|
|
|
|
|
// the cleanup PR (which drops `access_grants`), these two methods
|
|
|
|
|
// become the ONLY grant write path — the per-permission `grant` /
|
|
|
|
|
// `revoke` above are removed at that point.
|
|
|
|
|
//
|
|
|
|
|
// The handler layer drives these (it knows the Role); lifecycle hook
|
|
|
|
|
// bulk-deletes (`revoke_all_for_*` above) wipe role_grants in lockstep
|
|
|
|
|
// inside their own implementation, so callers using those paths don't
|
|
|
|
|
// need to invoke `clear_role` separately.
|
|
|
|
|
|
|
|
|
|
/// Set the role for a `(subject, resource)` pair. Idempotent via the
|
|
|
|
|
/// UNIQUE `(subject_type, subject_id, resource_type, resource_id)`
|
|
|
|
|
/// constraint — `ON CONFLICT` updates the role + expires_at if they
|
|
|
|
|
/// changed, which is exactly the right semantics for an atomic role
|
|
|
|
|
/// change (e.g. promoting Viewer → Editor in one UPDATE with no race
|
|
|
|
|
/// window, no DELETE+INSERT).
|
|
|
|
|
async fn set_role(
|
|
|
|
|
&self,
|
|
|
|
|
granted_by: Uuid,
|
|
|
|
|
subject: Subject,
|
|
|
|
|
role: crate::application::dtos::grant_dto::Role,
|
|
|
|
|
resource: Resource,
|
|
|
|
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
|
|
|
) -> Result<(), DomainError>;
|
|
|
|
|
|
|
|
|
|
/// Remove the role for a `(subject, resource)` pair. Idempotent —
|
|
|
|
|
/// succeeds whether or not the row existed. Called after `revoke`
|
|
|
|
|
/// succeeds to keep the two tables in sync during dual-write; after
|
|
|
|
|
/// cleanup this is the canonical role-revocation entry point.
|
|
|
|
|
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError>;
|
2026-05-20 22:56:00 +02:00
|
|
|
}
|