100 lines
4.1 KiB
Markdown
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').
|