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

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:

  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:

// 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.godomain.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.