Status banner
Sign in for status

Delegations (hierarchical write authority)

Lucille uses hierarchical, revocable delegations to allow jobs to write on behalf of users without special scheduler roles. Delegations form a tree and revoke cascades through descendants.

Core rules

  • Every delegation has an optional parent; roots have no parent.
  • Children can only request a scope subset of the parent — for every named capability, not just can_write_user_data (a child cannot introduce a grant, e.g. mail.send, that the parent does not itself hold).
  • If a delegation is revoked, all descendants are revoked.
  • Both issuer and recipient can revoke (revoke vs relinquish).
  • Writes from worker jobs must include a delegation id.
  • Scope is enforced at use time, not only when the delegation is minted: a read-only or narrowly-scoped delegation is rejected at any write gate whose required capability it does not grant.

Data model (user_delegations)

  • id: UUID
  • user_id: UUID
  • issued_by_job_id: job that created this delegation (nullable for roots)
  • issued_to_job_id: job that uses this delegation
  • scope_json: JSON capability grants. can_write_user_data: true is full write authority (a superset that satisfies any capability check). Narrower, per-tool grants can be listed alongside or instead of it, e.g. { "can_write_user_data": false, "mail.send": true } — a delegation that may send mail but cannot otherwise write user data or use any other tool.
  • created_at, expires_at
  • revoked_at, revoked_by_job_id
  • parent_delegation_id, root_delegation_id

Lifetime (default TTL)

A root delegation gets a default expires_at of max(now, run_at) + TTL (DELEGATION_DEFAULT_TTL_SECONDS, default 24h). The delegation_id is handed to job-implementation subprocesses as a bearer token, so bounding its lifetime limits the blast radius of a leaked id instead of it being valid forever. Anchoring at the job's run_at keeps a deferred job's delegation valid until it actually runs. A child can never outlive its parent: its expiry is clamped to (or inherited from) the parent's. An explicit expires_at is respected (still clamped for children); set the TTL to 0 to disable defaulting (never-expire, the prior behavior). Existing rows are left untouched — only new delegations are bounded.

Capability scoping (per-tool isolation)

require_active_delegation(session, delegation_id, user_id, capability="can_write_user_data") is the single write gate. Beyond checking that the delegation is present, live, unrevoked, and scoped to user_id, it verifies the delegation's scope_json actually grants capability (app/crud/delegations.py:scope_grants). Call sites that perform a specific external action pass the matching named capability (e.g. capability="mail.send").

This makes capabilities non-transitive between nodes/jobs: the fact that one job/node can perform an action (send mail, write Clockify) does not grant the same ability to another. Each must hold a delegation that explicitly grants that capability. To hand a downstream job a single narrow ability, mint a child delegation whose scope lists only that capability — the chain-subset rule prevents it from being widened later.

Cascading revocation

Revocation uses a recursive CTE to set revoked_at for the target delegation and all descendants. Any write attempt under a revoked or expired delegation fails with 403.

Central enforcement at MCP dispatch

Worker jobs run as MCP tool calls, all routed through mcp_server.dispatch_tool (both the SSE call_tool and the HTTP /internal/mcp/call_tool paths). Rather than relying on each handler to remember to validate, the gate is enforced once at dispatch for every write tool: before a handler runs, dispatch_tool calls require_active_delegation for any tool whose instinct metadata declares non-empty resources (a write op — the same signal the worker uses to require a delegation_id). Read-only kinds (e.g. *.recommend) declare empty resources and are not gated. user_id from the call binds cross-user access; when a kind carries only a resource id, the gate still enforces revocation/expiry/scope against the delegation's own user. This makes coverage uniform and regression-proof: a new write kind is gated the moment it declares a write resource, with no per-handler wiring.

Worker integration

When a job is claimed, it has a delegation_id. The worker must pass:

  • X-Delegation-Id: <delegation_id> to any internal HTTP endpoint that writes user data (checkins, thoughts, clockify, projects), and
  • delegation_id in the arguments of any write MCP tool call (enforced centrally at dispatch, see above).

No special scheduler

Scheduler-created jobs receive a root delegation just like user-created jobs. Orchestrator jobs can create child delegations via internal endpoints and enqueue child jobs without permanent privileges.