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 in our database carries the HOA-side org link, the CMC-side link ("who manages this now"; unset means unmanaged), the portfolio-manager and property-manager assignments, 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.

The pyramid is aggregation. A property manager or HOA member sees one roster; a portfolio manager sees a list of rosters; a CMC executive sees all the company's rosters grouped by portfolio manager; the platform admin sees everything. Nothing new appears at higher levels — there is just more of it.

Two independent role dimensions

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

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)

WhereKeysNotes
Membership publicMetadatarole, isOrgAdminSource of truth. Roles: hoa_president / hoa_treasurer / hoa_board / cm_executive / portfolio_manager / property_manager.
User publicMetadatamembershipRole, isOrgAdmin, platformAdminMirror 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 publicMetadatatype"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, platformAdminSteady state is zero per-request Clerk calls: the backend reads verified claims.

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. A property is visible iff it is not archived and one of:

ClauseReads as
caller is platform adminsees all, grouped by company
caller's org is the property's HOA orgHOA roles: their single roster
caller is a CMC executive whose org is the property's CMCall company properties, grouped by portfolio manager
caller is the assigned portfolio managertheir portfolio
caller is the assigned property managertheir one property

There is no per-role query — how many properties pass is the "My Property" vs "My Properties" distinction, and 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.

Lifecycle: linking, offboarding, onboarding

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 CMC-initiated: the CMC enters an HOA board member's email, the backend resolves it to that member's property and raises a request, and the HOA side accepts from the notifications bell — in-app only, no email sent, no identifiers pasted. Creating an offer requires Org Admin on the initiating side; accepting requires Org Admin on the opposite side. Accept is conditional and atomic: set the CMC link only if still unset (a no-op means "already managed" — reject), deleting competing open offers in the same transaction.

The relationship is consent-based on both sides — no link forms or breaks without both parties. Offboarding is CMC-side with pyramid approval: the property's property manager initiates, the portfolio manager approves or declines from their bell (fallback approver: CMC executive/Org Admin when no portfolio manager is assigned). On approval the link and both manager assignments clear, the property leaves the whole CMC's visible set, and the HOA keeps all its data and is notified. HOA-initiated linking and offboarding exist as latent capability but are not surfaced.

Onboarding is 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