Skip to content
Documentation GitHub
Platform

Permission System

Status: Implemented Crate: crates/domain/src/capability.rs, crates/application/src/permission/

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:

  1. The workspace owner always has all capabilities, resolved locally with no network dependency (works offline).
  2. PermissionGuard is unforgeable — it can only be constructed within the application crate via CapabilityResolver or the controlled system_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.

For non-owner participants (agents, collaborators), the flow diverges at CapabilityResolver:

ComponentLocationRole
Capability enumcrates/domain/src/capability.rs19 medium-grained capability variants
PermissionGuardcrates/application/src/permission/permission_guard.rsUnforgeable proof of resolved capabilities
CapabilityResolvercrates/application/src/permission/capability_resolver.rsSingle trust boundary for guard construction
CapabilityRepository traitcrates/application/src/permission/services.rsCloud capability fetch abstraction
PermissionErrorcrates/application/src/permission/services.rsDenied and ResolutionFailed variants
system_guard()crates/application/src/permission/mod.rsControlled factory for background task guards
empty_guard_for_testing()crates/application/src/permission/mod.rsZero-capability guard for infrastructure tests
SupabaseCapabilityRepositorycrates/infrastructure/supabase/src/capability/mod.rsPostgREST-based cloud capability fetch
Test helperscrates/application/src/permission/test_helpers.rsowner_guard(), guard_with(), MockCapabilityRepository
Framework Layer (Tauri / MCP / HTTP bridge)
|
v
CapabilityResolver.resolve_owner()
|
v
IdentityStore.get_identity() <-- OS keychain (offline, cached)
|
v
PermissionGuard { participant_id, capabilities } <-- unforgeable token
|
v
use_case.execute(&guard, ...args)
|
v
guard.require(Capability::PagesWrite)? <-- first line of execute()

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.

The Capability enum defines 19 variants organized by domain:

DomainCapabilitiesGranularity
PagesPagesRead, PagesWrite, PagesOrganize, PagesDeleteTiered: read -> write -> organize -> delete
SearchSearchUseSingle capability for FTS5 + semantic
HistoryHistoryReadEvent log and timeline queries
BookmarksBookmarksRead, BookmarksManageRead vs. create/delete
WorkspaceWorkspaceManageSettings, admin operations
ImportImportExecuteExternal markdown import
SyncSyncManageCloud sync state control
AttachmentsAttachmentsRead, AttachmentsWriteFile metadata and upload/delete
TypesTypesRead, TypesWriteType definition CRUD
TagsTagsRead, TagsWriteTag and tag group CRUD
PropertiesPropertiesRead, PropertiesWriteProperty 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.

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 case
let 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 check
impl DeletePageUseCase {
pub fn execute(&self, guard: &PermissionGuard, ...) -> Result<...> {
guard.require(Capability::PagesDelete)?;
// ... business logic
}
}

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.

crates/application/src/permission/mod.rs
pub fn system_guard() -> PermissionGuard {
PermissionGuard::system()
}

Usage in framework layer:

let guard = application::system_guard();

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.

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.

RuleEnforcement
Owner always has all capabilitiesCapabilityResolver.resolve_owner() returns Capability::all() unconditionally
Guard is unforgeablePermissionGuard::new() is pub(crate) — compile-time module privacy
Permission check is first line of use caseConvention enforced by code review and denial tests
Error messages do not leak capability namesPermissionError::Denied uses generic Display (“Permission denied”)
Capability::all() is exhaustiveExhaustive match test causes compile error on new variant
Capabilities are per-workspaceCapabilityRepository.get_capabilities() takes workspace_id
No local capability storage for non-ownersNon-owners always require connection; cloud-fetched
  1. Framework calls state.resolve_owner_guard() (Tauri) or state.resolve_owner_guard() (MCP)
  2. CapabilityResolver::resolve_owner() calls IdentityStore::get_identity()
  3. Identity found -> PermissionGuard::new(identity.id, Capability::all())
  4. Guard returned to framework, passed into use case
  1. CapabilityResolver checks participant.id == workspace.owner_id
  2. Not owner -> CapabilityRepository::get_capabilities(workspace_id, participant_id)
  3. SupabaseCapabilityRepository authenticates via ensure_valid_token()
  4. PostgREST query: SELECT capabilities FROM workspace_participants WHERE workspace_id = ? AND participant_id = ?
  5. parse_capabilities_from_row() deserializes snake_case strings into Capability variants
  6. PermissionGuard::new(participant_id, resolved_caps)

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 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”
CapabilityOwnerAgent (default)Agent (restricted)
PagesReadyesyesyes
PagesWriteyesyesyes
PagesOrganizeyesyesno
PagesDeleteyesnono
SearchUseyesyesyes
HistoryReadyesyesno
BookmarksReadyesnono
BookmarksManageyesnono
WorkspaceManageyesnono
ImportExecuteyesnono
SyncManageyesnono
AttachmentsReadyesyesyes
AttachmentsWriteyesyesno
TypesReadyesyesyes
TypesWriteyesnono
TagsReadyesyesyes
TagsWriteyesyesno
PropertiesReadyesyesyes
PropertiesWriteyesyesno

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.

VariantMeaningDisplay
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.

Use case errors typically wrap PermissionError into their domain-specific error type:

// Example: PageError in application layer
pub 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).

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.

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 capabilities
  • MockCapabilityRepository — configurable mock for non-owner resolution tests

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]

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.

  • 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/IdentityStore trait used by CapabilityResolver
  • crates/infrastructure/supabase/src/capability/mod.rs — cloud capability resolution
  • supabase/migrations/00000000000000_baseline.sqlworkspace_participants table schema

Was this page helpful?