Application map / Identity & authorization
Identity & Authorization
The org/property/role model everything else depends on: who exists, who can see what, and who can change whom.
Enforcement reality verified 2026-09-07: the design calls for the visibility predicate to be enforced twice — application layer and database row-level security. Today RLS is inert: policies are installed in production Postgres, but the app connects as the table owner (which bypasses RLS), so the application-layer can_see_property check is the sole live gate. Turning enforcement on is a config switch documented in the RLS activation runbook.
The model
Exactly two organization types, held in Clerk: an HOA (one self-managed community) and a Community Management Company (CMC) that manages many communities on behalf of their boards. Every user belongs to exactly one organization.
The property is the atomic entity, and its HOA ownership is its identity. A property is always owned by exactly one HOA; a CMC never owns a property outright — it gains one only through a consent-based management relationship with an HOA already on the platform. The property row carries the links and assignments in the figure, the financial attributes, and a lifecycle marker (archived properties disappear from all visibility).
The roster crosses organizations. A property's roster is its HOA members plus the one property manager assigned to it (who belongs to a CMC) — everyone on it sees the same table. It is computed at read time, never stored: Clerk organizations are flat and a roster deliberately spans two of them.
Two independent role dimensions
- Membership role — a person's title, per org type. HOA: President, Treasurer, Board (flat peer titles, no rank). CMC: Executive > Portfolio Manager > Property Manager (ranked 3 > 2 > 1).
- Org Admin — an administrative capability layered on top of the role. The first person onboarded for an org is an Org Admin by default. Whether someone is an Org Admin is visible only inside their own organization — an HOA member sees the property manager's name and role but never their CMC-internal admin status (the API omits the field entirely for cross-org viewers, never sends
false).
Invariants
- A property manager manages exactly one property and belongs to one CMC; a property has at most one property manager; a portfolio manager may oversee many properties.
- When a manager is set, the property's CMC link must be set and equal that manager's CMC — this is what makes "the executive sees every company property" hold.
- Ending management clears the CMC link and both manager assignments in one step (a stale manager id never grants visibility); a board-less property is archived at the same time.
- A management offer exists only for an unmanaged property; accepting sets the CMC link only if still unset and deletes the property's other open offers in the same transaction.
Who owns what
Two systems, one job each. Clerk owns identity, tenancy, and the admin capability; the application database owns the property and the cross-org links. No mirror tables, no sync, no webhooks; companies and users appear in our database only as opaque Clerk id strings. Clerk mints user/org/membership ids; we mint property and link ids. Org creation is backend-owned and — because it spans Clerk plus the database and cannot be one transaction — idempotent and resumable: a retry detects the existing org and resumes the missing step, and a first-request reconciliation routes an org-without-property caller into "finish setup".
Claim shapes (the canonical contract)
| Where | Keys | Notes |
|---|---|---|
Membership publicMetadata | role, isOrgAdmin | Source of truth. Roles: hoa_president / hoa_treasurer / hoa_board / cm_executive / portfolio_manager / property_manager. |
User publicMetadata | membershipRole, isOrgAdmin, platformAdmin | Mirror of the membership values (users are single-org, and only user metadata rides the JWT), plus the platform-admin flag — never self-settable, set only out-of-band. |
Org publicMetadata | type | "hoa" or "cmc". Org type is data, not branching: it selects the role vocabulary and authority rule from one config map. |
| JWT (custom template) | org_type, membership_role, isOrgAdmin, platformAdmin | Steady state is zero per-request Clerk calls: the backend reads verified claims. |
- Writes dual-write membership metadata first, then the user mirror; Clerk metadata writes replace rather than merge, so every write is read-modify-write.
- A brand-new invited member whose mirror isn't written yet is backfilled on the first request where the claim is absent — one Clerk read, then set for good. No webhooks, no cache, no staleness detection.
- Clerk's native org roles are never read for authority; only the Clerk-forced org creator holds one, all invitees are ordinary members, and all member mutations run through the backend on the secret key.
- Dual claim parsing is live, not legacy: the frontend parses both top-level claims (the custom template) and nested
metadata.*(the instance-default session token) because both token templates are in use. It stays until the default session token is reconfigured.
Visibility — one predicate
The whole pyramid is a single predicate, canSee(user, property). Everything a caller sees is the set of properties where it is true. Higher roles see nothing new — just more of the same: a property manager or HOA member sees one roster, a portfolio manager a list of them, a CMC executive the whole company's grouped by portfolio manager, the platform admin everything.
canSee is the "My Property" vs "My Properties" distinction.A property is visible iff it is not archived and one of:
| Clause | Reads as |
|---|---|
| caller is platform admin | sees all, grouped by company |
| caller's org is the property's HOA org | HOA roles: their single roster |
| caller is a CMC executive whose org is the property's CMC | all company properties, grouped by portfolio manager |
| caller is the assigned portfolio manager | their portfolio |
| caller is the assigned property manager | their one property |
There is no per-role query; presentation (single / list / grouped) is frontend bucketing of the same result set. Every read or write of a property's financial data names its property explicitly and is authorized through this same predicate — never inferred from the caller's active org, because the visible set crosses org boundaries. The one exception: an organization editing its own profile (name, address, balance, fiscal-year start) resolves from the caller's org by definition.
Defense in depth (design vs. today): the predicate is meant to be enforced twice — in application code on every read, and by database row-level security applying the identical rule to claims-scoped sessions, kept honest by a parity test asserting both forms agree over shared fixtures. The policies and parity test exist; enforcement is inert until the app connects as the restricted role (runbook).
Authorization — the Org Admin rule
One server-side check governs every role/membership mutation. Actor A may mutate target B iff A is a platform admin, or: A is an Org Admin in the same organization as B, A ≠ B, and — for a CMC — A strictly outranks B, with a role change additionally requiring the new role to rank strictly below A ("promote at most one rank below your own"). Never demote the last org admin. Rank applies to CMC roles only; in an HOA any Org Admin manages any member.
- This yields: an HOA admin manages any HOA member but never the property manager (different org); a property-manager admin manages no one; a portfolio-manager admin manages property managers; an executive admin manages everyone below.
- Assignment authority is its own rule: assigning a property/portfolio manager requires a CMC Org Admin who outranks the assignee, the assignee already a CMC member, and no self-elevation.
- On every authority-bearing mutation the actor's current role and admin status are re-read from membership metadata at write time — closing the stale-elevated-JWT window on the (rare) write path.
- The rank gate is server-only with no native bypass: the org-admin metadata flag, never the native Clerk org role, is the sole authority source.
Lifecycle: linking, offboarding, onboarding
Linking
Three ways a property becomes CMC-managed: the CMC creates the property directly (with its CMC link set — used when the CMC also provisions the board's org); an HOA admin offers management to a CMC; or a CMC requests an HOA. The surfaced product is the CMC-initiated flow in the figure. Creating an offer requires Org Admin on the initiating side; accepting requires Org Admin on the opposite side; an offer exists only for an unmanaged property, and an accept that finds the link already set is a no-op meaning "already managed" — rejected.
Offboarding
CMC-side with pyramid approval, as in the figure. HOA-initiated linking and offboarding exist as latent capability but are not surfaced.
Onboarding
Dashboard-first: the profile modal provisions the org the instant persona and role are picked (HOA also gets a placeholder property), so every page loads immediately behind a "needs setup" state; there is no mandatory wizard. The notification bell is the union of derived pending actionables (requests to accept, offboardings to approve) and stored already-happened events — never mocked.
UX principles that follow from the model
- Two scopes: property (the unit of work — planner, cashflow, transactions, documents, its people) driven by the selected-property switcher, and portfolio (the unit of oversight — executive/management dashboards, independent of any selection). Single-property users never see the switcher.
- The chrome always answers three questions: which property (sidebar switcher + page heading echoing the property name and address), who am I (top-bar profile menu), what needs my attention (notification bell).
- Zero fabricated data: every figure is real, visibility-scoped data or an explicitly labeled placeholder — never a plausible-looking mock. (One known violation stands: the
/management/teampage — see frontend contract, cleanup residue.) - Member management lives with the property (My Properties), not scattered; only the platform-admin role is intentionally out-of-band.