47 KiB
OxiCloud ReBAC — Permissions, Grants, and Cascading
Context
OxiCloud currently has a binary authorization model: the owner of a folder/file has every permission, every non-owner has none. The only user-to-user sharing is via anonymous token links (storage.shares) with three coarse flags (read/write/reshare). There is no way for a user to grant a named user fine-grained access to a folder, and no way to list resources others have shared with them.
This plan introduces a Relationship-Based Access Control (ReBAC) model:
- 6 named permissions:
read,create,share,comment,delete,update - Cascading: a grant on a folder applies to all descendants (sub-folders + files) via the existing
storage.folders.lpathltree - Subjects:
user(v1),group(future placeholder in schema),token(anonymous links — unified with existingstorage.shares),external(future in schema for federated identities: Open Cloud Mesh / external OIDC) - Pluggable engine: a single
AuthorizationEnginetrait, default implementation in PostgreSQL, ready for anOpenFgaEnginelater - Roles (Viewer, Commenter, Editor, Manager, Admin) as a UX/DTO sugar layer that the server expands into the underlying permission rows — storage and engine know nothing about roles
User decisions confirmed in conversation:
- Implicit owner — owners have no rows in
access_grants; the engine short-circuits when the caller is the resource's owner. sharepermission lets the holder grant to other named users viaPOST /api/grants(not just create anonymous links).GET /api/grants/incomingreturns direct grants only — one row per resource explicitly granted to the caller. UI drills in via existing listing endpoints.- Unify anonymous link shares under
access_grantswithsubject_type='token'.storage.sharesretains token-lifecycle metadata (password, expiry, access count) only; the permission flags move toaccess_grants. One-time data migration. - Roles in v1, implication chains deferred — roles bundle the 6 raw permissions at the DTO layer (no schema impact). The storage keeps one row per granted permission. Permission implication (e.g.,
update⊃comment⊃read) is a Future optimization that compresses storage but doesn't change observable behavior. - 6 permissions are final for v1 —
read,create,share,comment,delete,update.download(preview-only vs full-bytes) is a candidate for v2 if a "view-only" feature is added; trivial ALTER on the CHECK constraint then. - Schema reserves
subject_type='external'for federated identities (Open Cloud Mesh / external OIDC). v1 adds the enum value and theSubject::External(Uuid)variant; the lookup tableauth.external_subjectsand the federation middleware are deferred. - Architectural rule: AuthZ lives in the service layer, never in handlers. All permission checks go through
AuthorizationEnginevia service methods. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and passcaller_idinto the service — they do NOT perform their own ownership/permission checks. This rule must be documented inCLAUDE.md. - Per-row storage, not bitmap. One row per
(subject, resource, permission)rather than a single row with a packed bitmap. Preserves per-permissiongranted_atandgranted_by(audit value), keeps future per-grantexpires_atan easy addition, and maps 1:1 to OpenFGA tuples. Storage cost at OxiCloud's scale is acceptable and not on a hot path — micro-optimization deferred indefinitely. Matches the per-tuple shape used by Zanzibar, SpiceDB, OpenFGA, Permify.
Out-of-scope (deferred):
- Group creation & membership UI (the schema reserves
subject_type='group', but no group CRUD endpoints in this plan) - External-user federation (
subject_type='external'reserved in schema;auth.external_subjectstable + OCM/OIDC federation middleware come later) - Permission implication graph (
update⊃comment⊃read, etc.) — storage compression with no observable behavior change - Negative grants / deny rules (model stays additive — union of all applicable grants)
- Grant expiry per-row (token expiry stays on
storage.shares) - Comment feature itself (the
commentpermission is reserved; the comments table is a future feature) downloadpermission (separation of preview-only from full-bytes export)- Decision caching (in-process + Redis L2) — see "Future: caching layer" below
Architecture overview
┌────────────────────┐
│ HTTP handlers │ POST/GET/DELETE /api/grants
└──────────┬─────────┘
▼
┌────────────────────┐
│ FolderService │ ────► authz.require(caller, Update, Folder(id))
│ FileManagementSvc │ ────► authz.require(caller, Create, Folder(parent))
│ FileRetrievalSvc │ ────► authz.require(caller, Read, Folder(id))
│ ShareService │ ────► token grants written via authz.grant(Token(t), ...)
└──────────┬─────────┘
▼ Arc<dyn AuthorizationEngine>
┌─────────────────────────────────────────────┐
│ AuthorizationEngine trait │
│ • check(subject, perm, resource) → bool │
│ • require(...) │
│ • grant / revoke │
│ • list_incoming / list_on_resource │
└──────────┬────────────────────────┬─────────┘
▼ ▼
PgAclEngine (v1, default) OpenFgaEngine (future)
▼
storage.access_grants + storage.folders.lpath (cascading)
Schema
New table: storage.access_grants
CREATE TABLE storage.access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject (who has the permission)
-- 'user' — auth.users.id
-- 'group' — future: group membership
-- 'token' — refers to storage.shares.id (anonymous link)
-- 'external' — future: refers to auth.external_subjects.id (OCM / federated OIDC)
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group', 'token', 'external')),
subject_id UUID NOT NULL,
-- Resource (what the permission is on)
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
resource_id UUID NOT NULL,
-- Permission (what action is allowed)
permission TEXT NOT NULL CHECK (permission IN
('read', 'create', 'share', 'comment', 'delete', 'update')),
-- Audit
granted_by UUID NOT NULL, -- user_id who created the grant
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (subject_type, subject_id, resource_type, resource_id, permission)
);
CREATE INDEX idx_grants_subject ON storage.access_grants (subject_type, subject_id);
CREATE INDEX idx_grants_resource ON storage.access_grants (resource_type, resource_id);
granted_by is always a user (group cannot grant). No FK to auth.users on subject_id or granted_by — those tables are in a different schema and the values are polymorphic.
Cleanup of storage.shares
The permission columns move to access_grants. storage.shares keeps token-lifecycle metadata.
-- After data migration (below):
ALTER TABLE storage.shares
DROP COLUMN permissions_read,
DROP COLUMN permissions_write,
DROP COLUMN permissions_reshare;
Data migration (one-off, in the same migration file)
Each existing share becomes one or more rows in access_grants with subject_type='token', subject_id=shares.id:
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by
FROM storage.shares s
WHERE s.permissions_read;
-- 'write' on the old model implies full mutation rights for the link holder.
-- Mapped to read + create + update + delete in the new model.
INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by
FROM storage.shares s
CROSS JOIN (VALUES ('create'), ('update'), ('delete')) AS p(perm)
WHERE s.permissions_write;
INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by
FROM storage.shares s
WHERE s.permissions_reshare;
Lifecycle and grant cleanup (v1 — correctness requirement)
When a resource or subject is permanently deleted, all access_grants rows referring to it must be removed. Otherwise:
- Orphan grants linger forever
- A future UUID reuse (unlikely but possible) could match a stale row
- "Shared with me" returns grants on resources that no longer exist
- Audit queries (
COUNT(*) FROM access_grants) drift away from reality
What triggers cleanup, and what doesn't
| Event | Affected grants | Action |
|---|---|---|
| Folder permanently deleted | resource_type='folder', resource_id=F (plus all descendant files via FK cascade chain) |
DELETE |
| File permanently deleted | resource_type='file', resource_id=X |
DELETE |
| Folder/file moved to trash (soft) | None | No-op — restore must resume access |
| Folder/file restored from trash | None | No-op |
| Trash emptied (permanent destruction) | Same as permanent delete | DELETE |
| User deleted | subject_type='user', subject_id=U. granted_by=U is left as-is (audit trail). |
DELETE the subject rows; keep granter UUIDs |
| Anonymous share token deleted | subject_type='token', subject_id=T |
DELETE |
| Group deleted (future) | subject_type='group', subject_id=G |
DELETE |
Defense-in-depth: DB triggers in the same migration
Even if a future code path bypasses the service layer (admin scripts, bulk maintenance, manual SQL), the database enforces cleanup:
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE resource_type = TG_ARGV[0]
AND resource_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_cleanup_grants_folder
AFTER DELETE ON storage.folders
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder');
CREATE TRIGGER trg_cleanup_grants_file
AFTER DELETE ON storage.files
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file');
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE subject_type = TG_ARGV[0]
AND subject_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_cleanup_grants_user
AFTER DELETE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user');
CREATE TRIGGER trg_cleanup_grants_token
AFTER DELETE ON storage.shares
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token');
storage.files.folder_id REFERENCES storage.folders(id) ON DELETE CASCADE already exists — when a folder is permanently deleted, the file trigger fires for each cascaded child. No need to walk the ltree subtree manually.
Application-layer cleanup (explicit hooks)
The trait gains two cleanup methods so the application layer can invoke cleanup explicitly. This matters because a future cache layer (see Future section) needs to see the invalidation event at the engine boundary — DB triggers happen below the cache:
/// Removes all grants targeting this resource. Returns count removed.
async fn revoke_all_for_resource(&self, resource: Resource)
-> Result<usize, DomainError>;
/// Removes all grants where this subject is the holder.
async fn revoke_all_for_subject(&self, subject: Subject)
-> Result<usize, DomainError>;
Service call sites:
| Service method | Cleanup call |
|---|---|
FolderService::delete_folder_with_perms (permanent delete) |
authz.revoke_all_for_resource(Folder(id)) |
FileManagementService::delete_file_with_perms |
authz.revoke_all_for_resource(File(id)) |
FileManagementService::delete_and_cleanup_with_perms |
Same |
TrashService::delete_permanently |
authz.revoke_all_for_resource(...) per item |
TrashService::empty_trash |
Loop over items, same call |
TrashService::move_to_trash |
No cleanup (soft delete; grants preserved for eventual restore) |
TrashService::restore_item |
No action (grants are still there) |
ShareService::delete_shared_link |
authz.revoke_all_for_subject(Token(share_id)) |
AuthApplicationService::delete_user (admin) |
authz.revoke_all_for_subject(User(user_id)) |
DB triggers stay as defense-in-depth — they catch anything the application forgets, and they catch bulk maintenance operations. The application-layer hook is the canonical path; the trigger is the safety net.
File lifecycle hook integration
There's an existing FileDeletedHook trait in src/application/ports/file_lifecycle.rs that already fires after permanent file deletion (used today for blob-ref-count decrement). Implement an additional hook:
struct GrantCleanupHook { authz: Arc<dyn AuthorizationEngine> }
#[async_trait::async_trait]
impl FileDeletedHook for GrantCleanupHook {
async fn on_file_deleted(&self, file_id: Uuid) -> Result<(), DomainError> {
self.authz.revoke_all_for_resource(Resource::File(file_id)).await?;
Ok(())
}
}
Register it in common/di.rs alongside the existing hooks. The folder/user/token cases get inline calls in their respective services (no hook trait for those yet — adding one if it's needed for a third caller is a future refactor).
Verification — lifecycle scenarios in grants.hurl
-
Resource delete clears grants
- Alice creates folder F, grants Bob read, then permanently deletes F (via empty trash)
- Bob's
GET /api/grants/incomingreturns 0 entries containing F - Direct SQL check (in a debug endpoint or via a test fixture):
SELECT COUNT(*) FROM access_grants WHERE resource_id = Fis 0
-
Trash retains grants
- Alice grants Bob read on F, moves F to trash, then restores F
- Bob still has
readaccess after restore (regression: before any lifecycle change, this must continue to work)
-
User delete clears subject grants but preserves granter
- Alice grants Bob and Carol read on F. Admin deletes Bob.
- Carol's grant on F survives; her
granted_by=alicestill references Alice (intact) - Bob's row is gone
-
Token delete clears token grants
- Alice creates a public share link on F →
access_grantshas rows withsubject_type='token' - Alice deletes the share link → token rows in
access_grantsare gone
- Alice creates a public share link on F →
-
Orphan invariant (post-test SQL)
SELECT COUNT(*) FROM storage.access_grants g WHERE (g.resource_type = 'folder' AND NOT EXISTS (SELECT 1 FROM storage.folders WHERE id = g.resource_id)) OR (g.resource_type = 'file' AND NOT EXISTS (SELECT 1 FROM storage.files WHERE id = g.resource_id));Must always be 0 after every Hurl run.
Domain types
New module src/domain/services/authorization.rs:
use uuid::Uuid;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Subject {
User(Uuid),
Group(Uuid), // schema reserved, no CRUD endpoints in v1
Token(Uuid), // refers to storage.shares.id
External(Uuid), // future: refers to auth.external_subjects.id (OCM / federated OIDC)
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Resource {
Folder(Uuid),
File(Uuid),
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Permission {
Read, Create, Share, Comment, Delete, Update,
}
pub struct Grant {
pub id: Uuid,
pub subject: Subject,
pub resource: Resource,
pub permission: Permission,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
Conversion helpers (as_str() for SQL binding, TryFrom<&str> for row decoding) live alongside.
Port: AuthorizationEngine
New file src/application/ports/authorization_ports.rs:
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
#[async_trait::async_trait]
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Returns true if `subject` has `permission` on `resource`,
/// considering owner short-circuit AND cascading from folder ancestors.
async fn check(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<bool, DomainError>;
/// Convenience: returns Ok(()) when check passes; DomainError::not_found
/// otherwise (anti-enumeration — same error for "no such resource" and
/// "exists but you can't see it").
async fn require(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<(), DomainError> {
if self.check(subject, permission, resource).await? {
Ok(())
} else {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
};
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>;
/// All grants on a specific resource (for "Manage sharing" UI).
/// Caller-side must verify the caller has `share` on the resource.
async fn list_grants_on_resource(
&self,
resource: Resource,
) -> Result<Vec<Grant>, DomainError>;
/// Idempotent (UNIQUE constraint absorbs duplicates).
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<Grant, DomainError>;
/// Revoke by id.
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
}
Wired into AppState in src/common/di.rs as pub authorization: Arc<dyn AuthorizationEngine>. The factory selects the implementation from OXICLOUD_AUTHZ_ENGINE env var (default: postgres).
PgAclEngine implementation
New file src/infrastructure/services/pg_acl_engine.rs. Holds Arc<DbPools>, Arc<FolderDbRepository>, Arc<FileBlobReadRepository> (for owner lookups).
check() algorithm
async fn check(&self, subject: Subject, perm: Permission, resource: Resource) -> Result<bool, _> {
// Step 1 — owner short-circuit (only for user subjects)
if let Subject::User(uid) = subject {
let owner = match resource {
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await?,
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await?,
};
if owner == uid { return Ok(true); }
}
// Step 2 — direct or cascading grant via SQL
self.grant_exists(subject, perm, resource).await
}
Cascading SQL — folders
SELECT EXISTS (
SELECT 1
FROM storage.access_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
WHERE g.subject_type = $1 AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
)
gf.lpath @> target.lpath means "gf is an ancestor of (or equal to) target". Uses the existing GiST index idx_folders_lpath — O(log N).
Cascading SQL — files
A file inherits from its containing folder. Two-branch query:
SELECT EXISTS (
-- direct file grant
SELECT 1 FROM storage.access_grants
WHERE subject_type = $1 AND subject_id = $2 AND permission = $3
AND resource_type = 'file' AND resource_id = $4
UNION ALL
-- cascading from any ancestor folder of the file's containing folder
SELECT 1
FROM storage.access_grants g
JOIN storage.folders gf ON gf.id = g.resource_id
JOIN storage.files target_f ON target_f.id = $4
WHERE g.subject_type = $1 AND g.subject_id = $2
AND g.permission = $3
AND g.resource_type = 'folder'
AND target_f.folder_id IS NOT NULL
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = target_f.folder_id)
)
Files at root (folder_id IS NULL) only match the direct branch.
Engine selection in AppState
// src/common/di.rs (build_app_state)
let authz: Arc<dyn AuthorizationEngine> = match env::var("OXICLOUD_AUTHZ_ENGINE").as_deref() {
Ok("openfga") => unimplemented!("OpenFgaEngine — future"),
_ => Arc::new(PgAclEngine::new(
pools.clone(),
repositories.folder_repository.clone(),
repositories.file_read_repository.clone(),
)),
};
Service integration
Each *_with_perms method already calls verify_owner. Replace the call with authz.require(...). The semantics broaden (grants count, not just ownership) but the signature and error mapping stay the same.
Folder permission mapping (folder_service.rs)
| Method | Permission(s) checked |
|---|---|
create_folder_with_perms(dto, caller) |
Create on Folder(parent_id) |
get_folder_with_perms(id, caller) |
Read on Folder(id) |
rename_folder_with_perms(id, dto, caller) |
Update on Folder(id) |
move_folder_with_perms(id, dto, caller) |
Update on Folder(id) AND Create on Folder(new_parent) |
delete_folder_with_perms(id, caller) |
Delete on Folder(id) |
File permission mapping (file_management_service.rs)
| Method | Permission(s) checked |
|---|---|
move_file_with_perms(file_id, caller, target) |
Update on File(file_id) AND Create on Folder(target) if target is Some |
copy_file_with_perms(file_id, caller, target) |
Read on File(file_id) AND Create on Folder(target) if target is Some |
rename_file_with_perms(file_id, caller, name) |
Update on File(file_id) |
delete_file_with_perms(id, caller) |
Delete on File(id) |
copy_folder_tree_with_perms(src, caller, target, name) |
Read on Folder(src) AND Create on Folder(target) if target is Some |
File retrieval mapping (file_retrieval_service.rs)
get_file_owned, list_files_owned, get_file_stream_owned, get_file_optimized_owned, get_file_range_stream_owned, list_files_batch_for_owner → each becomes authz.require(caller, Read, File(id)) before delegating to the unchecked variant.
Path-based lookups (currently unchecked IDOR risk)
folder_service::get_folder_by_path(path) and file_retrieval_service::get_file_by_path(path) resolve a path then return the resource without any check. After this plan: resolve, then authz.require(caller, Read, …). This closes a known IDOR documented in the previous plan's "Out of scope" section.
Owner short-circuit ensures zero behavior change for current users
Because every existing user-vs-own-resource interaction is an owner check, the engine's owner short-circuit makes those calls equivalent to the current verify_owner. No grant lookups on the hot path until a real cross-user grant exists.
REST endpoints
New handler src/interfaces/api/handlers/grant_handler.rs. Registered under /api/grants.
POST /api/grants — create a grant
{
"subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"permissions": ["read", "comment"]
}
Behavior:
- Authenticated caller required.
authz.require(caller, Share, resource)— caller must haveshareon the resource (owners always pass via short-circuit).- For each permission in the list:
authz.grant(caller_id, subject, perm, resource). UNIQUE constraint makes repeats no-ops. - Returns 201 with the list of created/existing grants.
DELETE /api/grants/{id} — revoke a grant
- Look up the grant.
- Allow if caller is the grant's
granted_byuser OR caller hasshareon the underlying resource. authz.revoke(id).- Returns 204.
GET /api/grants/incoming?permission=read&type=folder — what others have shared with me
Direct grants only (per user decision). Subject is the authenticated caller's User(id). Optional filters by permission and resource type.
Returns:
[
{
"id": "<grant-uuid>",
"resource": { "type": "folder", "id": "<uuid>", "name": "Photos", "path": "..." },
"permission": "read",
"granted_by": { "id": "<uuid>", "username": "alice" },
"granted_at": "2026-05-20T10:51:13Z"
}
]
Resource name/path is enriched via a JOIN to storage.folders / storage.files.
GET /api/grants?resource_type=folder&resource_id={id} — list grants on a resource
Requires authz.require(caller, Share, resource) (you can see who has access only if you can manage sharing).
Returns the same shape as incoming, but for the specified resource.
GET /api/grants/outgoing — grants I have created
Filtered by granted_by = caller_id. Useful for "Manage all my shares" UI.
Roles (UX / DTO layer)
Roles are preset bundles of permissions that the API exposes for UI convenience. The server expands a role into its underlying permission list before writing rows; storage and engine know nothing about roles.
Role catalog
| Role | Permissions |
|---|---|
Viewer |
read |
Commenter |
read, comment |
Editor |
read, comment, create, update |
Manager |
read, comment, create, update, share |
Admin |
read, comment, create, update, share, delete |
Defined as a Rust enum in src/application/dtos/grant_dto.rs:
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role { Viewer, Commenter, Editor, Manager, Admin }
impl Role {
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Contributor => &[Permission::Read, Permission::Create],
Role::Editor => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update],
Role::Owner => &[Permission::Read, Permission::Comment,
Permission::Create, Permission::Update,
Permission::Share, Permission::Delete,
Permission::Manage],
}
}
}
Note (D-Prep, 2026-06-17): the
Managerrole was retired before shipping (its bundle was a strict subset ofOwner); the historicalAdminrole was renamed toOwnerto disambiguate fromUserRole::Admin(the user-account privilege) and match Drive plan terminology.Contributoris the new drop-zone role. The actual on-the-wire enum lives insrc/application/dtos/grant_dto.rs; that file is the canonical source of truth for bundle expansion. The pivot to role-keyed storage (role_grantstable) also happened in D-Prep — seedocs/architecture/rebac-authorization.mdfor the dual-write timeline.
POST /api/grants accepts either shape
// Either explicit permissions:
{ "subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"permissions": ["read", "comment"] }
// Or a role:
{ "subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"role": "editor" }
The DTO uses #[serde(untagged)] or two separate fields with server-side validation that exactly one is provided. Server expands role → permission list, then writes the rows.
PUT /api/grants/role — reconcile a subject's role on a resource
{ "subject": { "type": "user", "id": "<uuid>" },
"resource": { "type": "folder", "id": "<uuid>" },
"role": "manager" }
Behavior:
authz.require(caller, Share, resource).- Read the current set of permissions held by
subjectonresource. - Compute the diff vs
role.expand(): which permissions to INSERT, which to DELETE. - Apply both in one transaction.
- Returns 200 with the new full set.
This is the canonical way for a UI to set "Bob is now Editor of /Photos" — the frontend doesn't track which specific rows exist.
Why roles are pure DTO sugar (not stored)
- Roles can evolve without schema migrations — adding "Reviewer" tomorrow is a code change, no ALTER.
- Mixing is allowed — a future UI can start from "Editor" and add
sharemanually; the result is a custom mixture, not "Editor + share". - OpenFGA migration unaffected — tuples are per-permission regardless of how they were granted.
- Revocation is granular — removing a single permission doesn't require touching a "role" abstraction.
File changes
New files
migrations/2026MMDDHHMMSS_rebac_access_grants.sql— table + indexes + data migration fromstorage.sharessrc/domain/services/authorization.rs—Subject(includingExternalvariant),Resource,Permission,Grantenums/structssrc/application/ports/authorization_ports.rs—AuthorizationEnginetraitsrc/infrastructure/services/pg_acl_engine.rs— default implsrc/interfaces/api/handlers/grant_handler.rs— REST endpoints (POST/DELETE/GET /api/grants,PUT /api/grants/role,GET /api/grants/incoming|outgoing)src/application/dtos/grant_dto.rs— request/response DTOs includingRoleenum +Role::expand()
Modified
CLAUDE.md— add a section under the Backend Architecture documenting the rule: AuthZ is enforced exclusively in the application service layer. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) only authenticate the caller and passcaller_idto the service. Never duplicate permission checks at the exposition layer. This prevents drift between layers and matches the existing pattern of*_with_permsmethods.src/common/di.rs— wireauthzintoAppState; inject into Folder/FileManagement/FileRetrieval servicessrc/application/services/folder_service.rs— replaceverify_ownercalls withauthz.require; add path-based check toget_folder_by_pathsrc/application/services/file_management_service.rs— same; remove the privateverify_target_folder_ownerwrapper (engine does both)src/application/services/file_retrieval_service.rs— replace owner checks; add path-based check toget_file_by_pathsrc/application/services/share_service.rs— oncreate_shared_link, also write the correspondingaccess_grantsrows so that token-based access goes through the engine uniformlysrc/interfaces/api/routes.rs— register/api/grantsroutessrc/application/ports/mod.rs—pub mod authorization_portssrc/domain/services/mod.rs—pub mod authorizationsrc/infrastructure/services/mod.rs—pub mod pg_acl_engine
Removed
- The fields
permissions_read,permissions_write,permissions_resharefromstorage.shares(and their domain/dto representations) — replaced byaccess_grantsrows. Migration script preserves existing data.
Verification
Build & lint
cargo fmt --all
cargo clippy --all-features --all-targets -- -D warnings
cargo test --workspace
Hurl integration tests (new file tests/api/grants.hurl)
Run via the existing tests/api/run.sh (add permissions.hurl AND the new grants.hurl to the runner).
Setup (admin token + bob token, both already available from permissions.hurl):
-
Grant + check
- Alice creates folder
/api/folders {parent: home, name: "Shared"}→ capturesfolder_id - Alice grants Bob
readon the folder:POST /api/grantswith subject=user/bob, resource=folder/Shared, perms=[read] - Bob calls
GET /api/folders/{folder_id}/contents→ 200 (was 404 before grant) - Bob calls
PUT /api/folders/{folder_id}/rename→ 404 (noupdategrant)
- Alice creates folder
-
Cascading
- Alice creates a sub-folder
Shared/Inner - Alice uploads a file
vacation.jpginsideInner - Bob (with
readonShared) callsGET /api/files/{file_id}→ 200 (cascaded via lpath)
- Alice creates a sub-folder
-
Incoming list
- Bob calls
GET /api/grants/incoming→ returns 1 entry with the folder, permission=read
- Bob calls
-
Re-share
- Carol (new user) — Alice grants Bob
shareadditionally - Bob now successfully calls
POST /api/grantsto grant Carolread - Carol calls
GET /api/folders/{folder_id}/contents→ 200
- Carol (new user) — Alice grants Bob
-
Revoke
- Alice deletes Bob's grant via
DELETE /api/grants/{grant_id}→ 204 - Bob's
GET /api/folders/{folder_id}/contents→ 404
- Alice deletes Bob's grant via
-
Roles
- Alice grants
POST /api/grantswithrole: "editor"for Bob on a new folder - Bob can read AND rename a file inside (Editor includes
update) - Bob CANNOT delete the folder (Editor excludes
delete) → 404 - Alice calls
PUT /api/grants/rolewithrole: "admin"for Bob - Bob can now delete the folder → 200
- Alice calls
PUT /api/grants/rolewithrole: "viewer"for Bob - Bob loses update/delete/comment/create/share; can only read → rename returns 404
- Alice grants
-
Token unification (regression)
permissions.hurlalready covers existing share-link flows. After migration, those still pass — the engine reads fromaccess_grantsfor token subjects, transparently.
Unit tests
- New tests in
src/application/services/idor_protection_test.rs:engine.check(non_owner, Read, file)with no grant → falseengine.check(owner, _, _)→ true (owner short-circuit) without touchingaccess_grantsengine.check(grantee, Read, file)aftergrant()→ true- Cascade: grant on parent folder → child file check returns true
- Revoke removes the row → next check returns false
- Tests use a stub repo for owners and an in-memory grant store, OR run against the real PG via the existing test harness.
Storage growth sanity check (manual)
- Before migration: count rows in
storage.shares. - After migration: count rows in
storage.access_grantswithsubject_type='token'≈ shares × {1 + flag count}. - Confirm no owner-self rows were created (validates implicit-owner choice).
Rollout sequencing
- PR 1 — migration + schema (creates
access_grants, migratesstorage.sharespermission flags). No code changes yet. Deploy and verify the migration runs cleanly. - PR 2 —
AuthorizationEnginetrait +PgAclEngine+ DI wiring. No services changed yet — engine is built but unused. - PR 3 — service integration. Replace
verify_ownerwithauthz.requirein*_with_permsmethods. Add path-based checks. Hurl integration:permissions.hurlmust still pass (engine's owner short-circuit ensures no behavior change for existing flows). - PR 4 — REST endpoints (
/api/grants/*) + newgrants.hurltests covering cross-user grant/revoke/cascade scenarios. - PR 5 —
share_servicewritesaccess_grantsrows for new token shares (so token authz goes through the engine). At this pointstorage.shares.permissions_*columns are no longer read from anywhere — drop them.
Each PR is independently mergeable and the system stays functional throughout. PR 1-3 ship with zero observable change to users; PR 4 introduces the new feature; PR 5 retires the dead columns.
Future: caching layer (in-process + Redis)
Why
Every mutating service operation calls authz.require(...) at least once. The cascading SQL (gf.lpath @> target.lpath joined against access_grants) is O(log N) per check thanks to the GiST index, but at scale these costs compound:
- A batch delete of 1000 files = 1000 checks
- WebDAV PROPFIND on a deep folder may call
readfor every descendant - A user with many active sessions hammers the same
(subject, perm, resource)repeatedly - Cascading means even a "no" answer requires walking the full ancestor chain — short-circuited only when the GiST index returns empty
A cache changes the cost of repeat checks from "JOIN + ltree GiST lookup" to "HashMap get" (L1) or "Redis GET" (L2). For mostly-read workloads, hit rate should be very high.
Architecture — decorator over the trait
The AuthorizationEngine trait is unchanged. A CachedAuthorizationEngine wraps any underlying engine:
pub struct CachedAuthorizationEngine<E: AuthorizationEngine> {
inner: E,
l1: moka::future::Cache<DecisionKey, bool>, // in-process, fast, per-instance
l2: Option<Arc<dyn DistributedCache>>, // Redis (or similar), shared across instances
}
#[derive(Hash, Eq, PartialEq, Clone)]
struct DecisionKey {
subject: Subject,
permission: Permission,
resource: Resource,
}
impl<E: AuthorizationEngine> AuthorizationEngine for CachedAuthorizationEngine<E> {
async fn check(&self, subject: Subject, perm: Permission, resource: Resource)
-> Result<bool, DomainError>
{
let key = DecisionKey { subject, permission: perm, resource };
// L1: in-process
if let Some(decision) = self.l1.get(&key).await { return Ok(decision); }
// L2: Redis
if let Some(l2) = &self.l2
&& let Some(decision) = l2.get(&key).await? {
self.l1.insert(key.clone(), decision).await;
return Ok(decision);
}
// Miss — query the underlying engine and backfill
let decision = self.inner.check(subject, perm, resource).await?;
self.l1.insert(key.clone(), decision).await;
if let Some(l2) = &self.l2 {
l2.set(&key, decision, CACHE_TTL).await?;
}
Ok(decision)
}
async fn grant(...) -> Result<Grant, _> {
let g = self.inner.grant(...).await?;
self.invalidate_for(g.subject, g.resource).await;
Ok(g)
}
async fn revoke(...) -> Result<(), _> {
self.inner.revoke(...).await?;
// need the affected (subject, resource) — revoke() takes only grant_id today,
// so the trait gains a small helper or returns the deleted grant for invalidation.
Ok(())
}
}
Three tiers worth distinguishing
-
Per-request cache (cheapest to ship). A
HashMap<DecisionKey, bool>lives in a request extension. Cleared at request end. Avoids repeat checks during a single batch op (e.g., a 1000-file delete only hits the DB once per unique(subject, perm, file)). No invalidation problem — request scope. -
In-process L1 (
moka::future::Cache). Bounded LRU with TTL. Per-server-instance. Hit on hot resources, no network. Invalidated on localgrant/revoke. -
Distributed L2 (Redis). Shared across multiple OxiCloud server instances. Worth adding only when running multi-instance (HA / horizontal scale). Cross-instance invalidation via Redis pub/sub or short TTL.
Invalidation — the hard part
Cascading makes per-key invalidation hard. When Alice grants Bob read on folder F:
- Bob's
readon F becomes true → invalidate(bob, read, F) - Bob's
readon every descendant of F also becomes true (live cascade) → invalidate(bob, read, child)for every child
There's no efficient way to enumerate all descendants and invalidate each entry. Three pragmatic options:
| Strategy | Granularity | Implementation cost | Trade-off |
|---|---|---|---|
| Subject-scoped flush | All cached entries for subject regardless of resource |
Cheap (one bucket -> drop) |
Coarse — bob's checks on unrelated resources also dropped |
| Resource-scoped flush | All entries on resource and its descendants |
Need to walk ltree on invalidation OR mark a "version" on the folder root | More targeted but more code |
| Short TTL + eventual consistency | None — wait for TTL | Trivial | Stale true after revoke for up to TTL seconds (bad), stale false after grant for up to TTL seconds (mildly annoying) |
Recommendation when this lands: subject-scoped flush as the simple default; switch to resource-scoped flush if subject churn is too painful for cache hit rate.
Cache key normalization for cascading
Important detail: the cached entry for "bob can read folder F" doesn't need a separate entry per descendant. The engine's check(bob, read, child) would still go through the SQL because the cache key is (bob, read, child), distinct from (bob, read, F). So caching gives no descendant boost UNLESS we:
- Pre-resolve to "bob's effective grants" once (list all
(subject_id, permission, resource_id)rows for bob) and cache that bundle, then evaluate anycheck()against the in-memory bundle. This is a classic Zanzibar-style "user list" cache.
That's a separate L1 design: cache the bundle of bob's grants, not individual decisions. Hit rate is high (one cached blob per active user). Invalidation is per-subject (when bob receives/loses a grant). The check becomes "is the requested resource an ltree descendant of any folder in bob's grant bundle?" — done in process, no DB round-trip.
This is probably the right L1 shape for OxiCloud given the cascade semantics.
Config
// In OxiCloud config:
OXICLOUD_AUTHZ_CACHE=disabled // default in v1
OXICLOUD_AUTHZ_CACHE=in_memory // L1 only
OXICLOUD_AUTHZ_CACHE=redis // L1 + L2 (requires OXICLOUD_REDIS_URL)
OXICLOUD_AUTHZ_CACHE_TTL=300 // seconds
The engine selection in common/di.rs wraps the underlying PgAclEngine based on this config. Disabled by default to keep v1 minimal.
Why this is a clean follow-up, not v1
- The
AuthorizationEnginetrait is unchanged → the cache is a pure decorator - Owner short-circuit already avoids the DB for the most common case (caller acting on own resources) — caching's marginal value is highest only once cross-user grants are common
- Adding caching too early hides whether the uncached SQL is actually slow at production scale; better to measure first
- Redis adds a new infrastructure dependency; introducing it before there's measured pressure is premature
When to revisit
Add per-request cache when batch ops show repeated DB checks in tracing. Add L1 in-process cache when single-instance check p99 latency exceeds a threshold under cross-user workloads. Add L2 Redis only when running multi-instance and cross-instance cache coherence becomes a hit-rate problem.
Future (v2): extend ReBAC to calendars, address books, playlists
Three resource types already have user-to-user sharing implemented as bespoke per-feature tables. After v1 proves the engine shape on files/folders, absorb them in a follow-up plan per resource type.
Existing share infrastructure to migrate
| Resource | Today's share table | Today's permission shape |
|---|---|---|
| Calendar (CalDAV) | caldav.calendar_shares (calendar_id, user_id, access_level) |
`'read' |
| Address book (CardDAV) | carddav.address_book_shares (address_book_id, user_id, can_write) |
binary can_write |
| Playlist (audio) | audio.playlist_shares (playlist_id, user_id, can_write) |
binary can_write |
Required changes per resource type
Each migration is small and self-contained:
- Schema — extend
resource_typeCHECK constraint:ALTER TABLE storage.access_grants DROP CONSTRAINT access_grants_resource_type_check, ADD CONSTRAINT access_grants_resource_type_check CHECK (resource_type IN ('folder', 'file', 'calendar', 'address_book', 'playlist')); - Domain — extend
Resourceenum withCalendar(Uuid),AddressBook(Uuid),Playlist(Uuid). - Engine — no cascading needed (these are flat containers, not trees). The
check()SQL becomes a simple direct lookup with no ltree join for these branches. - Service refactor — remove the bespoke
share_calendar/share_address_book/share_playlistmethods. Sharing goes throughPOST /api/grantsuniformly. - Cleanup triggers — add AFTER DELETE triggers on
caldav.calendars,carddav.address_books,audio.playlists(same pattern as v1 triggers onstorage.folders/storage.files). - Data migration — convert existing share rows:
INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by) SELECT 'user', cs.user_id, 'calendar', cs.calendar_id, p.perm, c.owner_id FROM caldav.calendar_shares cs JOIN caldav.calendars c ON c.id = cs.calendar_id CROSS JOIN LATERAL ( SELECT unnest(CASE cs.access_level WHEN 'read' THEN ARRAY['read'] WHEN 'write' THEN ARRAY['read','update','create','delete'] WHEN 'owner' THEN ARRAY['read','update','create','delete','share'] END) AS perm ) p; -- Same shape for address_book_shares (FALSE → ['read'], TRUE → ['read','update','create','delete']) -- Same shape for playlist_shares. - Protocol mapping (CalDAV / CardDAV only) — the WebDAV sharing properties (
<DAV:share-access>,<oc:invite>) need to be re-implemented on top of the new grants. This is the largest unknown and the main reason for deferral.
Why deferred, not in v1
- v1 must prove the
AuthorizationEnginetrait shape works before three more services land on it. If the trait needs an adjustment after running it on files, fixing it before three more migrations is much cheaper. - The CalDAV/CardDAV protocol layer expects sharing semantics expressed via WebDAV properties — that's its own piece of work decoupled from the v1 grant table.
- Calendars/playlists are niche compared to file sharing — low migration risk if deferred.
- The 6-permission model already accommodates these without extension; the change is mechanical, just not yet.
Suggested rollout (one PR per resource)
- PR A — calendars: schema constraint + Resource enum + CalendarService refactor + Hurl tests + CalDAV property mapping
- PR B — address books: same shape, simpler (binary
can_write) - PR C — playlists: same shape, also binary
- Each PR drops the corresponding bespoke share table at the end.
Future: OpenFGA plug-in
Implementing OpenFgaEngine later requires:
- Define the OpenFGA model:
type folder relations define parent: [folder] define reader: [user, folder#reader] define creator: [user, folder#creator] define updater: [user, folder#updater] define deleter: [user, folder#deleter] define sharer: [user, folder#sharer] define owner: [user] type file relations define parent: [folder] define reader: [user, folder#reader] ... - On engine init, sync owner relationships (walk
storage.folders+storage.files). - On every
grant(), also write the tuple to OpenFGA. - On
check(), query OpenFGA's/checkendpoint.
The AuthorizationEngine trait shape is identical, so swapping engines is a configuration change. The PG engine remains the source of truth for storage.access_grants rows; OpenFGA becomes an indexed read cache.