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/
49 lines
2.9 KiB
Markdown
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.
|