55 lines
3.2 KiB
Markdown
55 lines
3.2 KiB
Markdown
# 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` (module `users`, `PgProfileRepository.listCustomers`) → all users, no role filter.
|
|
Consumed by the Customers page (`customersApi.list` → `/api/users`).
|
|
- `GET /admin/users` (module `security`) → 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:
|
|
1. `listCustomers` (→ `GET /users`) now always appends `AND iu.role = 'customer'` (code literal,
|
|
no user input → no injection). The `?q` search still applies on `email`. Single-user
|
|
`findCustomerById` (`/users/:id`) is untouched (owner-or-admin, role-agnostic).
|
|
2. `GET /admin/users`: base condition `role <> 'customer'` (literal) so the Users list NEVER
|
|
returns storefront customers; `?role=admin|editor` still narrows within internal staff.
|
|
`?role=customer` resolves to an empty intersection (still no leak).
|
|
3. Frontend: remove the `customer` option 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 `/customers` endpoint: rejected — the frontend already binds Customers↔`/users` and
|
|
Users↔`/admin/users`; splitting now duplicates effort with no behavioral gain.
|
|
|
|
## Boundary / security
|
|
- `users` module references `identity_users` only as a SQL table name (existing pattern in
|
|
`search`); no TypeScript import crosses the identity↔users↔security boundary.
|
|
`lint:boundaries` unaffected.
|
|
- No migration (`identity_users.role` already 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`: mock `pg.Pool`, assert `listCustomers`
|
|
emits `iu.role = 'customer'`, respects `q`, count === select filter, returns only customer rows.
|
|
- `security/api/security.routes.test.ts`: register routes on a mock Fastify with a mock pool +
|
|
mocked `authenticate` returning `admin`; assert `GET /admin/users` queries `role <> 'customer'`
|
|
and that `?role=admin` adds `AND role = $1`; response excludes `customer` rows.
|
|
- `app/tests/users.itest.ts` AC2/AC3: update assertion — `/users` returns 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.
|