Skip to content
Documentation GitHub
Architecture

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:

  1. Framework commands doing guard.require() then calling repos directly (bypassing use cases)
  2. Some MCP tool handlers skipping permission checks entirely
  3. PermissionGuard::system() publicly accessible from any crate
  4. 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 check

1. Move guard.require() Into Use Cases

Before (fragile):

// Framework: Tauri command
pub 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 command
pub 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:

permission_guard.rs
pub(crate) fn system() -> Self {
Self {
participant_id: SYSTEM_PARTICIPANT_ID,
capabilities: Capability::all(),
}
}
// permission/mod.rs — controlled public factory
pub 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

  1. Resolve the guard at the framework layer
  2. Pass it to the use case
  3. Never call guard.require() in the framework

When Adding a New Capability

  1. Add variant to Capability enum
  2. Add to Capability::all() — the exhaustive test enforces this
  3. Add to PostgreSQL CHECK constraint (capabilities_valid)
  4. Add denial tests for use cases requiring the new capability

When Adding a New Use Case

  1. Add guard.require(capability) as first line of execute()
  2. Add denial test
  3. 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

Was this page helpful?