commit 3fcba8244889c52eb1dadd8c208125adfb99fc1d Author: Rene Nochebuena Date: Thu Mar 19 13:55:08 2026 +0000 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "features": { + "ghcr.io/devcontainers-extra/features/claude-code:1": {} + }, + "forwardPorts": [], + "postCreateCommand": "go version", + "customizations": { + "vscode": { + "settings": { + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + "explorer.compactFolders": false, + "explorer.showEmptyFolders": true + }, + "extensions": [ + "golang.go", + "eamodio.golang-postfix-completion", + "quicktype.quicktype", + "usernamehw.errorlens" + ] + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f85e8c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Local DB +*.db + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6b72e19 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [0.9.0-poc] - 2026-03-18 + +### Added + +This entry describes what the POC demonstrates, not a versioned library API surface. + +**Application structure** + +- N-layer architecture: `cmd/todo-api` → `internal/application` (wiring only) → `internal/handler` → `internal/service` → `internal/repository` → `internal/domain`; each layer depends on its inner neighbours via interfaces, with `application` as the only package that owns the full object graph +- `application.Run()` — top-level entry point; wires all dependencies, registers routes inside `lc.BeforeStart`, and calls `lc.Run()` to start the launcher lifecycle +- Local `logAdapter` struct in `application` — bridges `logz.Logger` (whose `With` returns `logz.Logger`) to `httpmw.Logger` (whose `With` must return `httpmw.Logger`), demonstrating the pattern for resolving return-type mismatches between micro-lib interfaces without modifying either library + +**Domain layer** (`internal/domain`) + +- `Todo` struct — `ID int64`, `Title string`, `Done bool`, `CreatedAt time.Time`, JSON-tagged +- `User` struct — `ID string`, `Name string`, `Email string`, `CreatedAt time.Time`, JSON-tagged +- `ResourceTodos = "todos"` constant — the resource key used in the `user_role` table +- `PermReadTodo rbac.Permission = 0` and `PermWriteTodo rbac.Permission = 1` — bit positions for the todos resource permission mask + +**Repository layer** (`internal/repository`) + +- `TodoRepository` interface — `FindAll(ctx) ([]domain.Todo, error)`, `Create(ctx, domain.Todo) (domain.Todo, error)` +- `UserRepository` interface — `FindAll`, `FindByID`, `Create`, `SetPermissions(ctx, userID, resource string, mask rbac.PermissionMask) error` +- `NewTodoRepository(db sqlite.Client) TodoRepository` — SQLite-backed implementation; `FindAll` returns an empty slice (never nil) when no rows exist; `Create` uses `RETURNING` to retrieve the generated ID and timestamp +- `NewUserRepository(db sqlite.Client) UserRepository` — SQLite-backed implementation; `FindByID` returns a `xerrors.ErrNotFound`-wrapped error on `sql.ErrNoRows`; `SetPermissions` uses `INSERT ... ON CONFLICT DO UPDATE` for upsert +- `DBPermissionProvider` struct implementing `rbac.PermissionProvider` — resolves the integer bitmask from `user_role` for a given `(uid, resource)` pair; returns mask `0` (no permissions) for a missing row rather than an error +- `NewPermissionProvider(db sqlite.Client) *DBPermissionProvider` + +**Service layer** (`internal/service`) + +- `CreateTodoRequest` struct — `Title string` with `validate:"required,min=1,max=255"` tag +- `TodoService` interface — `FindAll(ctx) ([]domain.Todo, error)`, `Create(ctx, CreateTodoRequest) (domain.Todo, error)` +- `NewTodoService(repo repository.TodoRepository) TodoService` +- `CreateUserRequest` struct — `Name string`, `Email string`, `CanRead bool`, `CanWrite bool`, with validation tags; `CanRead`/`CanWrite` seed the permission bitmask for `ResourceTodos` at creation time +- `UserService` interface — `FindAll(ctx) ([]domain.User, error)`, `Create(ctx, CreateUserRequest) (domain.User, error)` +- `NewUserService(repo repository.UserRepository, idGen func() string) UserService` — `idGen` is injected (e.g. `uuid.NewString`) to mint user IDs + +**Handler layer** (`internal/handler`) + +- `TodoHandler` — `NewTodoHandler(svc service.TodoService, v valid.Validator) *TodoHandler`; `FindAll` delegates to `httputil.HandleNoBody[[]domain.Todo]`; `Create` delegates to `httputil.Handle[CreateTodoRequest, domain.Todo]` +- `UserHandler` — `NewUserHandler(svc service.UserService, v valid.Validator) *UserHandler`; `FindAll` and `Create` follow the same `httputil` adapter pattern + +**Middleware layer** (`internal/middleware`) + +- `Auth(userRepo repository.UserRepository) func(http.Handler) http.Handler` — reads `X-User-ID` header, looks up the user, and stores an `rbac.Identity` in context via `rbac.SetInContext`; returns 401 JSON on missing header or unknown user ID; the output contract is identical to what `httpauth-firebase.EnrichmentMiddleware` produces, so swapping in real Firebase auth requires no changes to handlers or services +- `Require(provider rbac.PermissionProvider, resource string, perms ...rbac.Permission) func(http.Handler) http.Handler` — reads `rbac.Identity` from context, resolves the permission mask, and enforces each required bit; returns 403 JSON on missing identity or insufficient permissions; returns 500 JSON if the provider errors + +**Micro-lib stack composition demonstrated** + +- `launcher` orchestrating `sqlite` and `httpserver` through `OnInit → OnStart → OnStop` +- `httpserver` with `RequestID → Recover → RequestLogger` middleware via `httpmw` +- `sqlite` (modernc pure-Go, no CGO) as the persistence layer with inline schema migrations run in `lc.BeforeStart` +- `rbac` identity and permission types as the shared contract across middleware, repository, and domain +- `valid` for request body validation in handlers +- `httputil` handler adapters (`Handle`, `HandleNoBody`) for all route handlers +- `xerrors` `ErrNotFound` for typed not-found errors in the repository layer +- Route registration in `lc.BeforeStart` so migrations complete before any route is reachable and the port is not bound until routes are registered + +**Routes** + +- `POST /users` — open; no auth required (user bootstrapping) +- `GET /todos` — requires `Auth` middleware and `PermReadTodo` permission +- `POST /todos` — requires `Auth` middleware and `PermWriteTodo` permission +- `GET /users` — requires `Auth` middleware and `PermReadTodo` permission on the todos resource + +### Design Notes + +- Header-based auth (`X-User-ID`) deliberately mirrors the `rbac.Identity` output contract of `httpauth-firebase`, proving that the RBAC contract is provider-agnostic; the POC can be upgraded to real Firebase auth by replacing the `Auth` middleware with no changes to any other layer +- SQLite with no Docker dependency keeps the POC self-contained: a single `go run ./cmd/todo-api` is sufficient to run the full stack, with the database created as `todos.db` in the working directory on first run +- Routes are registered in `lc.BeforeStart` rather than at construction time, making the lifecycle ordering explicit and auditable in a single closure: migrations run after `db.OnInit`, routes register after migrations, and `srv.OnStart` binds the port last diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..68d3275 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# todo-api + +POC application demonstrating the full micro-lib stack: N-layer architecture, SQLite persistence, header-based auth, and RBAC permission enforcement. + +## Purpose + +This is not a library. It is a runnable REST API that exercises every micro-lib tier in a realistic but containerless setup. The goal is to validate that the micro-lib modules compose correctly, that the RBAC contract between `httpmw.Auth`-equivalent middleware and `rbac.PermissionMask` works end-to-end, and that the launcher lifecycle manages database and HTTP server startup cleanly. + +## Tier & Dependencies + +**Tier 5** (application). Imports: +- `code.nochebuena.dev/go/launcher` — lifecycle orchestration +- `code.nochebuena.dev/go/httpserver` — HTTP server (Tier 4) +- `code.nochebuena.dev/go/httpmw` — request ID, recovery, request logger middleware +- `code.nochebuena.dev/go/logz` — structured logger +- `code.nochebuena.dev/go/sqlite` — SQLite client (Tier 1) +- `code.nochebuena.dev/go/rbac` — identity and permission types +- `code.nochebuena.dev/go/valid` — input validation +- `code.nochebuena.dev/go/httputil` — handler adapters +- `code.nochebuena.dev/go/xerrors` — typed errors (NotFound) +- `github.com/go-chi/chi/v5` — used in application for route grouping +- `github.com/google/uuid` — user ID generation + +## Key Design Decisions + +- **N-layer architecture** (ADR-001): `cmd → application → handler → service → repository → domain`. Each layer depends only on its inner neighbours via interfaces. `application` is the only package that knows the full object graph. +- **Header-based auth** (ADR-002): `middleware.Auth` reads `X-User-ID`, looks up the user, and puts an `rbac.Identity` in context — identical output contract to `httpauth-firebase`. Swapping in real Firebase auth requires only replacing the middleware. +- **Domain-owned permission bits** (ADR-003): `domain.PermReadTodo = 0` and `domain.PermWriteTodo = 1` define bit positions. `domain.ResourceTodos = "todos"` is the key in `user_role`. All layers import these constants from `domain`. +- **Routes registered in BeforeStart**: Route registration happens in `lc.BeforeStart(...)` so SQLite tables exist before handlers are reachable. This is the correct place in the launcher lifecycle — after `db.OnInit` migrates the schema, before `srv.OnStart` binds the port. +- **logAdapter**: `logz.Logger.With(...)` returns `logz.Logger`; `httpmw.Logger.With(...)` must return `httpmw.Logger`. A local `logAdapter` struct in `application` bridges this mismatch without requiring either library to change. +- **SQLite — no container required**: The database file is `todos.db` in the working directory. Local development and testing require no Docker, no Postgres, no network access. + +## Patterns + +**Layer interfaces** (defined at consumption site in Go convention): + +``` +repository.TodoRepository — consumed by service +repository.UserRepository — consumed by service and middleware +rbac.PermissionProvider — consumed by middleware.Require +service.TodoService — consumed by handler +service.UserService — consumed by handler +``` + +**Full middleware + route wiring in BeforeStart:** + +```go +lc.BeforeStart(func() error { + // run migrations + db.GetExecutor(ctx).ExecContext(ctx, migrate) + + // open endpoint + srv.Post("/users", userH.Create) + + // auth-gated group + srv.Group(func(r chi.Router) { + r.Use(appMW.Auth(userRepo)) + r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermReadTodo)). + Get("/todos", todoH.FindAll) + r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermWriteTodo)). + Post("/todos", todoH.Create) + }) + return nil +}) +``` + +**Creating a user with permissions (curl):** + +```sh +# Create a user with read+write on todos +curl -X POST http://localhost:3000/users \ + -H 'Content-Type: application/json' \ + -d '{"name":"Alice","email":"alice@example.com","can_read":true,"can_write":true}' + +# Use the returned ID to authenticate +curl http://localhost:3000/todos -H 'X-User-ID: ' +``` + +**Database schema:** + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE user_role ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, resource) +); + +CREATE TABLE todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +## What to Avoid + +- Do not add business logic to `application`. It is a wiring package only. +- Do not import `application` from any `internal/` sub-package. The dependency arrow points into `application`, never out. +- Do not define permission constants in `repository` or `middleware`. They belong in `domain` (ADR-003). +- Do not use the header auth pattern in production. `X-User-ID` is unauthenticated. Replace with `httpauth-firebase` or equivalent before any real deployment. +- Do not reuse or reassign existing permission bit positions. Append new bits at the next available position. +- Do not run migrations outside of `BeforeStart`. Running them in `OnInit` would execute before other components are ready; running them in `OnStart` races with route availability. +- Do not add telemetry imports to this POC unless demonstrating the telemetry module specifically. The OTel no-op default applies — adding `telemetry.New` is a one-line addition if needed. + +## Testing Notes + +- There are no tests in this POC currently. The structure is designed for testability: each layer depends only on interfaces, so handlers, services, and repositories can be tested with mock implementations. +- Service-layer tests: implement `repository.TodoRepository` / `repository.UserRepository` as in-memory stubs, construct the service, and call methods directly. +- Handler-layer tests: implement `service.TodoService` as a stub, use `httptest.NewRequest` + `httptest.NewRecorder` with the handler's `ServeHTTP`. +- Middleware tests: use `httptest.NewRequest`, set `X-User-ID`, and verify that `rbac.Identity` is present in context after `Auth` runs. +- Integration tests (optional): start a real SQLite in-memory database (`sqlite.Config{Path: ":memory:"}`), run migrations, and exercise the full stack. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmd/todo-api/main.go b/cmd/todo-api/main.go new file mode 100644 index 0000000..87d1add --- /dev/null +++ b/cmd/todo-api/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "code.nochebuena.dev/go/todo-api/internal/application" +) + +func main() { + if err := application.Run(); err != nil { + os.Exit(1) + } +} diff --git a/docs/adr/ADR-001-n-layer-architecture.md b/docs/adr/ADR-001-n-layer-architecture.md new file mode 100644 index 0000000..77536b8 --- /dev/null +++ b/docs/adr/ADR-001-n-layer-architecture.md @@ -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. diff --git a/docs/adr/ADR-002-header-auth-for-poc.md b/docs/adr/ADR-002-header-auth-for-poc.md new file mode 100644 index 0000000..530994d --- /dev/null +++ b/docs/adr/ADR-002-header-auth-for-poc.md @@ -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. diff --git a/docs/adr/ADR-003-domain-owned-permission-bits.md b/docs/adr/ADR-003-domain-owned-permission-bits.md new file mode 100644 index 0000000..68130ec --- /dev/null +++ b/docs/adr/ADR-003-domain-owned-permission-bits.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8f94975 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module code.nochebuena.dev/go/todo-api + +go 1.25 + +require ( + code.nochebuena.dev/go/httpmw v0.9.0 + code.nochebuena.dev/go/httpserver v0.9.0 + code.nochebuena.dev/go/httputil v0.9.0 + code.nochebuena.dev/go/launcher v0.9.0 + code.nochebuena.dev/go/logz v0.9.0 + code.nochebuena.dev/go/rbac v0.9.0 + code.nochebuena.dev/go/sqlite v0.9.0 + code.nochebuena.dev/go/valid v0.9.0 + code.nochebuena.dev/go/xerrors v0.9.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 +) + +require ( + code.nochebuena.dev/go/health v0.9.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e43fe8c --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +code.nochebuena.dev/go/health v0.9.0 h1:x0UKjC7CHAE3AgwyFzCyjmGJIjoLBBxeOHxXuqpbKwI= +code.nochebuena.dev/go/health v0.9.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso= +code.nochebuena.dev/go/httpmw v0.9.0 h1:d7bmSdEoXM6qhv018ImWrDjspq/7AeJWDr4Oo98htmc= +code.nochebuena.dev/go/httpmw v0.9.0/go.mod h1:FUVaJ+fqm3S7PhUa7Rv8RC3JdWB/bqO6nxAiiL6x9Rc= +code.nochebuena.dev/go/httpserver v0.9.0 h1:GQ1u27o2pNygBMPm/4Wt+1Xbb+e3lLRHyVEHKoN/pls= +code.nochebuena.dev/go/httpserver v0.9.0/go.mod h1:kOSYhsY2GMCC9PL6z5UYbC/0fqhFRbqeRgrOtPGGwXk= +code.nochebuena.dev/go/httputil v0.9.0 h1:HP8nX3Q5FEQlhGh6decmVNT3UjBcio8oytlMugaPZ1Q= +code.nochebuena.dev/go/httputil v0.9.0/go.mod h1:TsbXw2+GdyUzUX2CXCKoZOfDWBEWkbODd56ZduUfJts= +code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA= +code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g= +code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE= +code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8= +code.nochebuena.dev/go/rbac v0.9.0 h1:2fQngWIOeluIaMmo+H2ajT0NVw8GjNFJVi6pbdB3f/o= +code.nochebuena.dev/go/rbac v0.9.0/go.mod h1:LzW8tTJmdbu6HHN26NZZ3HzzdlZAd1sp6aml25Cfz5c= +code.nochebuena.dev/go/sqlite v0.9.0 h1:Ix8R10vQgoUtCv/fGubhGtjxFm+eXMkCPzPJxYDqHDc= +code.nochebuena.dev/go/sqlite v0.9.0/go.mod h1:GImlrhFXwGhFbzs67M8QYFcMbbevuI73HFYx2N4eT94= +code.nochebuena.dev/go/valid v0.9.0 h1:o8/tICIoed2+uwBp+TxXa3FE6KmyirU266O4jEUgFCI= +code.nochebuena.dev/go/valid v0.9.0/go.mod h1:SKpLcqpEsLMaEk7K3Y0kFF7Y3W5PHAQF6+U6wleFAhg= +code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE= +code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/application/launcher.go b/internal/application/launcher.go new file mode 100644 index 0000000..b9b3014 --- /dev/null +++ b/internal/application/launcher.go @@ -0,0 +1,117 @@ +package application + +import ( + "context" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "code.nochebuena.dev/go/httpserver" + "code.nochebuena.dev/go/httpmw" + "code.nochebuena.dev/go/launcher" + "code.nochebuena.dev/go/logz" + "code.nochebuena.dev/go/sqlite" + "code.nochebuena.dev/go/valid" + + "code.nochebuena.dev/go/todo-api/internal/domain" + "code.nochebuena.dev/go/todo-api/internal/handler" + appMW "code.nochebuena.dev/go/todo-api/internal/middleware" + "code.nochebuena.dev/go/todo-api/internal/repository" + "code.nochebuena.dev/go/todo-api/internal/service" +) + +const migrate = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_role ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource TEXT NOT NULL, + permissions INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, resource) +); + +CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +);` + +// logAdapter bridges logz.Logger (With returns logz.Logger) to httpmw.Logger +// (With must return httpmw.Logger). Defined locally — no extra package needed. +type logAdapter struct{ l logz.Logger } + +func (a *logAdapter) Info(msg string, args ...any) { a.l.Info(msg, args...) } +func (a *logAdapter) Error(msg string, err error, args ...any) { a.l.Error(msg, err, args...) } +func (a *logAdapter) With(args ...any) httpmw.Logger { return &logAdapter{a.l.With(args...)} } + +// Run wires all dependencies, starts the launcher, and blocks until shutdown. +func Run() error { + logger := logz.New(logz.Options{JSON: false}) + + // --- Infrastructure --- + db := sqlite.New(logger, sqlite.Config{Path: "todos.db"}) + + // --- Repositories --- + todoRepo := repository.NewTodoRepository(db) + userRepo := repository.NewUserRepository(db) + permProvider := repository.NewPermissionProvider(db) + + // --- Services --- + v := valid.New() + todoSvc := service.NewTodoService(todoRepo) + userSvc := service.NewUserService(userRepo, uuid.NewString) + + // --- Handlers --- + todoH := handler.NewTodoHandler(todoSvc, v) + userH := handler.NewUserHandler(userSvc, v) + + // --- HTTP server --- + srv := httpserver.New(logger, httpserver.Config{Port: 3000}, + httpserver.WithMiddleware( + httpmw.RequestID(uuid.NewString), + httpmw.Recover(), + httpmw.RequestLogger(&logAdapter{logger}), + ), + ) + + // --- Launcher --- + lc := launcher.New(logger) + lc.Append(db, srv) + + lc.BeforeStart(func() error { + // Migrations run after db.OnInit, before srv.OnStart. + ctx := context.Background() + if _, err := db.GetExecutor(ctx).ExecContext(ctx, migrate); err != nil { + return err + } + + // POST /users — open; no auth required (bootstrapping) + srv.Post("/users", userH.Create) + + // All other routes require a valid X-User-ID (Auth middleware populates rbac.Identity). + srv.Group(func(r chi.Router) { + r.Use(appMW.Auth(userRepo)) + + // Todos — permission-gated + r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermReadTodo)). + Get("/todos", todoH.FindAll) + r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermWriteTodo)). + Post("/todos", todoH.Create) + + // Users — read-only list, also requires read permission on todos + // (keeps the surface small; demonstrates RBAC on a second endpoint) + r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermReadTodo)). + Get("/users", userH.FindAll) + }) + + return nil + }) + + return lc.Run() +} diff --git a/internal/domain/todo.go b/internal/domain/todo.go new file mode 100644 index 0000000..82945e8 --- /dev/null +++ b/internal/domain/todo.go @@ -0,0 +1,11 @@ +package domain + +import "time" + +// Todo is the core domain entity. +type Todo struct { + ID int64 `json:"id"` + Title string `json:"title"` + Done bool `json:"done"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..636f4b8 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,25 @@ +package domain + +import ( + "time" + + "code.nochebuena.dev/go/rbac" +) + +// User is the core user entity. +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +// ResourceTodos is the resource key stored in the user_role table for todo permissions. +const ResourceTodos = "todos" + +// Permission bits for the todos resource. +// Each constant is a bit position (0-based); the stored value is 1 << bit. +const ( + PermReadTodo rbac.Permission = 0 // bit 0 → mask value 1 + PermWriteTodo rbac.Permission = 1 // bit 1 → mask value 2 +) diff --git a/internal/handler/todo_handler.go b/internal/handler/todo_handler.go new file mode 100644 index 0000000..425ae1c --- /dev/null +++ b/internal/handler/todo_handler.go @@ -0,0 +1,31 @@ +package handler + +import ( + "net/http" + + "code.nochebuena.dev/go/httputil" + "code.nochebuena.dev/go/valid" + "code.nochebuena.dev/go/todo-api/internal/domain" + "code.nochebuena.dev/go/todo-api/internal/service" +) + +// TodoHandler wires HTTP requests to the TodoService. +type TodoHandler struct { + svc service.TodoService + v valid.Validator +} + +// NewTodoHandler returns a TodoHandler. +func NewTodoHandler(svc service.TodoService, v valid.Validator) *TodoHandler { + return &TodoHandler{svc: svc, v: v} +} + +// FindAll handles GET /todos — returns all todos as JSON. +func (h *TodoHandler) FindAll(w http.ResponseWriter, r *http.Request) { + httputil.HandleNoBody[[]domain.Todo](h.svc.FindAll).ServeHTTP(w, r) +} + +// Create handles POST /todos — creates a new todo from the JSON body. +func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) { + httputil.Handle[service.CreateTodoRequest, domain.Todo](h.v, h.svc.Create).ServeHTTP(w, r) +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go new file mode 100644 index 0000000..bea1885 --- /dev/null +++ b/internal/handler/user_handler.go @@ -0,0 +1,31 @@ +package handler + +import ( + "net/http" + + "code.nochebuena.dev/go/httputil" + "code.nochebuena.dev/go/valid" + "code.nochebuena.dev/go/todo-api/internal/domain" + "code.nochebuena.dev/go/todo-api/internal/service" +) + +// UserHandler wires HTTP requests to UserService. +type UserHandler struct { + svc service.UserService + v valid.Validator +} + +// NewUserHandler returns a UserHandler. +func NewUserHandler(svc service.UserService, v valid.Validator) *UserHandler { + return &UserHandler{svc: svc, v: v} +} + +// FindAll handles GET /users — returns all users as JSON. +func (h *UserHandler) FindAll(w http.ResponseWriter, r *http.Request) { + httputil.HandleNoBody[[]domain.User](h.svc.FindAll).ServeHTTP(w, r) +} + +// Create handles POST /users — creates a new user with optional permission bits. +func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) { + httputil.Handle[service.CreateUserRequest, domain.User](h.v, h.svc.Create).ServeHTTP(w, r) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..26656b6 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "net/http" + + "code.nochebuena.dev/go/rbac" + "code.nochebuena.dev/go/todo-api/internal/repository" +) + +// Auth reads the X-User-ID request header, looks up the user in the database, +// and stores an rbac.Identity in the context. +// +// Returns 401 if the header is absent or the user ID is not found — the two +// cases are intentionally indistinguishable to callers. +func Auth(userRepo repository.UserRepository) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid := r.Header.Get("X-User-ID") + if uid == "" { + http.Error(w, `{"code":"UNAUTHENTICATED","message":"missing X-User-ID header"}`, + http.StatusUnauthorized) + return + } + + user, err := userRepo.FindByID(r.Context(), uid) + if err != nil { + http.Error(w, `{"code":"UNAUTHENTICATED","message":"user not found"}`, + http.StatusUnauthorized) + return + } + + identity := rbac.NewIdentity(user.ID, user.Name, user.Email) + ctx := rbac.SetInContext(r.Context(), identity) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go new file mode 100644 index 0000000..f341822 --- /dev/null +++ b/internal/middleware/rbac.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "net/http" + + "code.nochebuena.dev/go/rbac" +) + +// Require guards a handler: it reads the rbac.Identity from context (set by Auth), +// resolves the permission mask from the provider, and rejects the request with 403 +// if any of the required permission bits are not set. +// +// Must be chained after Auth so that an identity is guaranteed in context. +func Require(provider rbac.PermissionProvider, resource string, perms ...rbac.Permission) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + identity, ok := rbac.FromContext(r.Context()) + if !ok { + http.Error(w, `{"code":"PERMISSION_DENIED","message":"no identity in context"}`, + http.StatusForbidden) + return + } + + mask, err := provider.ResolveMask(r.Context(), identity.UID, resource) + if err != nil { + http.Error(w, `{"code":"INTERNAL","message":"could not resolve permissions"}`, + http.StatusInternalServerError) + return + } + + for _, p := range perms { + if !mask.Has(p) { + http.Error(w, `{"code":"PERMISSION_DENIED","message":"insufficient permissions"}`, + http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/repository/permission_provider.go b/internal/repository/permission_provider.go new file mode 100644 index 0000000..e0d750e --- /dev/null +++ b/internal/repository/permission_provider.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + "code.nochebuena.dev/go/rbac" + "code.nochebuena.dev/go/sqlite" +) + +// DBPermissionProvider implements rbac.PermissionProvider by reading the +// user_role table. A missing row is treated as "no permissions" (mask = 0). +type DBPermissionProvider struct { + db sqlite.Client +} + +// NewPermissionProvider returns a DBPermissionProvider backed by the given client. +func NewPermissionProvider(db sqlite.Client) *DBPermissionProvider { + return &DBPermissionProvider{db: db} +} + +// ResolveMask returns the permission bit-mask for uid on resource. +// Returns 0 (no permissions) if no row exists for the user/resource pair. +func (p *DBPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) { + row := p.db.GetExecutor(ctx).QueryRowContext(ctx, + `SELECT permissions FROM user_role WHERE user_id = ? AND resource = ?`, + uid, resource, + ) + var bits int64 + if err := row.Scan(&bits); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return 0, err + } + return rbac.PermissionMask(bits), nil +} diff --git a/internal/repository/todo_repository.go b/internal/repository/todo_repository.go new file mode 100644 index 0000000..22cf86b --- /dev/null +++ b/internal/repository/todo_repository.go @@ -0,0 +1,61 @@ +package repository + +import ( + "context" + + "code.nochebuena.dev/go/sqlite" + "code.nochebuena.dev/go/todo-api/internal/domain" +) + +// TodoRepository defines persistence operations for todos. +type TodoRepository interface { + FindAll(ctx context.Context) ([]domain.Todo, error) + Create(ctx context.Context, todo domain.Todo) (domain.Todo, error) +} + +type todoRepository struct { + db sqlite.Client +} + +// NewTodoRepository returns a SQLite-backed TodoRepository. +func NewTodoRepository(db sqlite.Client) TodoRepository { + return &todoRepository{db: db} +} + +func (r *todoRepository) FindAll(ctx context.Context) ([]domain.Todo, error) { + rows, err := r.db.GetExecutor(ctx).QueryContext(ctx, + `SELECT id, title, done, created_at FROM todos ORDER BY created_at DESC`, + ) + if err != nil { + return nil, r.db.HandleError(err) + } + defer rows.Close() + + var todos []domain.Todo + for rows.Next() { + var t domain.Todo + if err := rows.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt); err != nil { + return nil, err + } + todos = append(todos, t) + } + if err := rows.Err(); err != nil { + return nil, err + } + if todos == nil { + todos = []domain.Todo{} // always return a slice, never nil + } + return todos, nil +} + +func (r *todoRepository) Create(ctx context.Context, todo domain.Todo) (domain.Todo, error) { + row := r.db.GetExecutor(ctx).QueryRowContext(ctx, + `INSERT INTO todos (title, done) VALUES (?, ?) RETURNING id, title, done, created_at`, + todo.Title, todo.Done, + ) + var t domain.Todo + if err := row.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt); err != nil { + return domain.Todo{}, r.db.HandleError(err) + } + return t, nil +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go new file mode 100644 index 0000000..c32e558 --- /dev/null +++ b/internal/repository/user_repository.go @@ -0,0 +1,91 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + + "code.nochebuena.dev/go/rbac" + "code.nochebuena.dev/go/sqlite" + "code.nochebuena.dev/go/xerrors" + "code.nochebuena.dev/go/todo-api/internal/domain" +) + +// UserRepository defines persistence operations for users and their role bits. +type UserRepository interface { + FindAll(ctx context.Context) ([]domain.User, error) + FindByID(ctx context.Context, id string) (domain.User, error) + Create(ctx context.Context, user domain.User) (domain.User, error) + SetPermissions(ctx context.Context, userID, resource string, mask rbac.PermissionMask) error +} + +type userRepository struct { + db sqlite.Client +} + +// NewUserRepository returns a SQLite-backed UserRepository. +func NewUserRepository(db sqlite.Client) UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) FindAll(ctx context.Context) ([]domain.User, error) { + rows, err := r.db.GetExecutor(ctx).QueryContext(ctx, + `SELECT id, name, email, created_at FROM users ORDER BY created_at DESC`, + ) + if err != nil { + return nil, r.db.HandleError(err) + } + defer rows.Close() + + var users []domain.User + for rows.Next() { + var u domain.User + if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil { + return nil, err + } + users = append(users, u) + } + if err := rows.Err(); err != nil { + return nil, err + } + if users == nil { + users = []domain.User{} + } + return users, nil +} + +func (r *userRepository) FindByID(ctx context.Context, id string) (domain.User, error) { + row := r.db.GetExecutor(ctx).QueryRowContext(ctx, + `SELECT id, name, email, created_at FROM users WHERE id = ?`, id, + ) + var u domain.User + if err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.User{}, xerrors.New(xerrors.ErrNotFound, "user not found") + } + return domain.User{}, r.db.HandleError(err) + } + return u, nil +} + +func (r *userRepository) Create(ctx context.Context, user domain.User) (domain.User, error) { + row := r.db.GetExecutor(ctx).QueryRowContext(ctx, + `INSERT INTO users (id, name, email) VALUES (?, ?, ?) + RETURNING id, name, email, created_at`, + user.ID, user.Name, user.Email, + ) + var u domain.User + if err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil { + return domain.User{}, r.db.HandleError(err) + } + return u, nil +} + +func (r *userRepository) SetPermissions(ctx context.Context, userID, resource string, mask rbac.PermissionMask) error { + _, err := r.db.GetExecutor(ctx).ExecContext(ctx, + `INSERT INTO user_role (user_id, resource, permissions) VALUES (?, ?, ?) + ON CONFLICT (user_id, resource) DO UPDATE SET permissions = excluded.permissions`, + userID, resource, int64(mask), + ) + return err +} diff --git a/internal/service/todo_service.go b/internal/service/todo_service.go new file mode 100644 index 0000000..c12e853 --- /dev/null +++ b/internal/service/todo_service.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + + "code.nochebuena.dev/go/todo-api/internal/domain" + "code.nochebuena.dev/go/todo-api/internal/repository" +) + +// CreateTodoRequest is the input for creating a new todo. +type CreateTodoRequest struct { + Title string `json:"title" validate:"required,min=1,max=255"` +} + +// TodoService handles todo business logic. +type TodoService interface { + FindAll(ctx context.Context) ([]domain.Todo, error) + Create(ctx context.Context, req CreateTodoRequest) (domain.Todo, error) +} + +type todoService struct { + repo repository.TodoRepository +} + +// NewTodoService returns a TodoService backed by the given repository. +func NewTodoService(repo repository.TodoRepository) TodoService { + return &todoService{repo: repo} +} + +func (s *todoService) FindAll(ctx context.Context) ([]domain.Todo, error) { + return s.repo.FindAll(ctx) +} + +func (s *todoService) Create(ctx context.Context, req CreateTodoRequest) (domain.Todo, error) { + return s.repo.Create(ctx, domain.Todo{Title: req.Title}) +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go new file mode 100644 index 0000000..1ab28af --- /dev/null +++ b/internal/service/user_service.go @@ -0,0 +1,65 @@ +package service + +import ( + "context" + + "code.nochebuena.dev/go/rbac" + "code.nochebuena.dev/go/todo-api/internal/domain" + "code.nochebuena.dev/go/todo-api/internal/repository" +) + +// CreateUserRequest is the input for creating a new user. +// CanRead / CanWrite seed the permission bits for the todos resource immediately. +type CreateUserRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Email string `json:"email" validate:"required,email"` + CanRead bool `json:"can_read"` + CanWrite bool `json:"can_write"` +} + +// UserService handles user business logic. +type UserService interface { + FindAll(ctx context.Context) ([]domain.User, error) + Create(ctx context.Context, req CreateUserRequest) (domain.User, error) +} + +type userService struct { + repo repository.UserRepository + idGen func() string +} + +// NewUserService returns a UserService. idGen is called to mint new user IDs (e.g. uuid.NewString). +func NewUserService(repo repository.UserRepository, idGen func() string) UserService { + return &userService{repo: repo, idGen: idGen} +} + +func (s *userService) FindAll(ctx context.Context) ([]domain.User, error) { + return s.repo.FindAll(ctx) +} + +func (s *userService) Create(ctx context.Context, req CreateUserRequest) (domain.User, error) { + user, err := s.repo.Create(ctx, domain.User{ + ID: s.idGen(), + Name: req.Name, + Email: req.Email, + }) + if err != nil { + return domain.User{}, err + } + + // Build and persist permission mask from the request flags. + var mask rbac.PermissionMask + if req.CanRead { + mask = mask.Grant(domain.PermReadTodo) + } + if req.CanWrite { + mask = mask.Grant(domain.PermWriteTodo) + } + if mask != 0 { + if err := s.repo.SetPermissions(ctx, user.ID, domain.ResourceTodos, mask); err != nil { + return domain.User{}, err + } + } + + return user, nil +}