feat(ADM-018): completed feature
This commit is contained in:
99
legacy/specs-old/F-006-users-rbac/DESIGN.md
Normal file
99
legacy/specs-old/F-006-users-rbac/DESIGN.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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').
|
||||
Reference in New Issue
Block a user