Files
todo-api/docs/adr/ADR-003-domain-owned-permission-bits.md

49 lines
2.9 KiB
Markdown
Raw Permalink Normal View History

# ADR-003: Domain-Owned Permission Bits
**Status:** Accepted
**Date:** 2026-03-18
## Context
The RBAC system uses `rbac.PermissionMask`, a bitmask integer stored in the `user_role` table. Each bit position represents a permission. The question is where to define the meaning of each bit position — i.e., which bit is "can read todos" and which is "can write todos".
Options:
1. Define permission constants in the `rbac` library itself (centralized, but couples the generic library to application semantics).
2. Define them in the `repository` or `middleware` package (close to the code that enforces them, but creates coupling between infrastructure and policy).
3. Define them in the `domain` package (with the entities they protect).
## Decision
Permission bit positions are defined in the `domain` package alongside the entities they guard:
```go
// domain/user.go
const ResourceTodos = "todos"
const (
PermReadTodo rbac.Permission = 0 // bit 0 → mask value 1
PermWriteTodo rbac.Permission = 1 // bit 1 → mask value 2
)
```
The `rbac.Permission` type is a bit position (0-based). The stored mask value is `1 << bit`. So `PermReadTodo = 0` means the stored integer value `1`, and `PermWriteTodo = 1` means the stored integer value `2`. A user with both permissions has mask `3`.
`ResourceTodos` is the string key used in the `user_role` table's `resource` column. It ties the permission mask to a specific resource within the RBAC lookup:
```
user_role(user_id, resource="todos", permissions=3)
```
All layers that need permission constants import them from `domain`:
- `application/launcher.go``domain.PermReadTodo`, `domain.PermWriteTodo`, `domain.ResourceTodos` when wiring routes
- `service/user_service.go` — uses them when converting `CreateUserRequest.CanRead/CanWrite` to a mask
- `repository/permission_provider.go` — the `ResolveMask` method is resource-agnostic; resource strings come from callers
## Consequences
- Adding a new permission (e.g., `PermDeleteTodo = 2`) requires a change only in `domain/user.go`. All enforcement code in `middleware.Require` picks it up automatically because it receives the permission as an argument.
- The `rbac` library remains generic. It knows nothing about "todos" or any specific resource.
- Domain bits are visible to all layers that import `domain`, which is all of them. This is correct — permissions are a domain concept, not an infrastructure detail.
- Bit positions must be assigned carefully and never reused. Changing the value of an existing constant while data exists in the database would silently grant or revoke permissions. New permissions must use the next available bit position.
- The `DBPermissionProvider` in `repository` implements `rbac.PermissionProvider` by querying `user_role` for `(user_id, resource)` and returning the stored integer as `rbac.PermissionMask`. It is resource-agnostic — the same type serves all resources. A missing row returns mask `0` (no permissions), not an error.