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:
105
work/artifacts/F-006/implementer.md
Normal file
105
work/artifacts/F-006/implementer.md
Normal file
@@ -0,0 +1,105 @@
|
||||
done -> work/artifacts/F-006/implementer.md
|
||||
|
||||
# F-006 — Users: profile, addresses, RBAC (Build)
|
||||
|
||||
Agent: implementer
|
||||
Stage: build
|
||||
Feature: F-006
|
||||
|
||||
## Scope delivered
|
||||
|
||||
Profile + address management with owner-or-admin authorization, on top of the
|
||||
F-005 identity foundation. No new npm dependencies were added.
|
||||
|
||||
## Files
|
||||
|
||||
### Migrations (immutable; new migrations only)
|
||||
- `project/migrations/003_identity_roles.js` — adds `identity_users.role`
|
||||
(`customer` | `admin`, default `customer`, NOT NULL) + index.
|
||||
- `project/migrations/004_users.js` — `users_profiles` (PK `user_id` FK →
|
||||
`identity_users`, `display_name`, `phone`, timestamps) and
|
||||
`users_addresses` (`id` uuid PK, `user_id` FK ON DELETE CASCADE, address
|
||||
fields, `is_default`, timestamps, `user_id` index).
|
||||
|
||||
### Shared auth contract
|
||||
- `project/src/shared/auth.ts` — `Role`, `CurrentUser`, `Authenticate`,
|
||||
`requireRole`, `requireOwnerOrAdmin`. Shared (not module-owned) so both
|
||||
identity and users use one authorization vocabulary without cross-module
|
||||
imports.
|
||||
|
||||
### Identity additions
|
||||
- `project/src/modules/identity/infrastructure/session-authenticator.ts` —
|
||||
resolves the session cookie into `CurrentUser` from the DB (expiry +
|
||||
revocation enforced in SQL). Exported through the module index so the
|
||||
composition root can inject it.
|
||||
- Role added to `User`, `PgUserRepository` (SELECT/RETURNING `role`), and the
|
||||
register/login responses.
|
||||
|
||||
### Users module (hexagonal)
|
||||
- `domain/profile.ts`, `domain/address.ts`, `domain/ports.ts`
|
||||
- `application/profile-use-cases.ts`, `application/address-use-cases.ts`
|
||||
- `infrastructure/pg-profile-repository.ts`,
|
||||
`infrastructure/pg-address-repository.ts`
|
||||
- `api/users.routes.ts` — registers the endpoints below.
|
||||
- `index.ts` — public API.
|
||||
|
||||
### Composition root
|
||||
- `project/src/app/build-app.ts` — registers `@fastify/cookie` once at root
|
||||
(cross-module infrastructure), wires identity routes, builds the
|
||||
`Authenticate` from identity and injects it into the users routes.
|
||||
|
||||
### Tests
|
||||
- `project/src/shared/tests/auth.test.ts` (unit)
|
||||
- `project/src/app/tests/users.itest.ts` (integration)
|
||||
- `project/src/infrastructure/db/tests/migrations.itest.ts` — full rollback now
|
||||
asserts `users_addresses`, `users_profiles`, identity and baseline all drop.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| GET | /users | admin only |
|
||||
| GET | /users/:id | owner or admin |
|
||||
| PATCH | /users/:id | owner or admin |
|
||||
| GET | /users/:id/addresses | owner or admin |
|
||||
| POST | /users/:id/addresses | owner or admin |
|
||||
| PATCH | /users/:id/addresses/:addressId | owner or admin |
|
||||
| DELETE | /users/:id/addresses/:addressId | owner or admin |
|
||||
|
||||
Authorization runs BEFORE existence checks: a non-owner gets 403 regardless of
|
||||
whether the target resource exists. Address operations are additionally scoped
|
||||
by `user_id` in SQL, so a caller cannot touch another user's address even with
|
||||
a valid id.
|
||||
|
||||
## Verification
|
||||
- `npm run lint`, `npm run lint:boundaries`, `npm run typecheck`,
|
||||
`npm run build`: clean.
|
||||
- `npm test`: 52 passed, 22 skipped (integration skipped without
|
||||
TEST_DATABASE_URL).
|
||||
- `npm run test:integration`: 22 passed against real PostgreSQL 16.
|
||||
- `./scripts/verify.sh`: green.
|
||||
- Live smoke (`PORT=3995`): register/login 201/200; PATCH own profile 200;
|
||||
GET own 200; cross-user GET 403; GET /users as customer 403; after promotion
|
||||
GET /users as admin 200; address CRUD 201/204; cross-user address 403;
|
||||
no-cookie GET 401. Smoke data removed afterwards.
|
||||
|
||||
## Deviations from TESTS.md (documented, not silent)
|
||||
- The planned `users/tests/ownership.test.ts` was merged into
|
||||
`shared/tests/auth.test.ts`, because the owner-or-admin decision lives in the
|
||||
shared contract (`shared/auth.ts`), not in the users module. Coverage is
|
||||
equivalent.
|
||||
- `@fastify/cookie` registration moved from identity's route scope to the app
|
||||
root. Fastify encapsulation meant the `request.cookies` decorator was not
|
||||
visible in the users scope (caused 500s); cookies are now cross-module
|
||||
infrastructure registered once at the composition root.
|
||||
|
||||
## Notes for gates
|
||||
- Reviewer: check the SQL column whitelist in `pg-profile-repository.upsert`
|
||||
and `pg-address-repository.update` (only hardcoded columns; values always
|
||||
parameterized), and that every users route calls `authenticate` then
|
||||
`requireOwnerOrAdmin` before touching data.
|
||||
- Security: session resolution is DB-backed (revocation + expiry in SQL); role
|
||||
is read from `identity_users` per request, so promotion/demotion is reflected
|
||||
immediately without trusting the client.
|
||||
- QA: integration suite exercises every acceptance criterion (AC1–AC4) plus the
|
||||
F-005 logout regression.
|
||||
Reference in New Issue
Block a user