Files
2026-08-17 22:23:10 +02:00

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): 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

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').