Permission System
Status: Implemented Crate: crates/domain/src/capability.rs, crates/application/src/permission/
Overview
Section titled “Overview”The permission system implements capability-based access control for workspace operations. Rather than assigning roles (viewer, editor, admin), each participant receives a set of medium-grained capabilities per workspace. This model applies uniformly to all participant kinds — workspace owners, remote agents, and human collaborators.
The system is built around two core invariants:
- The workspace owner always has all capabilities, resolved locally with no network dependency (works offline).
PermissionGuardis unforgeable — it can only be constructed within theapplicationcrate viaCapabilityResolveror the controlledsystem_guard()factory.
Permission checks are enforced in the application layer (use cases), not the framework layer. Every entry point — Tauri
commands, MCP tools, HTTP bridge — flows through the same use case and the same guard.require() call.
Architecture
Section titled “Architecture”Permission Resolution Flow
Section titled “Permission Resolution Flow”For non-owner participants (agents, collaborators), the flow diverges at CapabilityResolver:
Component Inventory
Section titled “Component Inventory”| Component | Location | Role |
|---|---|---|
Capability enum | crates/domain/src/capability.rs | 19 medium-grained capability variants |
PermissionGuard | crates/application/src/permission/permission_guard.rs | Unforgeable proof of resolved capabilities |
CapabilityResolver | crates/application/src/permission/capability_resolver.rs | Single trust boundary for guard construction |
CapabilityRepository trait | crates/application/src/permission/services.rs | Cloud capability fetch abstraction |
PermissionError | crates/application/src/permission/services.rs | Denied and ResolutionFailed variants |
system_guard() | crates/application/src/permission/mod.rs | Controlled factory for background task guards |
empty_guard_for_testing() | crates/application/src/permission/mod.rs | Zero-capability guard for infrastructure tests |
SupabaseCapabilityRepository | crates/infrastructure/supabase/src/capability/mod.rs | PostgREST-based cloud capability fetch |
| Test helpers | crates/application/src/permission/test_helpers.rs | owner_guard(), guard_with(), MockCapabilityRepository |
Trust Chain
Section titled “Trust Chain”Framework Layer (Tauri / MCP / HTTP bridge) | vCapabilityResolver.resolve_owner() | vIdentityStore.get_identity() <-- OS keychain (offline, cached) | vPermissionGuard { participant_id, capabilities } <-- unforgeable token | vuse_case.execute(&guard, ...args) | vguard.require(Capability::PagesWrite)? <-- first line of execute()Key Design Decisions
Section titled “Key Design Decisions”Capability-Based, Not Role-Based
Section titled “Capability-Based, Not Role-Based”Capabilities are assigned directly to participants, not bundled into named roles. This avoids the rigidity of predefined roles and maps cleanly to product packaging (free vs. premium feature gating). If role-like convenience is needed later, roles would be implemented as named capability bundles — a UI shortcut, not a separate authorization concept.
19 Medium-Grained Capabilities
Section titled “19 Medium-Grained Capabilities”The Capability enum defines 19 variants organized by domain:
| Domain | Capabilities | Granularity |
|---|---|---|
| Pages | PagesRead, PagesWrite, PagesOrganize, PagesDelete | Tiered: read -> write -> organize -> delete |
| Search | SearchUse | Single capability for FTS5 + semantic |
| History | HistoryRead | Event log and timeline queries |
| Bookmarks | BookmarksRead, BookmarksManage | Read vs. create/delete |
| Workspace | WorkspaceManage | Settings, admin operations |
| Import | ImportExecute | External markdown import |
| Sync | SyncManage | Cloud sync state control |
| Attachments | AttachmentsRead, AttachmentsWrite | File metadata and upload/delete |
| Types | TypesRead, TypesWrite | Type definition CRUD |
| Tags | TagsRead, TagsWrite | Tag and tag group CRUD |
| Properties | PropertiesRead, PropertiesWrite | Property definition management |
Page operations are intentionally tiered — an agent that can edit content should not necessarily restructure the page hierarchy or permanently delete pages.
BookmarksRead gates get_bookmark and list_bookmarks. BookmarksManage gates create_bookmark and
delete_bookmark. These were originally named CheckpointsRead/CheckpointsManage for the git-based checkpoint system
and were renamed when bookmarks replaced checkpoints.
Unforgeable Guard via Module Privacy
Section titled “Unforgeable Guard via Module Privacy”PermissionGuard::new() is pub(crate), meaning only code within the application crate can construct guards. Rust’s
module privacy system enforces this at compile time — no runtime checks needed. This eliminates the class of bugs where
a framework layer constructs a guard directly, bypassing capability resolution.
Permission Checks Live in Use Cases, Nowhere Else
Section titled “Permission Checks Live in Use Cases, Nowhere Else”Framework layers (Tauri commands, MCP tools, HTTP bridge) resolve the guard via CapabilityResolver and pass it to use
cases. They never call guard.require() themselves. This ensures every entry point flows through the same permission
check in the application layer.
// Framework: resolves guard, passes to use caselet guard = state.resolve_owner_guard()?;let use_case = DeletePageUseCase::new(state.page_repo());use_case.execute(&guard, &workspace.path, &slug)?;
// Application: single source of truth for permission checkimpl DeletePageUseCase { pub fn execute(&self, guard: &PermissionGuard, ...) -> Result<...> { guard.require(Capability::PagesDelete)?; // ... business logic }}System Guard for Background Tasks
Section titled “System Guard for Background Tasks”Background tasks (embedding indexing, history collapse, import reconciliation) run without a user-facing auth context.
The system_guard() factory in the permission module creates an all-capabilities guard with SYSTEM_PARTICIPANT_ID
(nil UUID). This is the only authorized path for background-task guards — it is deliberately auditable.
pub fn system_guard() -> PermissionGuard { PermissionGuard::system()}Usage in framework layer:
let guard = application::system_guard();Forward-Compatible Cloud Parsing
Section titled “Forward-Compatible Cloud Parsing”SupabaseCapabilityRepository parses the PostgreSQL TEXT[] column from PostgREST JSON. Unknown capability names are
silently ignored via serde_json::from_str fallback. This means a newer server can send capabilities that an older
client does not recognize — the client simply ignores them, maintaining forward compatibility.
Owner Resolution is Offline-Only
Section titled “Owner Resolution is Offline-Only”The current implementation only exposes resolve_owner() on CapabilityResolver. The non-owner path
(resolve_non_owner() via CapabilityRepository) is scaffolded but operates in single-user mode, where the owner invariant guarantees all capabilities without a network round-trip.
Domain Rules
Section titled “Domain Rules”| Rule | Enforcement |
|---|---|
| Owner always has all capabilities | CapabilityResolver.resolve_owner() returns Capability::all() unconditionally |
| Guard is unforgeable | PermissionGuard::new() is pub(crate) — compile-time module privacy |
| Permission check is first line of use case | Convention enforced by code review and denial tests |
| Error messages do not leak capability names | PermissionError::Denied uses generic Display (“Permission denied”) |
Capability::all() is exhaustive | Exhaustive match test causes compile error on new variant |
| Capabilities are per-workspace | CapabilityRepository.get_capabilities() takes workspace_id |
| No local capability storage for non-owners | Non-owners always require connection; cloud-fetched |
Key Code Paths
Section titled “Key Code Paths”Guard Resolution (Owner Path)
Section titled “Guard Resolution (Owner Path)”- Framework calls
state.resolve_owner_guard()(Tauri) orstate.resolve_owner_guard()(MCP) CapabilityResolver::resolve_owner()callsIdentityStore::get_identity()- Identity found ->
PermissionGuard::new(identity.id, Capability::all()) - Guard returned to framework, passed into use case
Guard Resolution (Non-Owner Path)
Section titled “Guard Resolution (Non-Owner Path)”CapabilityResolverchecksparticipant.id == workspace.owner_id- Not owner ->
CapabilityRepository::get_capabilities(workspace_id, participant_id) SupabaseCapabilityRepositoryauthenticates viaensure_valid_token()- PostgREST query:
SELECT capabilities FROM workspace_participants WHERE workspace_id = ? AND participant_id = ? parse_capabilities_from_row()deserializes snake_case strings intoCapabilityvariantsPermissionGuard::new(participant_id, resolved_caps)
Use Case Permission Check
Section titled “Use Case Permission Check”Every use case follows the same pattern:
pub fn execute(&self, guard: &PermissionGuard, /* args */) -> Result<T, E> { guard.require(Capability::SomeCapability)?; // business logic}Use cases that call other use cases internally pass the same guard through — no re-resolution.
MCP Tool Permission Gating
Section titled “MCP Tool Permission Gating”MCP tools are execution-gated, not visibility-gated. All tools appear in the MCP tool schema regardless of the
caller’s capabilities. When a tool is invoked without sufficient permissions, the use case returns a structured
PermissionError::Denied. This design allows agents to see what tools exist and negotiate for access if denied, rather
than having tools silently disappear.
Default Capability Sets by Participant Kind
Section titled “Default Capability Sets by Participant Kind”| Capability | Owner | Agent (default) | Agent (restricted) |
|---|---|---|---|
PagesRead | yes | yes | yes |
PagesWrite | yes | yes | yes |
PagesOrganize | yes | yes | no |
PagesDelete | yes | no | no |
SearchUse | yes | yes | yes |
HistoryRead | yes | yes | no |
BookmarksRead | yes | no | no |
BookmarksManage | yes | no | no |
WorkspaceManage | yes | no | no |
ImportExecute | yes | no | no |
SyncManage | yes | no | no |
AttachmentsRead | yes | yes | yes |
AttachmentsWrite | yes | yes | no |
TypesRead | yes | yes | yes |
TypesWrite | yes | no | no |
TagsRead | yes | yes | yes |
TagsWrite | yes | yes | no |
PropertiesRead | yes | yes | yes |
PropertiesWrite | yes | yes | no |
Agent defaults are intentionally conservative — read, write, organize, search, and history. No delete, no workspace management, no import, no sync. Users can grant additional capabilities explicitly.
Error Handling
Section titled “Error Handling”PermissionError Variants
Section titled “PermissionError Variants”| Variant | Meaning | Display |
|---|---|---|
Denied { capability, participant_id } | Participant lacks a required capability | "Permission denied" (generic, no capability leak) |
ResolutionFailed(String) | Could not resolve capabilities (no identity, network error, parse error) | "Capability resolution failed: {detail}" |
The Denied variant intentionally hides capability and participant details from its Display implementation. The
fields are available for server-side logging and diagnostics but are not exposed to external callers. This prevents
capability enumeration attacks.
Error Propagation
Section titled “Error Propagation”Use case errors typically wrap PermissionError into their domain-specific error type:
// Example: PageError in application layerpub enum PageError { Permission(PermissionError), NotFound(String), // ...}
impl From<PermissionError> for PageError { fn from(e: PermissionError) -> Self { PageError::Permission(e) }}Framework layers map these to transport-appropriate errors (Tauri CommandError, MCP tool error, HTTP status).
Granularity Evolution
Section titled “Granularity Evolution”The current 19 capabilities can be split into finer-grained operations without API changes. For example, PagesDelete
could split into PagesTrash (soft-delete) and PagesPermanentDelete. The PermissionGuard and CapabilityResolver
APIs remain unchanged — only Capability::all() and the exhaustive test need updating.
Testing
Section titled “Testing”Test Helpers
Section titled “Test Helpers”The permission module provides test helpers in crates/application/src/permission/test_helpers.rs:
owner_guard()— creates a guard with all capabilities (simulates workspace owner)guard_with(&[Capability])— creates a guard with specific capabilitiesMockCapabilityRepository— configurable mock for non-owner resolution tests
Denial Test Convention
Section titled “Denial Test Convention”Every use case with a guard.require() call should have a corresponding denial test:
#[test]fn test_delete_page_rejected_without_pages_delete() { let guard = guard_with(&[Capability::PagesRead]); let use_case = DeletePageUseCase::new(Arc::new(repo)); let result = use_case.execute(&guard, Path::new("/tmp"), "test-page"); assert!(matches!(result, Err(PageError::Permission(_))));}Naming convention: test_[usecase]_rejected_without_[capability]
Exhaustive Capability Test
Section titled “Exhaustive Capability Test”A test in capability.rs uses an exhaustive match to ensure Capability::all() stays in sync with the enum. Adding a
new variant without updating the match causes a compile error.
Related
Section titled “Related”- ADR-008: Capability-Based Permission Model —
docs/ADR/008-capability-based-permission-model.md(developer docs) - ADR-007: Agent Integration via MCP Server and Sync Protocol —
docs/ADR/007-agent-integration-mcp-and-sync.md(developer docs) - Permission Guard: Single Source of Truth —
docs/solutions/architecture/permission-guard-single-source-of-truth.md(developer docs) crates/application/src/auth/—IdentityStoretrait used byCapabilityResolvercrates/infrastructure/supabase/src/capability/mod.rs— cloud capability resolutionsupabase/migrations/00000000000000_baseline.sql—workspace_participantstable schema
Was this page helpful?
Thanks for your feedback!