4.1 KiB
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
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): Authenticateexported from identity index. Readsmdv_sessioncookie, hashes token, resolves viaidentity_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
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').