Profile — Backend
Module shape, the guard chain for a handler that declares no permission, and why row selection does not route through PeopleAccessService.
Profile — Backend
Module
apps/api/src/modules/profile/
profile.module.ts imports RoleModule (RoleGuard injects RoleService)
profile.controller.ts one GET
profile.service.ts four self-scoped reads + two nested-list joins
dto/profile.dto.ts
profile.service.int.spec.tsProfileModule is registered in app.module.ts. It imports RoleModule because
RoleGuard injects RoleService, and a provider resolves its dependencies in the context
of the module that lists it — not the one it was written for. Omitting it fails at
InstanceLoader, naming the guard rather than the module.
The guard chain
@Controller("profile")
@UseGuards(JwtAuthGuard, RoleGuard)
export class ProfileController {
@Get()
async find(@CurrentAdmin() actor: AuthUser) { … }
}Three things here are deliberate and each closes a specific hole.
RoleGuard is declared explicitly
It is not a global guard — app.module.ts registers only JwtAuthGuard and
MaintenanceGuard as APP_GUARD. Controllers opt in.
@CurrentAdmin, not @CurrentUser
For a handler on the allowlist, RoleGuard returns true before its own request.user
check and before the active-role check that throws AUTH_ACTIVE_ROLE_REQUIRED. And
@CurrentUser() asserts nothing — it returns request.user verbatim.
So the pair @CurrentUser + allowlisted handler would serve a full profile to a session
that has not yet chosen a role. @CurrentAdmin() makes that assertion, and param
decorators run after guards, so it is reached.
test/structure/current-admin-guard.spec.ts asserts every @CurrentAdmin site also
applies RoleGuard, so the pairing is checked rather than remembered.
The allowlist entry
NO_PERMISSION_ADMIN_HANDLERS in role.guard.ts, mirrored in
test/structure/route-permissions.spec.ts. The two lists are duplicated on purpose and
a test asserts they are equal — the runtime deny and the CI gate must not drift.
One trap when editing that file: route-permissions.spec.ts regexes the whole guard
source for "(\w+Controller\.\w+)" and asserts the resulting set equals EXEMPT. A reason
string containing a quoted SomeController.method token would be picked up as an entry.
Why row selection is not PeopleAccessService.scopeFor
Every other people read in this codebase routes through scopeFor. This one does not, and
that is a considered divergence.
scopeFor returns { kind: "all" } for an all-scoped role holding the module
permission — which for a self-profile would then have to be intersected back down to the
caller anyway. eq(<table>.userId, actor.id) admits no such value: there is no scope,
present or future, that makes it return another person's row.
The service also takes no id parameter anywhere. A method that can be pointed at another user eventually is, and on a route with no declared permission that has to be structural rather than checked.
Guarantee added to the structure suite
Every assertion in route-permissions.spec.ts filters EXEMPT rows out first, and the
"behind RoleGuard" assertion only covered handlers that declare a permission. An exempt
handler shipped without RoleGuard therefore passed every case in the file.
A case was added in this change: every EXEMPT admin handler is still behind RoleGuard,
with BackupRestoreAdminController.live excluded because it is @Public() by design and
guarded by RestorePollTokenGuard instead.
Reuse
person is built by PERSON_SELECTION + toPersonDto from
people/shared/person-writer.service.ts, the same projection StudentDto and GuardianDto
embed.
Do not convert its lookup-name subqueries to joins. Ten names — two classification,
eight geography — come back as correlated subqueries, and the constant's own comment
explains why: two LEFT JOINs "would have to be added to all six call sites in the same
order or the shape silently diverges — which is the drift this shared constant exists to
prevent."
My Profile
The self-profile surface — what a person sees about their own record, which blocks appear for which person type, and the two categories of data deliberately excluded.
Profile — API
GET /api/profile and GET /api/mobile/portal/me — request, response shape, per-type block nullability, and error codes.