Files
todo-api/docs/adr/ADR-003-domain-owned-permission-bits.md
Rene Nochebuena 3fcba82448 feat(todo-api): add full-stack POC demonstrating micro-lib v0.9.0
Runnable REST API exercising every micro-lib tier in a containerless setup: N-layer architecture, SQLite persistence, header-based auth simulating Firebase output, and bit-mask RBAC enforcement.

What's included:
- cmd/todo-api: minimal main delegating to application.Run
- internal/application: full object graph wiring — launcher, sqlite, httpserver, httpmw stack, routes in BeforeStart
- internal/domain: User entity, ResourceTodos constant, PermReadTodo/PermWriteTodo bit positions
- internal/repository: TodoRepository, UserRepository, DBPermissionProvider (SQLite via modernc)
- internal/service: TodoService, UserService with interface-based dependencies
- internal/handler: TodoHandler, UserHandler using httputil adapters and valid for input validation
- internal/middleware: Auth (X-User-ID → rbac.Identity) and Require (bit-mask permission gate)
- logAdapter: bridges logz.Logger.With return type to httpmw.Logger interface
- SQLite schema: users, user_role (bitmask), todos; migrations run in BeforeStart
- Routes: POST /users (open), GET+POST /todos (RBAC), GET /users (RBAC)

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
2026-03-19 13:55:08 +00:00

49 lines
2.9 KiB
Markdown

# 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.