Let an Advisor hold a login to Harmony (BM Enterprise). A single user account can act as a Compliance Officer and/or an Advisor. The active role is decided by URL path: users land in the compliance app after login and switch into the (new) KYC portal if allowed.
The two relevant entities exist but are not linked as an identity. An Advisor is a
business record managed by a User; it cannot log in.
| Piece | Today | Where |
|---|---|---|
| User (identity) | Login identity: email, password, MFA, role tree (managed_by). Roles via user_roles. |
database/entities/user.entity.ts |
| Advisor (business) | Insurance advisor record. No user_id / credentials. Linked to a managing User via managed_by. |
database/entities/advisor.entity.ts |
| Login | Cookie session (sessionId, signed, HttpOnly). Optional MFA. Returns UserInfo. |
controllers/auth/auth.controller.ts · services/auth/auth.service.ts |
| Session model | CookieStrategy → validateUserBySessionId → builds UserSession with a CASL ability from the union of role permissions. |
core/strategies/cookie.strategy.ts · core/session.ts |
| Permissions | Flat permission list grouped by category; CASL can(). Default-deny — no permission, no access. |
core/permissions.ts |
| Authorization | Backend: @Permissions(...) + PermissionGuard. Frontend: route data.permissions + permissionGuard. |
core/guards/permission.guard.ts · frontend …/guards/permission-guard.ts |
| Routing | One protected tree under Layout (authGuard + permissionGuard + featureFlagGuard). Public under /auth. |
frontend …/pages/protected/protected.routes.ts |
| Multi-tenant | Tenant resolved by slug (middleware + AsyncLocalStorage); each tenant has its own datasource. |
core/tenant/* |
Keep one User = one identity. Introduce a persona derived from what the user is:
| Persona | Granted when… | Surface |
|---|---|---|
| compliance | User holds any compliance permission (existing roles). | Existing Harmony app (all current routes). |
| advisor | User is linked to an Advisor record (users.advisor_id set). | New KYC portal only. |
A user can have both, one, or in degenerate cases neither. The persona never expands permissions — CASL still enforces every action. Persona only decides which surface the user is on.
The requirement says separation happens by path. Concretely:
/kyc/** → advisor surface (KYC portal).Landing rules after login:
/dashboard (compliance), default today./kyc.Same session, same cookie. Switching personas is just navigating between the two route trees. The backend does not need a stateful "active persona" — each request is authorized by (a) the user's permissions and (b) the endpoint's surface guard. This keeps the change small and avoids a stale-persona class of bugs.
sessions. A persisted
active_persona column is listed as optional in §9 only if product wants the last-used surface
remembered across logins.
Add a nullable, unique advisor_id FK on users (one user maps to at most one advisor;
an advisor maps to at most one user). Nullable because most users are pure compliance officers, and most
advisors are not users.
// users.entity.ts (additions)
@Index({ unique: true, where: '"advisor_id" IS NOT NULL' })
@Column({ name: 'advisor_id', nullable: true })
advisorId: number | null;
@OneToOne(() => Advisor, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'advisor_id' })
advisor: Advisor | null;
Why a single FK on users (not a column on advisors): identity lives on the user
side, the unique partial index enforces 1:1, and advisor rows stay untouched for the (majority) non-user advisors.
Extend the flat permission catalogue with a kyc category and seed a built-in Advisor role that holds only those permissions.
// core/permissions.ts (add to CATEGORY_PERMISSIONS)
kyc: [
'view_own_kyc', // see own advisor profile / dashboard
'submit_kyc', // submit / update KYC info
'upload_kyc_document', // upload required documents
'list_own_policy', // KYC portal: list OWN policies only
'create_draft_policy', // KYC portal: create a policy (forced to DRAFT)
],
Default-deny does the heavy lifting: an advisor-only user holds none of the compliance permissions, so every existing compliance route/endpoint already returns 403/redirect with no extra code.
PolicyStatus enum exists in enums.ts but is
unused — the Policy entity has no lifecycle status column at all (only a separate
PolicyReviewStatus supervision workflow and a commission_status_id lookup). So
"save as DRAFT" cannot reuse anything today; it needs a real status column.
Add a DRAFT value to PolicyStatus and wire a status column onto policies.
// enums.ts
export enum PolicyStatus {
DRAFT = 'draft', // NEW — advisor-created, not yet submitted
PENDING = 'pending',
ACTIVE = 'active',
INACTIVE = 'inactive',
CANCELLED = 'cancelled',
EXPIRED = 'expired',
}
// policy.entity.ts (additions)
@Index()
@Column({ type: 'enum', enum: PolicyStatus, default: PolicyStatus.DRAFT })
status: PolicyStatus;
DRAFT server-side (see §5.2) — the client cannot pick a status.DRAFT → PENDING/ACTIVE through the existing compliance flows (transition rules in the open decisions, §11).ACTIVE) so the non-null column is safe to add.DRAFT explicitly and the compliance
create path sets its own status explicitly — don't rely on the default to carry meaning.| Object | Change | Note |
|---|---|---|
users.advisor_id | new | nullable unique FK → advisors.id |
kyc permissions | new | seeded into permissions table |
Advisor role | new | built-in role mapping the kyc permissions |
PolicyStatus.DRAFT | new | new enum value; today the enum is unused |
policies.status | new | lifecycle status column, default DRAFT |
UserInfo DTO | change | add personas + advisorId (see §5) |
sessions.active_persona | optional | only if "remember last surface" is wanted |
advisors table | unchanged | no columns added |
Compute available personas where the UserSession is built and surface them through
UserInfo. advisorId rides on the user; compliance persona is "holds any
non-kyc permission".
// core/session.ts — UserSession.toResponse()
const personas: Persona[] = [];
if (this.permissions.some(p => !KYC_PERMISSIONS.includes(p))) personas.push('compliance');
if (this.user.advisorId) personas.push('advisor');
return {
...existingFields,
advisorId: this.user.advisorId ?? null,
personas, // ('compliance' | 'advisor')[]
};
Touches: core/session.ts, packages/types/src/shared.ts (UserInfo), auth.service.ts (ensure advisorId is selected on the user load in validateUserBySessionId).
/api/kyc newNew controllers namespaced under kyc/. Every handler is gated by a new AdvisorPersonaGuard and is self-scoped to the caller's own advisor.
@Controller('kyc')
@UseGuards(AdvisorPersonaGuard) // 403 unless req.user.advisorId is set
export class KycProfileController {
@Get('me')
@Permissions(['view_own_kyc'])
getOwnProfile(@Req() req: Request) {
return this.kycService.getProfile(req.user.advisorId); // server-derived id ONLY
}
}
req.user.advisorId,
never from a path/body/query param. An advisor can only ever read or write their own record.
List is filtered to the caller's advisor; create overrides status and advisorId
server-side so the client cannot forge either. Reuse the existing policy service, passing the server-derived id.
@Controller('kyc/policies')
@UseGuards(AdvisorPersonaGuard)
export class KycPolicyController {
@Get()
@Permissions(['list_own_policy'])
listOwn(@Req() req: Request, @Query() q: PolicyListQueryDto) {
// advisorId is FORCED from the session, not the query
return this.policyService.list({ ...q, advisorId: req.user.advisorId });
}
@Post()
@Permissions(['create_draft_policy'])
createDraft(@Req() req: Request, @Body() dto: CreatePolicyDto) {
return this.policyService.create({
...dto,
advisorId: req.user.advisorId, // own book only
status: PolicyStatus.DRAFT, // forced — ignore any client value
});
}
}
Reuses services/policy/policy.service.ts (list filter by advisorId already supported via the advisor relation); add a status-aware create path that defaults/forces DRAFT for this surface.
AdvisorPersonaGuard new
Mirrors the existing guard style. Returns 403 if req.user.advisorId is null. Compliance endpoints
keep using only PermissionGuard — advisor-only users already lack the permissions, so no change
is required there.
core/guards/advisor-persona.guard.ts (new), exported from core/guards/index.ts
Add a sibling protected tree for the KYC portal with its own layout and guard. The existing tree is the compliance app, unchanged.
// app.routes.ts
export const routes: Routes = [
...protectedRoutes, // existing compliance app
...kycRoutes, // NEW: /kyc/** under KycLayout, personaGuard('advisor')
...publicRoutes,
// 404 / ** unchanged
];
personaGuard + landing redirect new
A small CanActivateFn that reads personas from the auth service. /kyc/**
requires the advisor persona; otherwise redirect to /dashboard (or /404).
'' → dashboard; on login, if the user is advisor-only, the auth
bootstrap redirects to /kyc.personas.length > 1. It is plain navigation between /dashboard
and /kyc.New: …/guards/persona-guard.ts, …/pages/kyc/** , …/shared/layouts/kyc-layout/* . Touched: app.routes.ts, auth.service.ts (personas signal), shared layout header.
"Not all advisor entities are users" → provisioning is an explicit, per-advisor action done by a compliance officer. It reuses the existing invite/registration machinery (JWT registration token + magic link) rather than inventing a new one.
provision_advisor_user (under the advisor or user category).advisor_id
onto that existing user instead of creating a second account (this is exactly the "both personas" case).Touches: advisor.service.ts (or a new provisioning service), auth.service.ts (reuse createUser + registration token), advisor controller (new endpoint), advisor-view frontend page (button).
req.user.advisorId server-side. Advisor
id is never accepted from the request. Prevents IDOR / horizontal escalation — an advisor lists and creates
policies only on their own book.status = DRAFT set on the server.
A client-supplied status (or advisorId) is ignored, so an advisor cannot self-activate
a policy or attach it to another advisor./kyc cannot grant compliance powers and vice-versa.users.email is uniquely indexed; provisioning links to the existing user
on email match rather than creating a duplicate identity.AuditTrail rows tied to their user id).Migrations only (no schema edits), timestamped <ts>-Name.ts per the existing convention.
| # | Migration | Does |
|---|---|---|
| 1 | AddAdvisorUserLink | Add users.advisor_id (nullable) + unique partial index + FK → advisors(id) ON DELETE SET NULL. |
| 2 | AddKycPermissionsAndAdvisorRole | Insert kyc permissions, create built-in Advisor role, seed role_permissions. Add provision_advisor_user permission. |
| 3 | AddPolicyDraftStatus | Add DRAFT to the policy_status enum type, add policies.status column (default DRAFT), backfill existing rows to ACTIVE. |
| 4 | AddActivePersonaToSessions optional | Only if product wants last-used surface remembered. Skip per §3.3 recommendation. |
Multi-tenant note: these run per tenant datasource. Confirm the permission/role seed runs through the same
per-tenant migration path used by the recent Add…Permission migrations (e.g. the notification-settings permission migration).
UserInfo.personas + advisorIdAdvisorPersonaGuard (backend)personaGuard + landing redirect (frontend)KycLayout + /kyc routesAddPolicyDraftStatus migration)| # | Question | Recommendation |
|---|---|---|
| 1 | KYC portal: same Angular app (route tree) or a separate deployed app? | Same app, separate route tree + layout (smaller, shares auth/session). Split later only if branding/scaling demands. |
| 2 | Remember last-used surface across logins? | No (derive from path). Add sessions.active_persona later only if requested. |
| 3 | Exact KYC portal feature set (profile, documents, own clients, tasks, e-sign…)? | Define in a follow-up scoping doc; Phase 3 here is a skeleton. |
| 4 | Which advisors are eligible for a login (status / contract gating)? | Default: any active advisor with a valid email; gate the provisioning button accordingly. |
| 5 | Should an advisor user with a compliance role also see their own book in the compliance app, or only via KYC? | Keep surfaces clean: own-book editing lives in KYC; compliance app keeps its existing supervisory views. |
| 6 | Who/what promotes a policy out of DRAFT, and what transitions are legal (DRAFT → PENDING → ACTIVE)? | Compliance officer submits/approves; advisor cannot self-promote. Define a small transition guard in policy service. |
| 7 | Can an advisor edit or delete their own DRAFT policy before it leaves DRAFT? | Yes — edit/delete allowed only while status = DRAFT and only on own book; locked once promoted. |