Permission Guard: Single Source of Truth Pattern
Permission Guard: Single Source of Truth Pattern
Problem
In a Clean Architecture codebase with multiple entry points (Tauri commands, HTTP bridge, MCP tools), permission checks can drift between layers. A code review found:
- Framework commands doing
guard.require()then calling repos directly (bypassing use cases) - Some MCP tool handlers skipping permission checks entirely
PermissionGuard::system()publicly accessible from any crate- Error messages leaking internal capability names
Symptoms:
- Framework and use case both check permissions (double-check in some paths, zero in others)
- Adding a new entry point requires remembering to add permission checks
#[allow(dead_code)]on middleware suggests it’s not actually wired
Root Cause
Permission enforcement was treated as a framework concern (the layer closest to the user) instead of a domain/application concern. When new entry points (MCP tools) were added, the permission checks from the Tauri commands weren’t automatically inherited.
Solution
Principle: Permission Checks Live in Use Cases, Nowhere Else
Framework Layer → Resolves guard (who is calling?) → Passes guard to use case → Does NOT call guard.require()
Application Layer → Use case calls guard.require(capability) → Single source of truth → Every entry point goes through same check1. Move guard.require() Into Use Cases
Before (fragile):
// Framework: Tauri commandpub fn delete_page(state: State<AppState>, slug: String) -> Result<(), CommandError> { let guard = state.resolve_owner_guard()?; guard.require(Capability::PagesDelete)?; // Framework checks state.page_repository.delete(&slug)?; // Bypasses use case!}After (robust):
// Framework: Tauri commandpub fn delete_page(state: State<AppState>, slug: String) -> Result<(), CommandError> { let guard = state.resolve_owner_guard()?; let use_case = DeletePageUseCase::new(state.page_repo()); use_case.execute(&guard, &workspace.path, &slug)?; // Use case checks}
// Application: Use case (single source of truth)impl DeletePageUseCase { pub fn execute(&self, guard: &PermissionGuard, path: &Path, slug: &str) -> Result<usize, PageError> { guard.require(Capability::PagesDelete)?; self.repository.soft_delete(path, slug) }}Now MCP tools, HTTP bridge, and Tauri commands all flow through the same permission check.
2. Restrict System Guard Construction
PermissionGuard::system() creates an all-capabilities guard for background tasks. It must not be callable from
framework crates:
pub(crate) fn system() -> Self { Self { participant_id: SYSTEM_PARTICIPANT_ID, capabilities: Capability::all(), }}
// permission/mod.rs — controlled public factorypub fn system_guard() -> PermissionGuard { PermissionGuard::system()}External callers use application::system_guard() — a deliberate, auditable entry point.
3. System Participant Sentinel
Use Uuid::nil() as a sentinel for system-level operations:
pub const SYSTEM_PARTICIPANT_ID: Uuid = Uuid::nil();This distinguishes system operations from real users in audit logs without needing special-case logic.
4. Generic External Errors
Permission errors should not leak capability names:
#[derive(Debug, thiserror::Error)]pub enum PermissionError { #[error("Permission denied")] // Generic for Display (external) Denied { capability: Capability, // Available for logging (internal) participant_id: Uuid, },}5. Exhaustive Capability Testing
Prevent forgetting to add new capabilities to Capability::all():
#[test]fn test_all_is_exhaustive() { fn assert_variant_covered(c: Capability) -> bool { match c { Capability::PagesRead | Capability::PagesWrite | Capability::PagesOrganize | Capability::PagesDelete | Capability::SearchUse | Capability::HistoryRead | Capability::BookmarksRead | Capability::BookmarksManage | Capability::WorkspaceManage | Capability::ImportExecute | Capability::SyncManage | Capability::AttachmentsRead | Capability::AttachmentsWrite | Capability::TypesRead | Capability::TypesWrite | Capability::TagsRead | Capability::TagsWrite | Capability::PropertiesRead | Capability::PropertiesWrite => true, } }
let all = Capability::all(); assert_eq!( all.len(), 19, "Capability::all() count mismatch — did you add a new variant?" ); for cap in &all { assert!(assert_variant_covered(*cap)); }}Adding a new variant without updating this match causes a compile error.
6. Denial Test Pattern
Every use case with guard.require() gets a corresponding denial test:
#[test]fn test_delete_page_rejected_without_pages_delete() { let guard = guard_with(&[Capability::PagesRead]); // Missing PagesDelete let repo = MockPageRepository::new(); 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(_))));}Convention: test_[usecase]_rejected_without_[capability]
Prevention
When Adding a New Entry Point
- Resolve the guard at the framework layer
- Pass it to the use case
- Never call
guard.require()in the framework
When Adding a New Capability
- Add variant to
Capabilityenum - Add to
Capability::all()— the exhaustive test enforces this - Add to PostgreSQL CHECK constraint (
capabilities_valid) - Add denial tests for use cases requiring the new capability
When Adding a New Use Case
- Add
guard.require(capability)as first line ofexecute() - Add denial test
- All framework callers automatically get permission enforcement
References
- INK-250 findings P1-02, P1-03, P1-05, P1-06, P2-03, P2-05, P3-04, P3-10
- ADR-008: Capability-Based Permission Model
Multi-Editor Grid Architecture with Per-Cell CRDT Next
SQLite Multi-Database Checkpoint Patterns
Was this page helpful?
Thanks for your feedback!