3.2 KiB
3.2 KiB
F-154 — Architect Evidence
Feature
F-154 — Admin: separate customers from internal users (fix, high/high).
Problem
Two admin list endpoints both returned all identity_users, so the Customers and Users
pages showed storefront customers and backoffice staff mixed together:
GET /users(moduleusers,PgProfileRepository.listCustomers) → all users, no role filter. Consumed by the Customers page (customersApi.list→/api/users).GET /admin/users(modulesecurity) → all users unless?role=supplied. Consumed by the Users page (adminUsersApi.list→/api/admin/users).
Roles (DB identity_users.role, citext NOT NULL DEFAULT 'customer')
customer→ storefront client.admin/editor/pos_cashier/pos_manager→ internal / backoffice staff.
Decision
Force the separation in the backend (single source of truth), not client-side:
listCustomers(→GET /users) now always appendsAND iu.role = 'customer'(code literal, no user input → no injection). The?qsearch still applies onemail. Single-userfindCustomerById(/users/:id) is untouched (owner-or-admin, role-agnostic).GET /admin/users: base conditionrole <> 'customer'(literal) so the Users list NEVER returns storefront customers;?role=admin|editorstill narrows within internal staff.?role=customerresolves to an empty intersection (still no leak).- Frontend: remove the
customeroption from the Users page role dropdown (UX polish — backend already enforces the boundary). Customers page unchanged (already calls/users).
Alternatives considered
- Client-side filtering only: rejected — backend is the trust boundary; the admin API must
not leak customers through
/admin/users. - New
/customersendpoint: rejected — the frontend already binds Customers↔/usersand Users↔/admin/users; splitting now duplicates effort with no behavioral gain.
Boundary / security
usersmodule referencesidentity_usersonly as a SQL table name (existing pattern insearch); no TypeScript import crosses the identity↔users↔security boundary.lint:boundariesunaffected.- No migration (
identity_users.rolealready exists, NOT NULL DEFAULT 'customer'). - User input (
q,role) stays parameterized ($N); the role constants are code literals.
Test plan (no DB required → runs in npm test)
users/infrastructure/pg-profile-repository.test.ts: mockpg.Pool, assertlistCustomersemitsiu.role = 'customer', respectsq, count === select filter, returns only customer rows.security/api/security.routes.test.ts: register routes on a mock Fastify with a mock pool + mockedauthenticatereturningadmin; assertGET /admin/usersqueriesrole <> 'customer'and that?role=adminaddsAND role = $1; response excludescustomerrows.app/tests/users.itest.tsAC2/AC3: update assertion —/usersreturns the customer (ben), not the promoted admin (ana).
Risk
Medium-high: changes admin list semantics (/users now customer-only). Mitigations:
/users/:id (single) unchanged; only the LIST contract changes; existing itest updated;
no migration; boundary/lint verified.