Implement authorization for OSA using a hybrid model: Role-Based Access Control (RBAC) for coarse-grained permissions and Relationship-Based Access Control (ReBAC) for resource-level access.
Background
OSA needs authorization to protect:
- Archive configuration - Only admins should manage schemas, traits, conventions, vocabularies
- Depositions - Owners can edit their own drafts, curators can review assigned submissions
- Published records - Public read access
Design Decision: RBAC + ReBAC (not ABAC)
| Model |
Use Case |
Decision |
| RBAC |
Role-based permissions (admin, curator, depositor) |
✅ Use for coarse-grained access |
| ReBAC |
Relationship-based (ownership, assignment) |
✅ Use for resource-level access |
| ABAC |
Attribute-based (status, timestamps, etc.) |
❌ Handle in domain logic instead |
Why not ABAC for lifecycle rules? Rules like "can only edit if status == draft" are domain invariants, not access policy. They apply regardless of who's calling (even system processes). Keeping them in domain logic centralises business rules and keeps authorization focused on identity.
Separation of Concerns
Authorization answers: "Is this user allowed to attempt this action on this resource?"
Domain logic answers: "Is this action valid given the current state?"
Authorization Model
Roles (RBAC)
SuperAdmin (node operator)
└── Admin (manages registries)
└── Curator (reviews depositions)
└── Depositor (submits data)
└── Public (reads published records)
Relationships (ReBAC)
- Ownership: User owns depositions they created
- Assignment: Curator is assigned to review specific depositions (future)
Permission Matrix
| Resource |
Public |
Depositor |
Curator |
Admin |
SuperAdmin |
| Published Records |
read |
read |
read |
read |
read |
| Own Depositions |
- |
CRUD |
read |
read |
read |
| Others' Depositions |
- |
- |
read, approve/reject |
read |
read |
| Schemas |
read |
read |
read |
CRUD |
CRUD |
| Traits |
read |
read |
read |
CRUD |
CRUD |
| Conventions |
read |
read |
read |
CRUD |
CRUD |
| Vocabularies |
read |
read |
read |
CRUD |
CRUD |
| User Management |
- |
- |
- |
- |
CRUD |
| Node Configuration |
- |
- |
- |
- |
CRUD |
Key Abstractions
Two complementary patterns enforce authorization at different layers, both designed so that forgetting to authorise is harder than remembering.
1. Declarative __auth__ gate on handlers (role check, no I/O)
Handlers declare a composable Policy via __auth__. The CommandHandler metaclass wraps run() to evaluate the policy before the handler body executes. A startup validator rejects any handler for a non-__public__ command that lacks __auth__ — missing policies are boot failures, not production vulnerabilities.
# Composable policy constructors
requires_role(Role.DEPOSITOR)
requires_role(Role.CURATOR) | requires_role(Role.ADMIN)
requires_any_role(Role.CURATOR, Role.ADMIN)
# Handler declaration
class SubmitDepositionHandler(CommandHandler[SubmitDeposition, SubmitResult]):
__auth__ = requires_role(Role.DEPOSITOR)
service: DepositionService
async def run(self, cmd: SubmitDeposition) -> SubmitResult:
# Only reached if principal has the Depositor role
return await self.service.submit(self._principal, cmd.deposition_id)
Policies compose with & (all must pass), | (any must pass), and ~ (negate):
class Policy:
async def evaluate(self, ctx: AuthContext) -> Verdict: ...
def __and__(self, other: Policy) -> AllOf: ...
def __or__(self, other: Policy) -> AnyOf: ...
def __invert__(self) -> Not: ...
Public commands opt out explicitly — silence means protected:
class SearchRecords(Command):
__public__: ClassVar = True
What this gives us: Cheap, I/O-free role check that runs before any DB access. Auditable by grepping __auth__. Forgetting it is a startup crash.
2. AuthorizedRepo + Guarded[T] (ownership/relationship check, at resource load)
For relationship-based checks (ownership, assignment), the resource must be loaded first. Rather than relying on manual authz.guard() calls in services that are easy to forget, the repository itself returns resources wrapped in Guarded[T] — a type that cannot be used without an explicit authorization check.
class Guarded(Generic[T]):
"""A loaded resource that requires authorization before use."""
def check(self, action: str) -> T:
"""Authorise and unwrap. Raises AuthorizationError if denied."""
self._authz.guard(self._principal, action, self._resource)
return self._resource
# No __getattr__ — you MUST .check() to get the inner value
AuthorizedRepo wraps a raw repository to return Guarded resources:
class AuthorizedRepo(Generic[T, ID]):
_inner: Repository[T, ID]
_principal: Principal
_authz: AuthorizationService
async def get(self, id: ID) -> Guarded[T]:
resource = await self._inner.get(id)
return Guarded(resource, self._principal, self._authz)
DI wiring: Principal is request-scoped (resolved from JWT + role lookup, same pattern as CurrentUser today). Dishka injects it into AuthorizedRepo alongside the real repo and AuthorizationService. Services declare which repo type they want:
# User-facing write service — gets authorization-enforcing repo
class DepositionService(Service):
_repo: AuthorizedRepo[Deposition, DepositionSRN]
async def submit(self, dep_id: DepositionSRN) -> SubmitResult:
dep = (await self._repo.get(dep_id)).check("deposition:submit")
# forgot .check()? ^^^ pyright error: Guarded[Deposition] has no .submit()
dep.submit()
...
# Internal event handler — uses raw repo, no auth wrapping
class ConvertDepositionToRecord(EventHandler[DepositionApproved]):
_dep_repo: DepositionRepository # raw port, no Guarded wrapper
What this gives us: Relationship checks happen at the point the resource is loaded (no extra query). Forgetting .check() is a type error, not a security hole. Domain ports stay pure — wrapping is done at the DI/application layer.
How the layers compose
Request
→ DI resolves Principal (JWT + role lookup, request-scoped)
→ Handler.__auth__ gate (role check, no I/O)
→ Handler.run()
→ Service calls repo.get() (returns Guarded[T])
→ Service calls .check(action) (ownership check, no extra query)
→ Domain logic on unwrapped T (state invariants)
| Layer |
Concern |
Enforced by |
Cost |
| Startup validation |
Every non-public handler has __auth__ |
Metaclass / boot check |
Zero (one-time) |
| Gate |
Role check |
Handler metaclass wraps run() |
Cheap (in-memory) |
| Guard |
Ownership / relationship |
Guarded[T] + type system |
Free (resource already loaded) |
| Domain |
State invariants |
Aggregate methods |
Already exists |
Principal
Enriched version of CurrentUser — resolved via DI from JWT + DB role lookup:
@dataclass(frozen=True)
class Principal:
user_id: UserId
identity: ProviderIdentity
roles: frozenset[Role]
def has_role(self, role: Role) -> bool: ...
def has_any_role(self, *roles: Role) -> bool: ...
Implementation Scope
In Scope
- Role enum, assignment model, and persistence
Principal resolution via DI
Policy type with composable constructors (requires_role, etc.)
- Handler metaclass enforcement (
__auth__ / __public__ startup validation)
Guarded[T] and AuthorizedRepo wrapper
AuthorizationService (evaluates ownership, role hierarchy)
owner_id / created_by on relevant aggregates
- Audit logging of authorization decisions
Out of Scope (Separate Issues)
- Curation assignment workflow
- Delegation
- Organizational/institutional boundaries
- Time-limited elevated access
Acceptance Criteria
Dependencies
References
Implement authorization for OSA using a hybrid model: Role-Based Access Control (RBAC) for coarse-grained permissions and Relationship-Based Access Control (ReBAC) for resource-level access.
Background
OSA needs authorization to protect:
Design Decision: RBAC + ReBAC (not ABAC)
Why not ABAC for lifecycle rules? Rules like "can only edit if status == draft" are domain invariants, not access policy. They apply regardless of who's calling (even system processes). Keeping them in domain logic centralises business rules and keeps authorization focused on identity.
Separation of Concerns
Authorization Model
Roles (RBAC)
Relationships (ReBAC)
Permission Matrix
Key Abstractions
Two complementary patterns enforce authorization at different layers, both designed so that forgetting to authorise is harder than remembering.
1. Declarative
__auth__gate on handlers (role check, no I/O)Handlers declare a composable Policy via
__auth__. TheCommandHandlermetaclass wrapsrun()to evaluate the policy before the handler body executes. A startup validator rejects any handler for a non-__public__command that lacks__auth__— missing policies are boot failures, not production vulnerabilities.Policies compose with
&(all must pass),|(any must pass), and~(negate):Public commands opt out explicitly — silence means protected:
What this gives us: Cheap, I/O-free role check that runs before any DB access. Auditable by grepping
__auth__. Forgetting it is a startup crash.2.
AuthorizedRepo+Guarded[T](ownership/relationship check, at resource load)For relationship-based checks (ownership, assignment), the resource must be loaded first. Rather than relying on manual
authz.guard()calls in services that are easy to forget, the repository itself returns resources wrapped inGuarded[T]— a type that cannot be used without an explicit authorization check.AuthorizedRepowraps a raw repository to returnGuardedresources:DI wiring:
Principalis request-scoped (resolved from JWT + role lookup, same pattern asCurrentUsertoday). Dishka injects it intoAuthorizedRepoalongside the real repo andAuthorizationService. Services declare which repo type they want:What this gives us: Relationship checks happen at the point the resource is loaded (no extra query). Forgetting
.check()is a type error, not a security hole. Domain ports stay pure — wrapping is done at the DI/application layer.How the layers compose
__auth__run()Guarded[T]+ type systemPrincipalEnriched version of
CurrentUser— resolved via DI from JWT + DB role lookup:Implementation Scope
In Scope
Principalresolution via DIPolicytype with composable constructors (requires_role, etc.)__auth__/__public__startup validation)Guarded[T]andAuthorizedRepowrapperAuthorizationService(evaluates ownership, role hierarchy)owner_id/created_byon relevant aggregatesOut of Scope (Separate Issues)
Acceptance Criteria
__auth__.check()on aGuarded[T]is a type errorDependencies
References