Skip to main content

API Security

How the authentication and authorization model is enforced in the NestJS API. This is the implementation reference for securing endpoints; see the architecture doc for the conceptual model.

Everything below lives under api/src/app/auth and api/src/app/identity.

Guards

Three composable guards, applied in order — each depends on the previous one.

GuardEnforcesOn failure
JwtAuthGuardValid bearer JWT; attaches request.user (AuthenticatedUser)401
TenantContextGuardX-Tenant-Code header + an active membership in that tenant; attaches request.activeContext (roles + permissions)400 (missing header) / 403 (no membership)
PermissionsGuardPermissions from @RequirePermissions(...) are all present in the active context (AND semantics)403

PermissionsGuard is a no-op when a route has no @RequirePermissions metadata, so it is safe to pair with TenantContextGuard on every tenant-scoped route.

Securing an endpoint

@Controller('students')
@UseGuards(JwtAuthGuard, TenantContextGuard, PermissionsGuard)
export class StudentsController {
@Get()
@RequirePermissions('students.read')
list(@ActiveContext() ctx: MeMembership) {
// ctx.schoolId + ctx.permissions[].scope drive data filtering
}

@Post()
@RequirePermissions('students.manage')
create(@ActiveContext() ctx: MeMembership) { /* ... */ }
}

Decorators:

  • @CurrentUser() — the authenticated principal (from the JWT).
  • @TenantCode() — raw X-Tenant-Code header value (the tenant code).
  • @ActiveContext() — the resolved membership: schoolId, roles, and permissions (each with its scope).

Permission vs scope

Guards enforce permission only (Phase 1). Scope (Phase 2) is the controller/service's responsibility: read ctx.permissions for the matched permission's scope (school, assigned-sections, own-children, self, none) and translate it into a query filter. Guards never see data rows.

Pluggable identity provider

Authentication is abstracted behind an IdentityProvider interface, so Keycloak is the default but not assumed. Selected via the AUTH_PROVIDER env var:

AUTH_PROVIDERProviderNotes
keycloak (default)KeycloakIdentityProviderValidates against realm JWKS; maps realm/resource roles
azure-adAzureAdIdentityProviderIssuer from AZURE_AD_TENANT_ID; maps oid/roles/groups

Each provider supplies JWKS/issuer/audience validation options and maps raw JWT claims to the canonical AuthenticatedUser. Swapping providers requires no controller changes.

Discovery: /me

GET /api/me returns the caller's identity, platform roles/permissions, and all active memberships. Without X-Tenant-Code it lists memberships (each with its tenantCode) for the client to choose from; with a valid X-Tenant-Code it also returns the resolved activeContext. This is how a client discovers which tenant codes to send on subsequent requests.