Harmony — Implementation Plan

Advisor as a User of BM Enterprise

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.

Status: Draft for review  ·  Scope: Auth, data model, routing, provisioning  ·  Stack: Angular + NestJS + TypeORM (PostgreSQL), multi-tenant

Contents

1. Requirement 2. How it works today 3. Core concepts & decisions 4. Data model changes 5. Backend changes 6. Frontend changes 7. Provisioning flow (advisor → user) 8. Security 9. Migrations 10. Delivery phases 11. Open decisions 12. Out of scope

1. Requirement

2. How it works today

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.

PieceTodayWhere
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 CookieStrategyvalidateUserBySessionId → 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/*
Key gap: there is no Advisor↔User link, no notion of an "active role/persona" on the session, and no second app surface. All three are new. There is no existing KYC-portal or persona code — this is greenfield.

3. Core concepts & decisions

3.1 Persona = capability, not a new identity

Keep one User = one identity. Introduce a persona derived from what the user is:

PersonaGranted when…Surface
complianceUser holds any compliance permission (existing roles).Existing Harmony app (all current routes).
advisorUser 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.

3.2 Path is the source of truth for the active persona

The requirement says separation happens by path. Concretely:

Landing rules after login:

3.3 Switching = navigation, not re-auth

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.

Decision: derive the active persona from path; do not persist it on sessions. A persisted active_persona column is listed as optional in §9 only if product wants the last-used surface remembered across logins.

4. Data model changes

4.1 Link Advisor → User new

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.

4.2 KYC permission category + Advisor role new

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.

4.3 Policy DRAFT lifecycle status new

Schema gap found: the 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;

4.4 Summary of model deltas

ObjectChangeNote
users.advisor_idnewnullable unique FK → advisors.id
kyc permissionsnewseeded into permissions table
Advisor rolenewbuilt-in role mapping the kyc permissions
PolicyStatus.DRAFTnewnew enum value; today the enum is unused
policies.statusnewlifecycle status column, default DRAFT
UserInfo DTOchangeadd personas + advisorId (see §5)
sessions.active_personaoptionalonly if "remember last surface" is wanted
advisors tableunchangedno columns added

5. Backend changes

5.1 Expose personas on the session

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).

5.2 KYC portal endpoints under /api/kyc new

New 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
  }
}
Security rule (non-negotiable): KYC endpoints take the advisor id from req.user.advisorId, never from a path/body/query param. An advisor can only ever read or write their own record.

Own policies — list & create-as-DRAFT

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.

5.3 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

6. Frontend changes

6.1 Two route trees

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
];

Compliance app (today)

/dashboard /advisors /clients /policies /audit-reviews /incidents /settings/** guards: authGuard permissionGuard featureFlagGuard

KYC portal new

/kyc (dashboard) /kyc/profile (own KYC) /kyc/documents (uploads) /kyc/policies (OWN policies) /kyc/policies/new (create → DRAFT) guards: authGuard personaGuard('advisor') permissionGuard

6.2 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).

New: …/guards/persona-guard.ts, …/pages/kyc/** , …/shared/layouts/kyc-layout/* . Touched: app.routes.ts, auth.service.ts (personas signal), shared layout header.

7. Provisioning flow (advisor → user)

"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.

Compliance officer opens Advisor → clicks "Grant portal access" │ ▼ Backend: create User { email = advisor.email, advisor_id = advisor.id } assign built-in "Advisor" role (reject if a user with that email already exists) │ ▼ Email: registration magic link (existing completeRegistration JWT flow) │ ▼ Advisor sets password → logs in → lands directly on /kyc

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).

8. Security

OWASP-aligned. Default deny, least privilege, explicit authorization, no trust in client-supplied identifiers.

9. Migrations

Migrations only (no schema edits), timestamped <ts>-Name.ts per the existing convention.

#MigrationDoes
1AddAdvisorUserLinkAdd users.advisor_id (nullable) + unique partial index + FK → advisors(id) ON DELETE SET NULL.
2AddKycPermissionsAndAdvisorRoleInsert kyc permissions, create built-in Advisor role, seed role_permissions. Add provision_advisor_user permission.
3AddPolicyDraftStatusAdd DRAFT to the policy_status enum type, add policies.status column (default DRAFT), backfill existing rows to ACTIVE.
4AddActivePersonaToSessions optionalOnly 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).

10. Delivery phases

Phase 1 — Identity

  • Migration 1 (advisor_id link)
  • Migration 2 (kyc perms + Advisor role)
  • Provisioning endpoint + UI button
  • Invite/registration reuse

Outcome: an advisor can be granted a login and set a password.

Phase 2 — Persona plumbing

  • UserInfo.personas + advisorId
  • AdvisorPersonaGuard (backend)
  • personaGuard + landing redirect (frontend)
  • Switcher UI (dual-persona only)

Outcome: login lands on compliance; switch works; advisor-only lands on /kyc.

Phase 3 — KYC portal

  • KycLayout + /kyc routes
  • Self-scoped KYC controllers/services
  • Profile / documents screens
  • Own-policy list + create-as-DRAFT (needs AddPolicyDraftStatus migration)

Outcome: the actual advisor-facing surface. Largest, mostly net-new.

11. Open decisions (need product input)

#QuestionRecommendation
1KYC 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.
2Remember last-used surface across logins?No (derive from path). Add sessions.active_persona later only if requested.
3Exact KYC portal feature set (profile, documents, own clients, tasks, e-sign…)?Define in a follow-up scoping doc; Phase 3 here is a skeleton.
4Which advisors are eligible for a login (status / contract gating)?Default: any active advisor with a valid email; gate the provisioning button accordingly.
5Should 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.
6Who/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.
7Can 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.

12. Out of scope (this plan)