Identity, Access & Tenancy
Part of the Anaya Care product wiki. See 00-overview.md.
Purpose
This module covers who can log in, what they can do, and which tenant's data they can see. It spans:
- Authentication — password/phone/magic-link/Google/Apple login, JWT access tokens, rotating refresh tokens, logout denylisting (
apps/backend/src/auth/). - Identity — the
Userentity plus role-specific profile entities, signup and invitation flows, identity verification (email codes, SMS, government ID), and account lifecycle (separation, deletion) (apps/backend/src/users/). - Authorization — a granular, business-configurable permission system (
BusinessPermissionin@anaya/shared) enforced byPermissionsGuard, with role-based fallbacks viaRolesGuard. - Tenancy — every business-scoped document carries a
businessref; scoping is enforced by a CLS-based Mongoose plugin, not by guards (apps/backend/src/common/plugins/business-scope.plugin.ts). - Business administration — the
Businesstenant entity, owner signup/onboarding, custom roles (BusinessRole), and platform-wide pricing (BaseRate/CityRate).
Entities & Data Model
User (users collection)
apps/backend/src/users/entities/user.entity.ts
| Field | Type | Notes |
|---|---|---|
firstName, lastName, preferredName, fullName | string | fullName is a virtual (lines 50–55) |
role | UserRole enum | default Representative (line 69) |
customRole | ObjectId → BusinessRole | only set when role === Custom, else null (lines 72–81) |
business | ObjectId → Business | tenant-scoping field; the auto-scoping plugin keys on it (lines 83–91) |
businessAssignmentStatus | BusinessAssignmentStatus | Pending/Active/Suspended/Terminated/Resigned (packages/shared/src/enums/domain-status.ts:109–115) |
email, username, phone | string | sparse-unique via partial-filter indexes (lines 295–309); empty strings normalized to null in a pre('save') hook (lines 281–290) |
password | string, select: false | bcrypt hash; invited users get the literal placeholder 'default-password' (auth/constants/password.constants.ts:14) |
emailVerified, phoneVerified | Date | null | verification timestamps |
isActive, isOnboarded | boolean | both default false; isActive set true at signup/login |
inviteStatus[], inviteEmailId, inviteEmailStatus[] | arrays | invite lifecycle + Resend email webhook history (lines 127–160) |
verificationStatus | UserVerificationStatus | PENDING_EMAIL/PENDING_PHONE/PENDING_ID/REJECTED/ACTIVE (domain-status.ts:33–39) |
governmentIdVerification | ObjectId → GovernmentIdVerification | lines 231–236 |
googleId/appleId (+ …LinkedAt) | string | OAuth links, unique partial indexes (lines 310–320) |
tokenVersion | number | bumping it invalidates all JWTs (checked in JwtStrategy, line 54 of jwt.strategy.ts) |
passwordResetAttempts, passwordResetLockedUntil | number/Date | lockout fields (lines 238–242) |
deletedAt, deletionRequestedAt, deletionReason | — | account-deletion request workflow (lines 250–258) |
separationEffectiveDate/Reason/Type/InitiatedBy/InitiatedAt | — | care-provider termination/resignation tracking (lines 260–274) |
careProvider, medicalProfessional | ObjectId refs | pointers to role profile docs (lines 183–195) |
| Preferences | — | timezone, timeFormat, dateFormat, weekStart, expoPushTokens[], birthday, unReadNotificationCount |
Key indexes: {business, role}, {verificationStatus}, {separationEffectiveDate, businessAssignmentStatus} (cron), {deletedAt} (lines 322–335).
Business (businesses collection)
apps/backend/src/business/entities/business.entity.ts
| Field | Type | Notes |
|---|---|---|
name, slug | string | slug globally unique (line 38) |
status | BusinessStatus | active/inactive/suspended, default active |
owner | ObjectId → User | set during owner signup |
onboardingStatus, onboardingCompletedAt | BusinessOnboardingStatus | pending/complete; default Complete (line 75) — but owner signup explicitly creates with Pending (users.service.ts:2774) |
| Onboarding survey fields | — | phone, address, careTypes[], yearsInOperation, clientCountRange, caregiverCountRange, currentSchedulingMethod, priorityFeatures[], matchingCriteria[], operationalChallenges[], successGoal, referralSource (lines 81–143, mapped to onboarding "Steps 1–7") |
baseRolePermissionOverrides | Record<UserRole, BusinessPermission[]> | null | per-business override of default role permissions (lines 145–153) |
settings | Record<string, any> | untyped settings bag (line 48) |
BusinessRole (business_roles collection) — custom roles
apps/backend/src/business/entities/business-role.entity.ts
| Field | Notes |
|---|---|
name | unique per business ({business, name} unique index, line 59) |
permissions[] | array of BusinessPermission values |
description, color | UI metadata |
business | required tenant ref |
Created by owners on the Roles & Permissions settings page, or auto-created by migration 031, which converted every legacy Care Manager user to role: Custom + a "Care Manager" BusinessRole (apps/backend/src/migrations/031-migrate-care-manager-to-custom-role.ts). The CareManager role no longer exists in UserRole.
Profile entities
| Entity | Collection | Linked by | Key fields | Source |
|---|---|---|---|---|
CareProviderProfile | care_provider_profiles | userId → User | about, skills[] (refs), workExperiences[], education[], licenses[] (embedded, with files[] → LockCare), averageRating, isAcceptingJobs | users/entities/care-provider-profile.entity.ts |
RepresentativeProfile | representative_profiles | user → User, client → Client (both required) | relationshipType, relationshipDescription, decisionAuthorities[], notes, isActive; unique {user, client} among active rows (lines 65–68) | users/entities/representative-profile.entity.ts |
MedicalProfessionalProfile | medical_professional_profiles | userId → User | role (MedicalProfessionalRole), speciality, subSpeciality, upin, npi | users/entities/medical-professional-profile.entity.ts |
Naming history: FamilyProfile was renamed to RepresentativeProfile by migration 025, which renamed the family_profiles collection, flipped users.role 'Family' → 'Representative', and rewrote Family-referencing values across care proposals, reviews, requests, notifications, and priorityFeatures (apps/backend/src/migrations/025-family-to-representative-rename.ts, header comment). RepresentativeProfile is the single source of truth for representative↔client links — the legacy User.families[] / Client.families[] arrays were removed in migration 023.
None of the three profile entities has a business field — they are tenant-scoped only transitively through their User (see Tenancy below).
BaseRate / CityRate (base_rates, city_rates collections)
apps/backend/src/base-rates/entities/base-rate.entity.ts, city-rate.entity.ts
| Entity | Fields |
|---|---|
BaseRate | hourlyRate, overtimeRate, liveInRate, vasRates {MD, CT, HA, AF, CB, CM} |
CityRate | state, city, rates {hourlyRate, overtimeRate, liveInRate, vasRates}, isSameAsBaseRate |
Neither entity has a business field, so the auto-scoping plugin does not apply — rates are platform-global and shared across all tenants, even though the API is gated by the business-level permissions BASE_RATES_VIEW/BASE_RATES_MANAGE (base-rates/base-rates.controller.ts:26–79).
Auth support entities (apps/backend/src/auth/entities/)
RefreshToken— SHA-256 token hash,userId,familyId(rotation family),tokenVersion,userAgent,expiresAt(refresh-token.entity.ts, used byrefresh-token.service.ts).VerificationToken— password-reset tokens.EmailVerificationCode— 6-digit code records with attempt counters.GovernmentIdVerification— statusPENDING/APPROVED/REJECTED/EXPIRED+ document type (domain-status.ts:44–49).AuthAuditLog— security audit events (e.g.,CROSS_BUSINESS_ACCESSlogged when a SuperAdmin reads a business inbusiness/platform.controller.ts:34–45).
(BASE_RATE/CITY_RATE have no FK to BUSINESS — they are platform-global.)
Workflows & State Machines
Login & sessions
Login methods (auth/auth.controller.ts):
POST /auth/password— username or email + password, bcrypt compare (auth.service.ts:284–321). Blocked while invite is still pending (line 297).POST /auth/phone/send-code+POST /auth/phone— SMS OTP via the SMS sender service (Twilio Verify) (auth.service.ts:323–367).POST /auth/magic-link+GET /auth/magic-link— emailed JWT link; verifying it also accepts a pending invite and marks email/phone verified (auth.service.ts:251–282).POST /auth/google,POST /auth/apple(+ link/unlink endpoints) — OAuth sign-in (auth.controller.ts:671–771).
Token model (auth/services/token.service.ts, refresh-token.service.ts):
- Access token: JWT, 15 minutes (
SESSION.ACCESS_TOKEN_EXPIRY_SECONDS,packages/shared/src/constants/common.ts:481). Payload:sub/userId,username,role,businessId,tokenVersion(token.service.ts:31–42). Extracted fromAuthorization: Beareror a?token=query param (for PDF streaming) (jwt.strategy.ts:25–30). - Refresh token: opaque 96-hex-char random string, 30 days, stored as SHA-256 hash in Mongo with a
familyIdfor rotation/theft detection; a 30-second grace window tolerates concurrent refreshes (refresh-token.service.ts:24–41).POST /auth/refreshis@Public()+ rate-limited (30/min) and rotates the pair (auth.controller.ts:157–163,auth.service.ts:415–468). - Logout (
POST /auth/logout): revokes the refresh token and adds the access token to a Redis denylist keyed bysub:expuntil natural expiry (token.service.ts:79–105). - Per-request validation (
jwt.strategy.ts:36–90): rejects denylisted tokens, deleted users,tokenVersionmismatches, and users whosebusinessAssignmentStatusisTerminated/Resigned. It re-reads the user from the DB every request and resolves the effectivepermissionsarray fresh viaPermissionServiceso role/permission changes apply immediately, not after token expiry.
Signup
POST /auth/signup(no guard — open) creates the user, auto-logs-in, and returns role-basedrequiredSteps/nextStep(auth.controller.ts:257–313):- Care Provider: email verification (if email given) → phone verification (required) → government ID → admin review (
PENDING_ID) →ACTIVE(auth.controller.ts:378–418). - Representative: email verification required, phone/Government ID optional.
- Care Provider: email verification (if email given) → phone verification (required) → government ID → admin review (
- Business assignment at public signup (
users.service.ts:170–204): invite flows inherit the inviter's business from CLS; self-signup Care Providers getbusiness: null(independent); all other public signups fall back to the first active business bycreatedAt— a platform-wide default-tenant behavior. POST /auth/signup/owner(rate-limited 5/15 min) creates aBusiness(statusActive, onboardingPending) plus anOwneruser, and signs them in (auth.controller.ts:315–332,users.service.ts:2729–2820).POST /auth/signup/save-progress/GET /auth/signup/progress— resumable signup state in Redis with a 24-hour TTL (auth.controller.ts:334–370).- Email verification codes: 10-minute expiry, max 5 check attempts, max 3 resends, 60-second resend cooldown (
auth.service.ts:147–160). - Password reset: 1-hour token; account lockout after 5 failed attempts for 30 minutes (
auth.service.ts:1112,1217–1224); admin unlock viaPOST /auth/admin/unlock-account.
Invitation flow
users.service.ts:1840–2070, auth.controller.ts:1440–1481:
- Admin invites by email or phone (
POST /users/invite-by-email|invite-by-phone, gated byUSERS_INVITE). The user is created immediately withinviteStatus: [INVITED],businessAssignmentStatus: PENDING, and the placeholder password'default-password'. - A 7-day magic-link JWT is emailed/SMS'd. Mobile-only roles (CareProvider, Representative) get a link to the web
/invitepage (which redirects to the app); other roles get/accept-invite(users.service.ts:1962–1965). - If the email/SMS send fails outright the user record is deleted (rollback); if SMS is merely unconfigured the record is kept for re-invite (
users.service.ts:1894–1913). - Accepting (
POST /auth/accept-invitefrom mobile, or magic-link verification on web) flipsinviteStatustoACCEPTEDand signs the user in;POST /auth/set-initial-password(JWT-guarded) replaces the placeholder password. POST /users/invite-super-admincreates a platformSuperAdmin(no business) via the same pattern; the route itself has no@RequirePermissionsand is checked in the service/controller logic (users.controller.ts:331–339).
Profile setup (per role)
profile-setup/profile-setup.service.ts:39–74 — steps and required subset:
| Role | All steps | Required for completion |
|---|---|---|
| CareProvider | about, skills, education, work_experience, licenses | about, skills |
| MedicalProfessional | professional_role, specialty, credentials | all three |
| Representative | none | none — "admin handles linking" (lines 335–341) |
| Owner / Admin / SuperAdmin / System / Custom | none | none |
POST /profile-setup/complete-step upserts the role profile document and sets User.isOnboarded once all required steps are done (lines 156–237). GET /profile-setup/status also "auto-fixes" a stale isOnboarded flag (lines 132–138).
Business onboarding (Owner)
- Owner signup leaves
Business.onboardingStatus = pending; the web proxy forces Owners to/setup-businessuntil complete (apps/web/proxy.ts:296–313). PATCH /businesses/:id/onboardingsaves survey steps;POST /businesses/:id/onboarding/completefinishes;POST /businesses/:id/onboarding/invite-teambulk-invites teammates by email with placeholder names'—'(business/business.controller.ts:87–139). All onboarding routes are@Roles(SuperAdmin, Owner)with anassertOwnershipcheck that Owners only touch their own business (lines 35–42).
User status lifecycle
(UserVerificationStatus, domain-status.ts:33–39; transitions implemented across auth.controller.ts:372–418 and the verification endpoints.)
Separately, businessAssignmentStatus tracks employment: Pending (invited) → Active → Suspended/Terminated/Resigned. Separation is scheduled with an effective date via POST /users/:id/separate, reversible via :id/reinstate and DELETE :id/pending-separation (gated by USERS_DEACTIVATE, users.controller.ts:633–676); a cron processes due separations (users/care-provider-separation-cron.service.ts). Terminated/Resigned users are rejected at JWT validation (jwt.strategy.ts:11–14, 58–65). Account deletion is a request → admin execute/cancel queue (users.controller.ts:463–631, gated by USERS_DELETE).
Business Rules & Constraints
Roles
UserRole(single source of truth,packages/shared/src/enums/user-role.ts:9–23):SuperAdmin,System(platform roles, no business),Owner,Admin,CareProvider,Representative,MedicalProfessional,Custom,Other.- The legacy "SuperAdmin → Admin → CareManager → CareProvider → Family → MedicalProfessional" hierarchy from older docs no longer matches the code: Family was renamed Representative (migration 025) and CareManager was migrated to a Custom
BusinessRole(migration 031).Ownerwas introduced as a distinct business-owner role. - Role groups:
ADMIN_ROLES = [Owner, Admin, SuperAdmin, System],OWNER_ROLES,CAREGIVER_ROLES,MOBILE_ONLY_ROLES = [CareProvider, Representative],BUSINESS_ROLES,PLATFORM_ROLES(user-role.ts:39–108). The role-group helpers are marked@deprecatedin favor of permission checks (lines 47–59). PERMISSION_BYPASS_ROLES = [SuperAdmin, System, Owner]always have all permissions and cannot be overridden (user-role.ts:250–258).CONFIGURABLE_BASE_ROLES = [Admin, CareProvider, Representative, MedicalProfessional]— the only roles whose defaults a business can override (user-role.ts:114–119).
Permissions
BusinessPermission(packages/shared/src/enums/business-permission.ts) defines ~150 granular permissions asmodule:actionor scopedmodule:action:{own|assigned|all}. Scope is hierarchical —:all⊇:assigned⊇:own— implemented byhasScopedPermission()/hasAnyScopedPermission()/getHeldScope()(lines 281–394).- Default sets per configurable role live in
DEFAULT_ROLE_PERMISSIONS(packages/shared/src/constants/default-role-permissions.ts); convention: Admin →:all, CareProvider →:assigned, Representative →:own, MedicalProfessional →:assigned(file header comment). - Per-business overrides live in
Business.baseRolePermissionOverrides(key = role string), managed viaPUT/DELETE /business-roles/permissions/base/:role(gated byROLES_CONFIGURE,business/business-roles.controller.ts:78–101). - Custom roles:
BusinessRoledocuments with arbitrary permission sets; users withrole: Customget exactlyBusinessRole.permissions(auth/services/permission.service.ts:58–65). - Resolution order (
permission.service.ts:34–84): bypass roles → ALL; nobusinessId→[](non-business users have no permissions); Custom →BusinessRole.permissions; base role → business override if present, else defaults. PERMISSION_GROUPSandSCOPED_PERMISSION_GROUPS(packages/shared/src/constants/permission-groups.ts) are UI metadata for the web Roles & Permissions matrix — groups like Initial Assessments, Care Proposals, Users, Business Administration, etc., with scoped families rendered as None/Own/Assigned/All selectors.PermissionService.getUserIdsWithPermission()fans out notifications to every user (base, Owner, or custom role) whose effective set satisfies a permission (permission.service.ts:119–171).
Guard wiring — verified enforcement mechanics
Registered global guards, in execution order (apps/backend/src/app.module.ts:287–298): BusinessGuard → PermissionsGuard → IdleTimeoutGuard. There is no global authentication guard — JwtAuthGuard (Passport jwt) is bound per-controller or per-route only.
Consequences, verified in code:
- Routes are unauthenticated by default. A route is only protected if its controller/handler binds
JwtAuthGuard(orAuthGuard('jwt')). The@Public()decorator merely tellsJwtAuthGuardand the global guards to skip (auth/guards/jwt-auth.guard.ts:12–23). - Global guards run before controller-bound guards (NestJS ordering), so when the global
BusinessGuard/PermissionsGuard/IdleTimeoutGuardinstances execute,req.useris not yet populated — and all three explicitly returntruewhenrequest.useris falsy (business.guard.ts:56–57,permissions.guard.ts:66–71,idle-timeout.guard.ts:43–44). ThePermissionsGuarddoc comment states this design explicitly: the global instance "defers when req.user is not yet populated… The controller-level instance then performs the actual permission check after JwtAuthGuard has authenticated the user" (permissions.guard.ts:23–27). - Therefore
@RequirePermissions(...)is only enforced where the controller also bindsPermissionsGuardafterJwtAuthGuard— e.g.@UseGuards(JwtAuthGuard, PermissionsGuard). 24 controllers do this correctly (users, auth admin endpoints, base-rates, business-roles, trainings, job-postings, blog, announcements, wound-care, incident-reports, health-metrics, requests, knowledge-base, statistics, platform-logs, platform-health, home-inventories, essential-needs, daily-motivations, feature-requests, ai-settings). - 22 controllers use
@RequirePermissionsbut never bindPermissionsGuard, so their permission requirements are decorative — never checked at runtime (the global instance passes pre-auth; no local instance runs post-auth). Verified by diffing controllers that referenceRequirePermissionsvs. those that referencePermissionsGuard:care-proposals/care-proposal-reviews|care-proposal-versions|care-proposals.controller.ts,chat/chat.controller.ts,client-engagements/controllers/shift-engagement-assignment.controller.ts,client-meals/controllers/client-meals|shift-meal-assignment.controller.ts,clients/controllers/business-calendar|client-care-provider-tasks|client-engagement-activities|client-schedules|client-task-submissions|clients|representative-tasks|shift-summary|task-submission-comments|task-templates.controller.ts,health-observations/health-observations.controller.ts,initial-assessments/initial-assessments.controller.ts,lock-care/lock-care.controller.ts,posts/posts.controller.ts,shifts/shifts.controller.ts. Example:clients.controller.tsbinds only@UseGuards(JwtAuthGuard)at class level (line 92) while methods carry@RequirePermissions(CLIENTS_MANAGE)etc. (lines 132+). These routes are authenticated but not permission-checked; effective access control is whatever the service layer does. BusinessGuard(business-must-be-Active check) andIdleTimeoutGuard(HIPAA 15-minute idle timeout) are bound only globally — no controller binds them — so by the same ordering both no-op on every authenticated route (req.useris always undefined when they run). The active-business enforcement and idle-timeout enforcement described in their comments cannot be observed to execute for HTTP requests. The idle-activity timestamp is also only written inside the guard (idle-timeout.guard.ts:63–68), so it is never recorded either.PermissionsGuardsemantics when it does run: no decorator → pass (backward compatible); bypass roles → pass; otherwise the user needs at least one of the listed permissions (OR logic), scope-aware, sourced fromreq.user.permissionsresolved byJwtStrategy(permissions.guard.ts:58–98). Denials throw a typed 403PERMISSION_DENIED.RolesGuard+@Roles(...)provides coarse role checks where bound (e.g.BusinessController/PlatformControllerare@Roles(SuperAdmin)class-wide with per-route relaxation to Owner plus anassertOwnershiptenancy check —business.controller.ts:26–42).
Tenancy enforcement
Tenant scoping is not done by guards. The mechanism (all global):
BusinessScopeInterceptor(APP_INTERCEPTOR, runs after guards soreq.userexists) copiesuser.businessIdfrom the JWT intoreq.businessIdand CLS (common/interceptors/business-scope.interceptor.ts). Platform roles,@Public(), and@SkipBusinessScope()set it tonull.- A global Mongoose plugin (
common/plugins/business-scope.plugin.ts) activates only on schemas that have abusinesspath (line 43–46) and, when CLS has a businessId, injects{business}intofind/findOne/count/update/deletefilters, prepends$matchto aggregates, and stampsbusinesson saves/inserts. Callers can bypass with the query option_bypassBusinessScope(lines 52–55). - When the businessId is
null(SuperAdmin, public routes, cron jobs, migrations, WebSocket handlers, or any code outside the HTTP CLS context) the plugin silently does nothing (lines 109–126) — those code paths see all tenants unless they filter explicitly. - Entities without a
businessfield are never auto-scoped:BaseRate,CityRate,CareProviderProfile,RepresentativeProfile,MedicalProfessionalProfile,RefreshToken, etc. Their isolation depends entirely on service-level query shapes. (Note:ClientCarePlandoes have abusinessfield —clients/entities/client-care-plan.entity.ts:244— so itsfindOne-family reads are plugin-scoped even where service filters are clientId-only; sibling modules that reported clientId-only reads are relying on this plugin.) email,username, andphoneuniqueness is platform-global, not per-business (user.entity.ts:295–309) — a person cannot hold accounts in two businesses with the same email.
Surfaces (Web & Mobile)
Mobile (apps/mobile/app/)
- Auth screens —
(auth)/sign-in.tsx,sign-up.tsx,phone-signin.tsx,magic-link-signin.tsx,verify-magic-link.tsx,verify-email.tsx,verify-phone.tsx,forgot-password.tsx,reset-password.tsx,government-id/(submission),verification-pending.tsx,verification-rejected.tsx,accept-invite.tsx,invite-welcome.tsx,user-onboarding.tsx. - Profile setup —
setup-profile/careprovider/(about, skills, education, work-experience, licenses + add-* screens),setup-profile/medicalprofessional/,setup-profile/guide.tsx. These mirror the backend step lists exactly. - Onboarding —
(onboarding)/index.tsx. - Frontend-only gating —
lib/route-guards.tsandlib/tab-config.tsshow/hide tabs and screens by role/permission, andlib/business-permissions.tsmirrors permission checks client-side; auth/session state lives instore/auth-store.ts. These are UX gates only — server enforcement is the guard system above.
Web (apps/web/)
- Auth pages —
app/(app)/(auth)/signin,get-started(owner signup),verify-email,reset-password,verification-pending,verification-rejected; publicaccept-invite,invite(mobile redirect),download-app,account-deletion. - Business onboarding —
app/(app)/(onboarding)/setup-business. - User management —
app/(app)/(admin)/dashboard/users(list,[id]detail,roleviews),dashboard/settings/roles(custom roles & permission matrix driven byPERMISSION_GROUPS),dashboard/settings/business,dashboard/base-rates; SuperAdmin console underapp/(app)/(platform)/platform. - Middleware (frontend-only rules) —
apps/web/proxy.ts: refreshes tokens in middleware, redirects unauthenticated users to/signin, routes unverified users to verification pages, forces Owners withonboardingStatus: pendingto/setup-business(lines 296–313), and blocks mobile-only roles (CareProvider, Representative) from/dashboardand/onboarding, sending them to/download-app(lines 316–330). This mobile-only block exists only in web middleware — the API itself does not restrict those roles by surface. - Client-side permission checks —
lib/hooks/use-permissions.ts(usePermissions()readingsession.user.permissions, bypass roles short-circuit) andlib/permissions.ts; session is stored in cookiessession_user+session_permissions(lib/auth-cookies.ts:22–42).
Cross-Module Dependencies
- Every module depends on this one:
JwtAuthGuard/PermissionsGuard/RolesGuardand@RequirePermissionsgate all controllers;JwtStrategyinjectspermissionsintoreq.userfor downstream services (auth/strategy/jwt.strategy.ts:75–88). - Clients / Care Plans / Shifts / Assessments / Proposals (01, 02, 03, 04, 05) — consume the tenancy plugin via their
businessfields and rely on permission scopes (:own/:assigned/:all) for row filtering (e.g.,getHeldScope,business-permission.ts:381–394).RepresentativeProfile.clientis the family↔client link used in client access checks. - Job postings (06) — accepting an application assigns an independent CareProvider (
business: nullself-signup) to the hiring business (JOB_POSTINGS_APPLICATIONS_DECIDEdescription,permission-groups.ts:482). - Notifications —
PermissionService.getUserIdsWithPermission()is the fan-out primitive for domain-event notifications (permission.service.ts:113–171); login triggers counter reconciliation (auth.service.ts:373–375). - Platform logs / audit — logins emit
PLATFORM_LOG_ACTIVITY_EVENT(auth.service.ts:197–216); SuperAdmin cross-business reads writeAuthAuditLogentries. - Email/SMS — invites and verification use the Resend-backed email sender (with webhook-driven
inviteEmailStatus) and Twilio Verify. - Lock Care (document vault) — care-provider license files reference
LockCare(care-provider-profile.entity.ts:122–127). - Care proposals → users: care proposals can originate users (
InviteStatus.CARE_PROPOSAL_ORIGIN,domain-status.ts:124–129).
Open Questions & Gaps
@RequirePermissionsis unenforced on 22 controllers (full list in Business Rules §guard-wiring) because they bind onlyJwtAuthGuardand rely on the globalPermissionsGuard, which by design passes before authentication. This includes high-sensitivity modules: clients, shifts, care proposals, initial assessments, chat, posts, lock-care, health observations. Whether service-layer checks fully compensate cannot be determined from the guard code alone — each route needs an audit.BusinessGuardnever executes its check. It is global-only and skips whenreq.useris absent (business.guard.ts:56–57), which is always true at global-guard time. Users of inactive/suspended businesses are therefore not blocked at the API edge despite the guard's stated purpose. (JwtStrategy blocks Terminated/Resigned users, but nothing blocks an inactive business.)IdleTimeoutGuardnever executes for the same reason — the HIPAA 15-minute idle timeout it documents (idle-timeout.guard.ts:16–21) is not actually enforced server-side, and the last-activity timestamp it is supposed to maintain is never written.- Routes are open-by-default. With no global auth guard, any handler whose author forgets
@UseGuards(JwtAuthGuard)is publicly accessible. Severaluserslookups are authenticated but unscoped/unpermissioned:GET /users/email/:email,GET /users/username/:username,GET /users/id/:id(any logged-in user can resolve any account,users.controller.ts:176–200), andGET /users/caregivers/global-mapaccepts an arbitrarybusinessIdquery with no permission decorator (lines 156–166). - BaseRate/CityRate are platform-global (no
businessfield) yet are managed through business-permission-gated endpoints — every tenant reads and writes the same rate documents. Intent (shared platform pricing vs. missing tenancy) cannot be determined from code. - Default-business fallback on public signup: non-CareProvider public signups are attached to the oldest active business in the database (
users.service.ts:198–204). In a true multi-tenant deployment this silently drops new Representatives/MedicalProfessionals into an arbitrary tenant. - Invited users carry the shared placeholder password
'default-password'(password.constants.ts:14). Login is blocked while the invite is pending (auth.service.ts:297–300), but the window between magic-link acceptance andset-initial-passwordrelies on that pending check alone; whether any path allows password login with the placeholder after acceptance cannot be fully determined from the inspected code. - Tenancy plugin blind spots: cron jobs, migrations, WebSocket gateways, and any service using
_bypassBusinessScopeor.aggregateon schemas without a leading business$matchoperate cross-tenant by default (business-scope.plugin.ts:109–126). Profile entities (CareProviderProfile,RepresentativeProfile,MedicalProfessionalProfile) have nobusinessfield at all, so cross-tenant isolation for them is purely service-level. Business.onboardingStatusdefaults toCompletein the schema (business.entity.ts:71–76) while owner signup setsPendingexplicitly — businesses created throughPOST /businesses(SuperAdmin) skip onboarding by default. Intentional? Cannot determine from code.- Stale docs/UI naming: backend CLAUDE.md still references the removed
families[]arrays era, and the role hierarchy in older documentation (CareManager, Family) no longer exists;MEMORY.mdreferencesFamilyProfilewhich is nowRepresentativeProfile(migration 025). - Wiki cross-link targets
00-overview.md,02-assessments.mdetc. exist, but07/08/10pages referenced by numbering are absent fromdocs/wiki/at the time of writing — sibling links here point only to pages that exist.