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/
This commit is contained in:
45
docs/adr/ADR-001-n-layer-architecture.md
Normal file
45
docs/adr/ADR-001-n-layer-architecture.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# ADR-001: N-Layer Architecture
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
The todo-api is a POC application demonstrating the full micro-lib stack. It needs an internal structure that is illustrative, maintainable, and cleanly testable. The question is how to organize the application code within the `internal/` directory.
|
||||
|
||||
The project operates on the following rules:
|
||||
- Dependencies must flow inward: outer layers may depend on inner layers, inner layers must not depend on outer layers.
|
||||
- Domain types are the shared vocabulary — they may be imported by all layers.
|
||||
- All interfaces are defined at the layer boundary where they are consumed (not where they are implemented).
|
||||
|
||||
## Decision
|
||||
|
||||
The application uses a five-layer architecture within `internal/`:
|
||||
|
||||
```
|
||||
cmd/todo-api/main.go
|
||||
└── internal/application/ (wiring: constructs and connects all components)
|
||||
├── internal/handler/ (HTTP: decodes request, calls service, encodes response)
|
||||
│ ├── internal/service/ (business logic: validates, orchestrates)
|
||||
│ │ ├── internal/repository/ (persistence: SQL queries)
|
||||
│ │ │ └── internal/domain/ (entities, constants, no dependencies)
|
||||
│ │ └── internal/middleware/ (HTTP middleware: auth, RBAC)
|
||||
│ └── (domain types shared across layers)
|
||||
```
|
||||
|
||||
Dependency rules enforced:
|
||||
- `domain` imports nothing from `internal/`.
|
||||
- `repository` imports `domain` and infrastructure (`sqlite`, `rbac`). It defines its own interfaces (`TodoRepository`, `UserRepository`).
|
||||
- `service` imports `domain` and `repository` interfaces. It does not import concrete repository types.
|
||||
- `handler` imports `domain`, `service` interfaces, and `httputil`/`valid`. It does not import repository or service concrete types.
|
||||
- `middleware` imports `rbac` and `repository` interfaces. It does not import service or handler.
|
||||
- `application` imports everything and wires it together. It is the only package with knowledge of the full object graph.
|
||||
|
||||
The `cmd/todo-api/main.go` entry point contains only `application.Run()` — it is the thinnest possible `main`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Each layer is independently testable with mocks of the layer below.
|
||||
- The `application` package is the only place where concrete types cross layer boundaries. Changing a repository implementation requires changes only in `application` and `repository`.
|
||||
- Service interfaces (`TodoService`, `UserService`) are defined in the `service` package (where they are implemented), not in `handler` (where they are consumed). This follows Go convention: accept interfaces, not concrete types — but define them close to the implementation.
|
||||
- The `middleware` package sits alongside `handler` rather than below it in the dependency chain. Both are HTTP-layer concerns; neither depends on the other. `application` wires them together on the router.
|
||||
37
docs/adr/ADR-002-header-auth-for-poc.md
Normal file
37
docs/adr/ADR-002-header-auth-for-poc.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# ADR-002: Header-Based Auth for POC
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
The todo-api needs authentication so it can demonstrate the RBAC permission system. In production this role would be played by Firebase Auth: the client presents a Firebase ID token, the `httpauth-firebase` middleware (from `httpmw`) verifies the JWT, and an `rbac.Identity` is placed in the request context.
|
||||
|
||||
For this POC, introducing Firebase Auth would require:
|
||||
- A Firebase project or emulator
|
||||
- Token issuance in tests and curl examples
|
||||
- JWT verification dependencies in the application
|
||||
|
||||
This overhead is not necessary to demonstrate the RBAC flow. The goal is to show the contract between auth middleware and the rest of the stack, not to validate the auth mechanism itself.
|
||||
|
||||
## Decision
|
||||
|
||||
Authentication is performed by reading the `X-User-ID` request header. The `middleware.Auth` middleware:
|
||||
|
||||
1. Reads `r.Header.Get("X-User-ID")`.
|
||||
2. Returns 401 if the header is absent.
|
||||
3. Calls `userRepo.FindByID(ctx, uid)` to verify the user exists in the database.
|
||||
4. Returns 401 if the user is not found (the two failure cases are intentionally indistinguishable to callers — no enumeration of which check failed).
|
||||
5. Constructs an `rbac.Identity` via `rbac.NewIdentity(user.ID, user.Name, user.Email)` and stores it in the context with `rbac.SetInContext`.
|
||||
|
||||
The output contract — an `rbac.Identity` in the request context — is identical to what `httpauth-firebase` would produce. The downstream `middleware.Require` RBAC guard consumes `rbac.Identity` from context and has no knowledge of how the identity was established.
|
||||
|
||||
The `POST /users` route is deliberately unguarded. It allows bootstrapping: a new deployment can create its first users (with permission bits set) without prior credentials.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Replacing header auth with Firebase auth in a production version requires only swapping the `Auth` middleware. All downstream code (`Require`, handlers, services, repositories) is unchanged.
|
||||
- The header-based approach is intentionally insecure and must not be used in production. This is a POC.
|
||||
- The 401 response does not distinguish "missing header" from "user not found" — this is intentional to avoid user enumeration.
|
||||
- `POST /users` is an open endpoint. In a production system this would be protected by Firebase auth or an admin credential. Here it serves as the bootstrap mechanism to populate the `users` table and assign permission bits.
|
||||
- The `logAdapter` in `application` bridges `logz.Logger.With(...)` (which returns `logz.Logger`) to `httpmw.Logger.With(...)` (which must return `httpmw.Logger`). This mismatch arises because `httpmw.Logger` is a duck-typed interface with a `With` method that must return `httpmw.Logger`, not a concrete type. The adapter is defined locally in `application` — no extra package needed.
|
||||
48
docs/adr/ADR-003-domain-owned-permission-bits.md
Normal file
48
docs/adr/ADR-003-domain-owned-permission-bits.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user