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/
2.9 KiB
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:
- Define permission constants in the
rbaclibrary itself (centralized, but couples the generic library to application semantics). - Define them in the
repositoryormiddlewarepackage (close to the code that enforces them, but creates coupling between infrastructure and policy). - Define them in the
domainpackage (with the entities they protect).
Decision
Permission bit positions are defined in the domain package alongside the entities they guard:
// 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.ResourceTodoswhen wiring routesservice/user_service.go— uses them when convertingCreateUserRequest.CanRead/CanWriteto a maskrepository/permission_provider.go— theResolveMaskmethod is resource-agnostic; resource strings come from callers
Consequences
- Adding a new permission (e.g.,
PermDeleteTodo = 2) requires a change only indomain/user.go. All enforcement code inmiddleware.Requirepicks it up automatically because it receives the permission as an argument. - The
rbaclibrary 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
DBPermissionProviderinrepositoryimplementsrbac.PermissionProviderby queryinguser_rolefor(user_id, resource)and returning the stored integer asrbac.PermissionMask. It is resource-agnostic — the same type serves all resources. A missing row returns mask0(no permissions), not an error.