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

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.

Worker integration

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

  • X-Delegation-Id: <delegation_id>

to any internal endpoint that writes user data (checkins, thoughts, clockify, projects).

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.