Files
mercadodevida/legacy/specs-old/F-006-users-rbac/DESIGN.md
2026-08-17 22:23:10 +02:00

100 lines
4.1 KiB
Markdown

# DESIGN — F-006 Users: profile, addresses, RBAC
## Boundary constraint that drives everything
Rule R1: a module may import only its own subtree, `src/shared`, Node builtins, and
npm packages. Therefore `users` MUST NOT import `identity`. The session authenticator
(which reads identity tables) is exported by identity's public API and INJECTED into
users by the composition root (`src/app/build-app.ts`). Shared contract types live in
`src/shared/auth.ts` so both modules agree without importing each other.
## Shared contract — src/shared/auth.ts
```ts
Role = 'customer' | 'admin'
CurrentUser = { id, email, role }
Authenticate = (request: FastifyRequest) => Promise<CurrentUser> // throws AppError 401
requireRole(user, role) -> void | throws AppError 403
```
## Role
Migration 003 (identity namespace): `ALTER TABLE identity_users ADD COLUMN role text
NOT NULL DEFAULT 'customer' CHECK (role IN ('customer','admin'))`. Role is the single
source of truth for "who may act as what" and travels with the authenticated user.
## identity additions
- `createSessionAuthenticator(pool): Authenticate` exported from identity index.
Reads `mdv_session` cookie, hashes token, resolves via
`identity_sessions JOIN identity_users` (valid, not revoked, not expired). Returns
CurrentUser or throws AppError(401, 'UNAUTHORIZED').
- Login response now includes `role` (additive).
## users module layout (hexagonal)
```
src/modules/users/
index.ts # registerUsersRoutes(app, { pool, authenticate })
domain/
profile.ts # Profile type + normalize
address.ts # Address type
errors.ts # ForbiddenError, NotFoundError
ports.ts # ProfileRepository, AddressRepository
application/
get-profile.ts update-profile.ts list-profiles.ts
list-addresses.ts create-address.ts update-address.ts delete-address.ts
infrastructure/
pg-profile-repository.ts pg-address-repository.ts
api/users.routes.ts
tests/
```
## Data model — migration 004_users
```sql
users_profiles(
user_id uuid PK REFERENCES identity_users(id) ON DELETE CASCADE,
display_name text, phone text,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
)
users_addresses(
id uuid PK DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
label text, recipient_name text NOT NULL, street text NOT NULL,
city text NOT NULL, postal_code text NOT NULL, country text NOT NULL,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
) + index on user_id
```
FK to identity_users is schema-level integrity only; users runtime queries touch only
`users_*` tables (prefix rule upheld).
## API (all guarded; ownership = param id === auth id OR role admin)
| Route | Authz | Notes |
|---|---|---|
| GET /users | admin only | list profiles (AC2/AC3) |
| GET /users/:id | owner-or-admin | profile; non-owner -> 403 (AC1) |
| PATCH /users/:id | owner-or-admin | upsert display_name/phone |
| GET /users/:id/addresses | owner-or-admin | list |
| POST /users/:id/addresses | owner-or-admin | create |
| PATCH /users/:id/addresses/:addressId | owner-or-admin | update |
| DELETE /users/:id/addresses/:addressId | owner-or-admin | delete |
Authorization runs BEFORE existence checks so a non-owner gets 403 regardless of
whether the target exists (no enumeration). Bodies validated with parseJson (zod).
## Wiring (build-app)
```
if (deps.pool) {
registerIdentityRoutes(...)
const authenticate = createSessionAuthenticator(deps.pool)
registerUsersRoutes(app, { pool, authenticate })
}
```
## Test strategy
- Unit (module-scoped): requireRole, ownership decision, address/profile normalization.
- Integration (app-scoped itest, real PostgreSQL): A->B 403; customer->admin-only 403;
admin->admin-only 200; address CRUD end-to-end; unauthenticated -> 401; admin role
granted by direct SQL UPDATE in the test.
## Risks / mitigations
- Cross-module sharing solved by injection + shared contract, not imports.
- Role escalation is impossible via API (role only set by DB/migration; register always
creates 'customer').