feat(F-006): users profile, addresses and RBAC

- users module: profile + address CRUD behind use cases (users_profiles,
  users_addresses)
- roles customer/admin on identity_users; role resolved from DB per request
- shared auth contract (Authenticate, requireRole, requireOwnerOrAdmin)
  injected from composition root; users never imports identity
- authorization runs before existence checks; address SQL scoped by user_id
- @fastify/cookie registered once at app root (cross-module)
- migrations 003_identity_roles + 004_users (reversible)
- no new npm dependencies; tests: unit 52, integration 22

Gates: reviewer/security/qa APPROVED; verify.sh green
This commit is contained in:
rikrdo
2026-08-15 09:27:38 +02:00
parent 75293f39bc
commit 546971280f
37 changed files with 1732 additions and 161 deletions

View File

@@ -40,8 +40,9 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
5xx messages are always generic; stack traces stay in server logs only.
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
- Auth codes: `INVALID_CREDENTIALS` (401), `EMAIL_ALREADY_REGISTERED` (409),
`TOO_MANY_ATTEMPTS` (429, with `Retry-After` header).
- Auth codes: `UNAUTHORIZED` (401, missing/invalid/revoked session),
`FORBIDDEN` (403, role or ownership check failed), `INVALID_CREDENTIALS` (401),
`EMAIL_ALREADY_REGISTERED` (409), `TOO_MANY_ATTEMPTS` (429, with `Retry-After` header).
- Log level via `LOG_LEVEL` env var (default `info`); logs are JSON only.
## Authentication (identity module)
@@ -49,11 +50,11 @@ is separate from deployment (no redeploy). Copy `.env.example` to `.env` to star
The server is the only authority for identity; the frontend is never trusted with
session or credential state.
| Route | Result |
| ------------------- | --------------------------------------------------- |
| POST /auth/register | `201` + `{ id, email, createdAt }` |
| POST /auth/login | `200` + `{ id, email }` + `Set-Cookie: mdv_session` |
| POST /auth/logout | `204`, cookie cleared, session revoked (idempotent) |
| Route | Result |
| ------------------- | --------------------------------------------------------- |
| POST /auth/register | `201` + `{ id, email, role, createdAt }` |
| POST /auth/login | `200` + `{ id, email, role }` + `Set-Cookie: mdv_session` |
| POST /auth/logout | `204`, cookie cleared, session revoked (idempotent) |
- Passwords: argon2id (OWASP parameters). Only the PHC hash is stored, never
plaintext or anything reversible.
@@ -68,6 +69,33 @@ session or credential state.
swap later without touching use cases).
- Identity routes are wired only when the app is built with a DB pool.
## Users and RBAC (users module)
Every route resolves the session cookie against the DB first (expired/revoked
sessions and missing cookies get `401 UNAUTHORIZED`). Roles are `customer`
(default) and `admin`; the role is read from `identity_users` on every request,
so promotions/demotions apply immediately. Role changes are an out-of-band DB
operation in this slice (no admin API yet).
| Route | Access | Result |
| -------------------------------------- | -------------- | ---------------------------------- |
| GET /users | admin only | `200` + `{ items: [profile] }` |
| GET /users/:id | owner or admin | `200` profile, `404` if none yet |
| PATCH /users/:id | owner or admin | `200` upserted profile |
| GET /users/:id/addresses | owner or admin | `200` + `{ items: [address] }` |
| POST /users/:id/addresses | owner or admin | `201` address |
| PATCH /users/:id/addresses/:addressId | owner or admin | `200` address, `404` if not theirs |
| DELETE /users/:id/addresses/:addressId | owner or admin | `204`, `404` if not theirs |
- Authorization is checked before existence: a non-owner gets `403 FORBIDDEN`
regardless of whether the target resource exists (no enumeration).
- Address queries are scoped by `user_id` in SQL, so a valid foreign address id
is unreachable.
- `GET /users` lists users that have a profile row (users who have patched their
profile at least once).
- The users module never imports identity: session resolution arrives as an
injected `Authenticate` function from the composition root.
## Database (local dev)
```bash
@@ -100,7 +128,8 @@ src/
├── modules/ # business modules, one folder each
│ ├── health/ # exemplar module: public API only via index.ts
│ ├── flags/ # feature flags (unknown default OFF, runtime flip)
── identity/ # register/login/logout, argon2, sessions, rate limit
── identity/ # register/login/logout, argon2, sessions, rate limit
│ └── users/ # profile + address CRUD, owner-or-admin RBAC
└── shared/ # cross-cutting helpers (error envelope, input parsing)
```