This repository has been archived on 2026-03-27. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drive/docs/permissions.md
Sienna Meridian Satterwhite 58237d9e44 Initial commit — Drive, an S3 file browser with WOPI editing
Lightweight replacement for the upstream La Suite Numérique drive
(Django/Celery/Next.js) built as a single Deno binary.

Server (Deno + Hono):
- S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs
- WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks)
- Ory Kratos session auth + CSRF protection
- Ory Keto permission model (OPL namespaces, not yet wired to routes)
- PostgreSQL metadata with recursive folder sizes
- S3 backfill API for registering files uploaded outside the UI
- OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED)

Frontend (React 19 + Cunningham v4 + react-aria):
- File browser with GridList, keyboard nav, multi-select
- Collabora editor iframe (full-screen, form POST, postMessage)
- Profile menu, waffle menu, drag-drop upload, asset type badges
- La Suite integration service theming (runtime CSS)

Testing (549 tests):
- 235 server unit tests (Deno) — 90%+ coverage
- 278 UI unit tests (Vitest) — 90%+ coverage
- 11 E2E tests (Playwright)
- 12 integration service tests (Playwright)
- 13 WOPI integration tests (Playwright + Docker Compose + Collabora)

MIT licensed.
2026-03-25 18:28:37 +00:00

8.0 KiB

Permissions

Zanzibar-style relationship-based access control via Ory Keto. Sounds fancy, works well.


The Model

Drive uses Ory Keto for permissions. The model: relationship tuples ("user X has relation Y on object Z") checked by graph traversal. If you've read the Zanzibar paper, this is that.

The OPL (Ory Permission Language) definitions live in keto/namespaces.ts. This file is consumed by the Keto server at deploy time — Deno never executes it. It uses TypeScript syntax with Keto's built-in types (Namespace, Context, SubjectSet).

Namespaces

Five namespaces, hierarchical:

User
Group        members: (User | Group)[]
Bucket       owners, editors, viewers  -->  permits: write, read, delete
  Folder     owners, editors, viewers, parents: (Folder | Bucket)[]  -->  permits: write, read, delete
    File     owners, editors, viewers, parents: Folder[]  -->  permits: write, read, delete

User

class User implements Namespace {}

Marker namespace. Users are their Kratos identity UUID.

Group

class Group implements Namespace {
  related: {
    members: (User | Group)[];
  };
}

Groups can contain users or other groups (nested groups). Used in editor/viewer relations via SubjectSet<Group, "members">.

Bucket

class Bucket implements Namespace {
  related: {
    owners: User[];
    editors: (User | SubjectSet<Group, "members">)[];
    viewers: (User | SubjectSet<Group, "members">)[];
  };

  permits = {
    write: (ctx) => owners.includes(subject) || editors.includes(subject),
    read:  (ctx) => write(ctx) || viewers.includes(subject),
    delete: (ctx) => owners.includes(subject),
  };
}

Top of the hierarchy. Only owners can delete. Writers can also read. Standard privilege escalation.

Folder

class Folder implements Namespace {
  related: {
    owners: User[];
    editors: (User | SubjectSet<Group, "members">)[];
    viewers: (User | SubjectSet<Group, "members">)[];
    parents: (Folder | Bucket)[];
  };

  permits = {
    write: (ctx) =>
      owners.includes(subject) ||
      editors.includes(subject) ||
      this.related.parents.traverse((p) => p.permits.write(ctx)),
    // ... same pattern for read and delete
  };
}

The interesting bit is parents.traverse(). No direct permission grant? Keto walks up the parent chain. Folder in a bucket inherits the bucket's permissions. Folder in a folder inherits from that, which may inherit from its parent, all the way up to the bucket. One tuple at the top covers an entire tree.

File

class File implements Namespace {
  related: {
    owners: User[];
    editors: (User | SubjectSet<Group, "members">)[];
    viewers: (User | SubjectSet<Group, "members">)[];
    parents: Folder[];
  };
  // Same traverse pattern as Folder
}

Files can only have Folder parents (not Buckets directly). Same traversal logic.

How Traversal Works

parents.traverse() is where Keto earns its keep. When you check "can user X write file Y?", Keto:

  1. Checks if X is in Y's owners or editors
  2. If not, looks at Y's parents relations
  3. For each parent folder, recursively checks if X can write that folder
  4. Each folder checks its own owners/editors, then traverses up to its parents
  5. Eventually reaches a Bucket (no more parents) and checks there

Give a user editor on a bucket and they can write every folder and file in it. No per-file tuples needed.


The HTTP API

The Keto client in server/keto.ts is a thin fetch wrapper. No SDK — Keto's HTTP API is simple enough that a client library would add more weight than value.

Check Permission

const allowed = await checkPermission(
  "files",                              // namespace
  "550e8400-e29b-41d4-a716-446655440000", // object (file UUID)
  "read",                                // relation
  "kratos-identity-uuid",                // subject_id
);

Under the hood:

POST {KETO_READ_URL}/relation-tuples/check/openapi
Content-Type: application/json

{
  "namespace": "files",
  "object": "550e8400-...",
  "relation": "read",
  "subject_id": "kratos-identity-uuid"
}

Returns { "allowed": true } or { "allowed": false }. Network errors return false — fail closed, always.

Write Relationship

await createRelationship("files", fileId, "owners", userId);
PUT {KETO_WRITE_URL}/admin/relation-tuples
Content-Type: application/json

{
  "namespace": "files",
  "object": "file-uuid",
  "relation": "owners",
  "subject_id": "user-uuid"
}

Write Relationship with Subject Set

For parent relationships (file -> folder, folder -> folder, folder -> bucket):

await createRelationshipWithSubjectSet(
  "files", fileId, "parents",   // this file's "parents" relation
  "folders", parentFolderId, "" // points to the folder
);

Delete Relationship

DELETE {KETO_WRITE_URL}/admin/relation-tuples?namespace=files&object=...&relation=...&subject_id=...

Batch Write

Atomic insert/delete of multiple tuples:

await batchWriteRelationships([
  { action: "delete", relation_tuple: { namespace: "files", object: fileId, relation: "parents", subject_set: { ... } } },
  { action: "insert", relation_tuple: { namespace: "files", object: fileId, relation: "parents", subject_set: { ... } } },
]);
PATCH {KETO_WRITE_URL}/admin/relation-tuples

Expand (Debugging)

const tree = await expandPermission("files", fileId, "read", 3);

Returns the full permission tree. Useful for debugging why someone can or can't access something.


Permission Middleware

server/permissions.ts exports permissionMiddleware for file/folder routes. Straightforward:

  1. Extracts identity from c.get("identity")
  2. Parses file/folder UUID from the URL path (/api/files/:id or /api/folders/:id)
  3. Maps HTTP method to permission: GETread, DELETEdelete, everything else → write
  4. Checks against Keto
  5. 403 if denied

For list operations (no :id in the path), the middleware passes through — per-item filtering happens in the handler:

const filtered = await filterByPermission(files, userId, "read");

Checks permissions in parallel and returns only the allowed items.


Tuple Lifecycle

Tuples live and die with their resources. No orphans.

On File Create

await writeFilePermissions(fileId, ownerId, parentFolderId);

Creates:

  • files:{fileId}#owners@{ownerId} — creator is owner
  • files:{fileId}#parents@folders:{parentFolderId}#... — links to parent folder

On Folder Create

await writeFolderPermissions(folderId, ownerId, parentFolderId, bucketId);

Creates:

  • folders:{folderId}#owners@{ownerId}
  • folders:{folderId}#parents@folders:{parentFolderId}#... — nested in another folder
  • folders:{folderId}#parents@buckets:{bucketId}#... — at the bucket root

On Delete

await deleteFilePermissions(fileId);

Lists all tuples for the file across every relation (owners, editors, viewers, parents) and batch-deletes them. Clean break.

On Move

await moveFilePermissions(fileId, newParentId);

Batch operation: delete old parent tuple, insert new one, atomically. The file never exists in a state with no parent or two parents.


What This Means in Practice

The payoff of Zanzibar for a file system:

  • Share a folder → everything inside it is shared, recursively. No per-file grants.
  • Move a file → it picks up the new folder's permissions automatically.
  • Groups are transitive — add a user to a group, they inherit everything the group has.
  • One tuple on a bucket can cover thousands of files.

The tradeoff is check latency. Every permission check is an HTTP call to Keto, which may traverse multiple levels. For list operations, checks run in parallel, but it's still N HTTP calls for N items. Fine for file browser pagination (50 items). Would need optimization for bulk operations — we'll cross that bridge when someone has 10,000 files in a folder.