Skip to main content

Knowledge > Products > WiseAI Realtor > Auth & RBAC

WiseAI Realtor — Auth, Identity & RBAC

Closes P0 #4 of backend-completeness-audit.md: "No multi-user auth/RBAC. Today = one shared /admin/[token] magic link. Team Beckett is two agents — they can't share one identity without breaking per-agent attribution, routing, and security." This spec is design-only. It does not write code or migrations; it specifies them so an implementation agent can build against a settled contract.

IMPLEMENTATION STATUS (2026-06-28): §1 (auth model), §1.3 (invite/accept), §1.4 (session + x-realtor-* headers), §2/§2.1/§2.2 + §8 (roles/caps/external-partner ceiling), and §5 (dual-auth bridge + MVP cut) are now BUILT on branch feat/re-auth-bridge-phase6 (Supabase Auth — magic-link/OTP default + password + Google + Microsoft OAuth + MFA/TOTP; the re_legacy legacy-token → synthetic-owner bridge; invite/accept single-use + email-bound R6 + inviter-role ceiling). The RBAC resolver (src/lib/real-estate/rbac.ts + rbac-server.ts) is built with 40 contract tests. §3.3 (RLS policies + SECURITY DEFINER helper functions on local_business_leads) remains design-only / DEFERRED to the next workstream — the authed dashboard is still service-role, so the deny-by-default RLS already on the table is correct as-is. Resume: HANDOFF_WISEAI_REALTOR_2026-06-28_PHASE6_AUTH.md.

0. Today's reality (verified 2026-06-27, do not assume)

The Realtor product currently rides on the Local Business Platform, not the church RBAC stack:

LayerChurch platform (premium_churches)Realtor / local-business platform (local_businesses) — where Beckett lives
Tenant anchorpremium_churches (+ churches)local_businesses row (Beckett = terry-and-sheri, site /s/terry-and-sheri)
Customer login/admin/[token]admin_token column (getPremiumByToken) + cookie session cw_sessionchurch_admin_identities (validateSessionViaRest, middleware injects x-church-id/x-identity-id/x-identity-role)/business/[token] → single shared local_businesses.admin_token (resolveLocalBusinessToken). One token, no cookie identity, no per-user anything.
Multi-userYeschurch_team_members (per-member access_token, role, group_ids, capabilities) + church_custom_groups (12 templates)None. Zero team rows, zero RBAC, zero identities.
Authorization engineModel C resolver in src/lib/rbac.tseffectivePermissions(member, groups), can(), requireCapability() (server guard) over a 57-key capability taxonomyNone. The token either resolves the whole dashboard or nothing.
Authentication primitiveCustom cw_session cookie (hashed token → church_admin_identities). NOT Supabase Auth.None beyond the shared admin_token string.
True Supabase Auth in the codebaseUsed by SermonWise (/sermons/app, handleSermonAuth), ShareWiseAI (/social/app, handleSocialAuth), ITWNot used here yet.

Net: the church side has a battle-tested capability resolver we should reuse, but its authentication is a bespoke cookie scheme and its tables are church-semantic. The realtor side has no identity layer at all. So the realtor build is greenfield for auth tables + identity, and a reuse-the-pattern job for the capability resolver. Confirmed greenfield by do-not-reinvent.md: "Brokerage / team RBAC hierarchy — current RBAC is token + role; brokerage-scoped multi-agent teams are not modelled."


1. Auth model

1.1 Recommendation — Supabase Auth as the identity primitive, reuse the church capability resolver for authorization

Authentication (who you are): Supabase Auth. Rationale:

  • It is already the portfolio's user-account primitive (SermonWise, ShareWise, ITW) — one mental model, one set of middleware helpers (@supabase/ssr), one JWT/session story, free MFA + OAuth + OTP.
  • The church admin's bespoke cw_session/church_admin_identities scheme exists for historical reasons (token-link migration). We should not propagate it to a brand-new vertical. New product → use the managed platform (CLAUDE.md cross-project Rule #9: "Use managed platforms, not custom infra").
  • Realtors expect a real login (email+password and/or Google OAuth and/or email OTP), session persistence across the multiple devices they work from (desk + phone at a showing), and "forgot password" — all native to Supabase Auth.

Authorization (what you may do): reuse the church Model-C resolver pattern, not a new engine. src/lib/rbac.ts is already client-safe, pure, and vertical-agnostic in shape (surface:resource:action[:qualifier]). Two clean options:

  • (A, recommended) Generalize rbac.ts into a shared resolver and add a realtor capability set + realtor role templates (Section 2) alongside the church ones, keyed by vertical. The resolver, can(), assertCan(), requireCapability() guard, and the catalog/UI pattern all carry over unchanged.
  • (B) Copy the ~120-line resolver into src/lib/real-estate/rbac.ts if coupling church + realtor capability unions is judged risky. Cheaper to ship, more drift. Prefer (A).

Identity ≠ authorization. Supabase Auth answers "is this auth.uid() a valid, logged-in human?" The membership + capability layer (ours) answers "is this human an agent on Team Beckett, and may they see commissions?" Keep them separate; never encode roles in the Supabase JWT app_metadata as the source of truth (it's a cache at best — see §6 risk R4).

1.2 Data model for identity + membership (new tables)

Anchor the realtor tenant on the existing local_businesses row (the account) to avoid reinventing tenancy for MVP, and layer membership on top:

auth.users (Supabase Auth) ← authentication
│ 1:1
realtor_users (profile mirror) ← name, photo, license #, default_locale, phone
│ uid = auth.users.id (PK/FK)

│ M:N via
realtor_memberships ← authorization + tenancy + ownership anchor
├─ user_id → realtor_users.uid
├─ account_id → local_businesses.id (the tenant)
├─ team_id → realtor_teams.id (nullable; null = account-level)
├─ role text (agent | team_admin | transaction_coordinator |
│ broker_admin | brokerage_owner | marketing_assistant)
├─ scope text (own | team | brokerage) ← visibility breadth (§3)
├─ capabilities text[] ← direct per-user grants (additive)
├─ status text (invited | active | suspended)
├─ is_primary_owner boolean ← billing + transfer authority
└─ invited_by, joined_at, last_seen_at

realtor_teams (account_id, name, parent_team_id) ← only materialized at team/brokerage tier
realtor_invites (account_id, email, role, scope, team_id, token, expires_at, accepted_at, invited_by)
  • account_id = local_businesses.id. For Beckett the account is "Team Beckett". No new tenant table for MVP. (The audit's separate question of RE-native lead/contact/deal tables is out of scope here — this doc owns identity/membership only.)
  • A user can belong to multiple accounts (an assistant who works two teams; an agent who moves brokerages) → membership is M:N, never a column on auth.users.
  • realtor_teams is deferred for MVP (account == team when team_id is null). It only materializes at the brokerage tier where one account has many teams/offices.
  • Effective capabilities = roleTemplate(role).capabilities ∪ membership.capabilities, computed by the reused resolver. (Realtor MVP can skip the church's custom group layer; role templates + per-user direct grants are enough. Add custom groups later if a brokerage needs bespoke roles — the resolver already supports it.)

1.3 Invite flow (how Sheri gets her own identity)

  1. Owner invites from Settings → Team: enters email + role + scope (+ team for brokerage tier). Writes a realtor_invites row with a single-use, expiring token (e.g. 7-day TTL). Sends a branded email via Resend on the property's own host (wiseaiagency.com for the realtor brand — never the churchwiseai.com default; memory feedback_outreach_links_per_property_host). This is a transactional account email, not a campaign, so it is exempt from the founder-press-send gate.
  2. Invitee clicks https://wiseaiagency.com/realtor/accept-invite?token=… → the page validates the invite (unexpired, unaccepted), then runs Supabase Auth sign-up/sign-in (email+password, Google OAuth, or email OTP).
  3. On first successful auth, link auth.uid() → create realtor_users profile + flip the realtor_invites row to accepted + create the realtor_memberships row with the invited role/scope/team. Mark status='active'.
  4. Re-invite / resend / revoke are owner actions; revoking deletes the pending invite, suspending sets membership.status='suspended' (keeps the row + audit history; fail-closed in the resolver — see §6 R1).
  5. Primary-owner transfer (is_primary_owner) is its own gated capability (team:transfer_ownership), mirroring the church church:transfer_ownership admin-only cap.

1.4 Session model

  • Supabase Auth session (JWT access + refresh) stored in HTTP-only cookies via @supabase/ssr, exactly as /sermons/app and /social/app already do. Middleware refreshes the session and resolves auth.uid().
  • Tenant + role resolution per request: from auth.uid(), look up the active membership for the requested account_id (the account is in the URL or the active-account cookie). Inject x-realtor-account-id, x-realtor-user-id, x-realtor-role, x-realtor-scope headers (mirrors the church middleware's x-church-id/x-identity-id/x-identity-role pattern) so server routes get tenancy cheaply.
  • Active-account switcher for multi-account users (assistant on 2 teams): a small account picker writes an active_account_id cookie; the membership lookup is always re-validated server-side (cookie is a hint, not authority).
  • Server guard: requireRealtorCapability(request, cap) — the realtor twin of requireCapability() — resolves membership, computes effective caps via the reused resolver, returns 401 (no session/membership) or 403 (missing cap). Request-scoped WeakMap cache, identical to rbac-server.ts.
  • Idle/absolute timeout + MFA: lean on Supabase defaults; require MFA (TOTP) for any role that can see commissions or manage billing (broker_admin, brokerage_owner, primary owner). Optional for plain agents.

[Refined 2026-06-27 · industry research — see §8.4/§8.5] Auth-method default firmed up: passwordless email OTP / magic-link as the default, optional password, Google + Microsoft OAuth, MFA (TOTP) required for commission/billing roles, SAML SSO reserved for the brokerage tier. Quebec Law 25 §20 turns the §6 "default narrow + audit financial/PII reads" guidance from a UX preference into a legal requirement (least-privilege + need-to-know + access logging + periodic access reviews).


2. Role matrix

Six roles × capability domains. Cells: E = view + edit/act, V = view only, = none. A trailing scope (own / team / bkg) means the cell is further bounded by §3 visibility scope. Bold cells are the security-critical gates (financial/commission, other agents' pipelines, team mgmt, billing).

Capability domainagentteam_admintransaction_coordinatorbroker_adminbrokerage_ownermarketing_assistant
Leads / contactsE (own)E (team)V (team)E (bkg)E (bkg)V (team)¹
Conversations / inbox (chat·voice·SMS)E (own)E (team)V (team)E (bkg)E (bkg)
Listings (manual edit · feature/reorder)E (own)E (team)V (team)E (bkg)E (bkg)V (team)
Deals / transactions (key dates, docs, status)E (own)E (team)E (team)E (bkg)E (bkg)
Commissions / financialV (own only)V (team)— ²V (bkg)E (bkg)
Content (blog · newsletter · social · guides)E (own)E (team)E (bkg)E (bkg)E (team)
Reviews / testimonialsE (own)E (team)VE (bkg)E (bkg)E (team)
AI config (chat agent · voice lines · scripts · routing)E (team)E (bkg)E (bkg)V
Analytics / attributionV (own)V (team)V (team)V (bkg)V (bkg)V (team)
Team management (invite·remove·assign·set scope)E (team)E (bkg)E (bkg)
Lead routing rules (round-robin·geo·language)E (team)E (bkg)E (bkg)
Billing & planVE
Settings (business profile·hours·branding·compliance)VE (team)VE (bkg)E (bkg)V (branding only)
Integrations / API keys / CRM syncE (team)VE (bkg)E (bkg)
Ownership transfer / delete accountE

¹ marketing_assistant sees lead contact info needed to run campaigns (name, email, consent status) but not the lead's private AI-summarized intent/financial-qualification notes — gate at the field level (§2.2). ² transaction_coordinator manages the deal mechanics (dates, docs, conditions) but is walled off from commission $ figures by default — TCs at brokerages routinely should not see split economics. Grantable per-user if a brokerage wants it. ³ a solo agent IS effectively team_admin of a one-person account (§4); the "agent" column reflects a team member who is not the lead. A solo agent gets the team_admin column.

[Refined 2026-06-27 · industry research — see §8.1/§8.2/§8.3] The 6-role model is sound and matches the incumbents, but research across Follow Up Boss, BoldTrail/kvCORE, Lofty, and Sierra found two roles missing and one access primitive that every major platform ships:

  • Add a 7th role — external_partner (the "Lender" role). Every platform has a dedicated, more-restricted-than-agent external role (FUB/Sierra/BoldTrail call it Lender) scoped to only the leads they're explicitly attached to. This is exactly the external-partner access the founder asked about — see §8.3.
  • Add an isa role (inside sales agent / appointment-setter) — universal (FUB "ISA", Lofty "Assistant", Sierra "ISA"). Distinct from transaction_coordinator (deal mechanics) and marketing_assistant (content). Nurtures/qualifies leads, books appointments, no commission/billing.
  • Add the collaborator grant primitive (per-record access without changing a user's role — FUB's "Collaborators") as the mechanism for the external-partner + time-boxed access pattern (§8.3). It's additive on top of the role matrix, not a new column.
  • Schema already supports a brokerage Office tier (BoldTrail's Company→Office→Team→Agent) via realtor_teams.parent_team_id — no change needed.

2.1 Realtor capability taxonomy (mirrors rbac.ts key format)

surface:resource:action[:scope]. Scope qualifier is own | team | brokerage. Illustrative (not exhaustive):

leads:lead:read:own leads:lead:read:team leads:lead:read:brokerage
leads:lead:update leads:lead:assign leads:lead:reassign
leads:routing:edit
contacts:contact:read:{scope} contacts:contact:merge
inbox:conversation:read:{scope} inbox:conversation:reply inbox:handoff:take
listings:manual:edit listings:feature:reorder listings:ddf:sync
deals:deal:read:{scope} deals:deal:edit deals:doc:upload
commissions:amount:read:own commissions:amount:read:team
commissions:amount:read:brokerage commissions:amount:edit ← FINANCIAL GATE
content:item:create content:item:publish content:translate
reviews:request:send reviews:testimonial:edit
ai:chat:config ai:voice:lines:edit ai:voice:script:edit
analytics:report:view:{scope}
team:member:invite team:member:remove team:member:set_scope
team:transfer_ownership account:delete ← OWNER-ONLY
billing:view billing:manage billing:cancel ← OWNER / broker_admin only
settings:profile:edit settings:branding:edit settings:compliance:edit
integrations:crm:edit api_keys:manage

Define an ADMIN_ONLY_CAPABILITIES-equivalent set (billing:*, account:delete, team:transfer_ownership, api_keys:manage, audit:view, and all commissions:*) that is never exposed in the role-template-edit UI and never grantable as a casual per-user direct grant — same guard the church catalog enforces.

2.2 Financial / commission / cross-pipeline gating (the load-bearing rules)

These are the rules that make multi-agent safe and must be enforced at the API and the query layer, never by UI hiding alone (church CLAUDE.md data-access rule — same discipline here):

  1. Commission $ figures are a distinct capability, not bundled into "deals." A TC can run a deal to close without ever resolving commissions:amount:read:*. Default-deny; explicit grant only.
  2. An agent sees only their OWN commission (commissions:amount:read:own). Team-wide commission visibility (:team) is team_admin+; brokerage-wide (:brokerage) is broker_admin/owner.
  3. Other agents' pipeline is invisible by default. leads:lead:read:own is the agent floor. Seeing a teammate's leads/conversations/deals requires :team scope on the membership AND the corresponding :team capability. This is the single most important per-agent isolation rule for Beckett: Terry must not silently see Sheri's leads unless the membership says so (and vice-versa), even though they're one team — until they opt into shared visibility.
  4. Field-level redaction for partial roles. marketing_assistant gets contact identity for campaigns but the lead's AI intent/qualification/financial-readiness fields return redacted (mirrors the church inbox:callback:read:reason / prayer:read:confidential redaction pattern).
  5. Audit every financial read. commissions:amount:read:* and billing actions write an audit:view-style audit row. Brokerages will ask "who looked at my splits."

3. Lead / record ownership + visibility scoping

3.1 Ownership columns (on every owned record)

Every owned realtor record — local_business_leads, conversations, deals, listings (manual), tasks, content — carries:

owner_user_id uuid → realtor_users.uid (the agent who owns it)
account_id uuid → local_businesses.id (tenant; already present or to add)
team_id uuid → realtor_teams.id (nullable; for brokerage tier routing/scoping)
  • Lead capture sets owner_user_id via the routing rules (claim / round-robin / geo / language-based — Beckett needs language routing between two agents, per the audit). Unrouted leads sit in a pond (owner_user_id null) visible to anyone with :team read until claimed.
  • Reassignment (leads:lead:reassign) changes owner_user_id and writes an audit row + preserves attribution history (who originally captured it) so per-agent reporting stays honest.

3.2 Three visibility scopes (the scope column on membership)

ScopeSeesTypical roles
ownonly records where owner_user_id = me (+ the unassigned pond, read-only)agent (default)
teamall records where team_id ∈ my teams (or account_id = mine when teams aren't modelled)team_admin, TC, marketing_assistant
brokerageall records in the account (across all teams/offices)broker_admin, brokerage_owner

Scope is breadth; capability is the action. A team_admin with scope=team + leads:lead:read:team sees the team's leads; the same person needs commissions:amount:read:team to also see the money on them.

3.3 RLS implications on the shared single instance

Critical context: the portfolio runs one Supabase instance for dev + prod, and the church/local-business admin paths currently use the service-role key (which bypasses RLS) with app-layer capability checks as the real gate. So RLS is defense-in-depth, not the sole gate — but for a multi-user product handling other people's commissions, we should turn it on for the realtor tables.

Design:

  1. Move realtor end-user reads/writes to the user-scoped client (Supabase Auth JWT → anon/authenticated role), so auth.uid() is available inside policies. Keep service-role strictly for system paths (provisioning, cron, the voice agent writing leads, webhooks) — these legitimately bypass RLS and must therefore keep enforcing tenancy in app code.

  2. Helper functions (SECURITY DEFINER, stable) so policies stay readable:

    • realtor_account_ids(uid) → set of account_ids the user has an active membership in.
    • realtor_can(uid, account_id, capability) → boolean (server-side mirror of the resolver, fed by role templates + direct grants).
    • realtor_scope(uid, account_id)own|team|brokerage.
  3. Policy shape per owned table (illustrative, leads):

    -- SELECT: tenant match AND (scope=brokerage)
    -- OR (scope=team AND team_id in my teams)
    -- OR (scope=own AND owner_user_id = auth.uid())
    -- OR owner_user_id IS NULL -- the claimable pond
    create policy leads_select on local_business_leads
    for select to authenticated using (
    account_id in (select realtor_account_ids(auth.uid()))
    and (
    realtor_scope(auth.uid(), account_id) = 'brokerage'
    or (realtor_scope(auth.uid(), account_id) = 'team'
    and team_id = any (my_team_ids(auth.uid(), account_id)))
    or owner_user_id = auth.uid()
    or owner_user_id is null
    )
    );
    -- UPDATE/INSERT gated additionally by realtor_can(...,'leads:lead:update')
  4. Financial columns (commission_amount, splits) cannot be hidden by row RLS alone (RLS is row-, not column-, granular in a single select *). Two options: (a) put commission figures in a separate realtor_deal_commissions table with its own stricter RLS keyed on commissions:amount:read:* (recommended — clean column-isolation), or (b) force all reads through a view / RPC that nulls the columns when the caller lacks the cap. Prefer (a).

  5. get_advisors (security lint) must be clean before launch — run it on every new realtor table; a multi-tenant table with RLS disabled is a P0.

  6. Service-role app-code parity: because cron/voice/webhooks bypass RLS, the same scope logic MUST also live in requireRealtorCapability + query builders. RLS and app-layer must agree; the resolver is the single source both consult.


4. Progressive disclosure — one app shell, solo → team → brokerage

One shell (the unified Realtor backend per IA decision §5c), gated by account tier (derived from plan + membership count) and the viewer's role + scope:

TierTriggerNav / capabilities that unlock
Solo agent1 membership, role = team_admin (solo-as-admin)Full personal command center. Hidden: "Team" nav, lead-routing rules, scope/assignee pickers, cross-agent analytics, commission-split UI. Leads are implicitly all "mine" — no owner column shown. Zero multi-user cognitive load.
Team (Beckett)≥2 active membershipsUnlock: Team nav (roster, invite, scope), assignee/owner column + picker on leads/deals, lead routing (round-robin / geo / language), per-agent + team analytics toggle, "my leads / team leads" filter, team_admin commission view.
Brokerageplan = brokerage OR realtor_teams count > 1Unlock: Offices/Teams management, realtor_teams hierarchy, brokerage-wide rollup analytics, broker_admin/owner roles, recruiting pages, brokerage-identity compliance invariant (RECO/TRESA), audit log surface.

Rules:

  • Disclosure is additive and reversible — dropping from team to solo (a partner leaves) re-hides team nav but never deletes data; orphaned records reassign to the remaining owner.
  • Never show a control the viewer can't action. Nav items + buttons are filtered by effective capability (same as church getCapabilitiesByCategory). A plain agent in a team never sees the invite button.
  • The same components render across tiers — they read tier + caps from context, not separate apps. This is the IA's locked "one app shell" decision (§2 Decision #2, §5c).

5. Migration path + MVP cut

5.1 Migration: legacy local_businesses.admin_token → real per-user auth (no broken tenants)

Principle: the old shared magic link keeps working until every member of an account has a real identity; flip per-account, not globally. Mirrors the church /auth/magic one-time cookie-upgrade bridge.

  • Phase 0 — additive schema (no behavior change). Create realtor_users, realtor_memberships, realtor_invites (+ realtor_teams, deferred). Add owner_user_id (nullable) to owned realtor tables. Nothing reads them yet. Existing /business/[token] keeps working unchanged.
  • Phase 1 — dual-auth resolve. requireRealtorCapability + page loaders accept either a valid Supabase Auth session or the legacy admin_token. A legacy-token request resolves to a synthetic owner membership (full caps, scope=brokerage) for that account — identical to how resolveToken returns role:'admin' for a bare church admin_token today. No tenant breaks.
  • Phase 2 — claim the owner identity. First time the account opens via the legacy link, offer "Set up your login" → Supabase Auth sign-up → creates the primary-owner membership bound to that account. Legacy token still honored as a fallback.
  • Phase 3 — invite the rest. Owner invites co-agents (Sheri) via §1.3. Each gets their own identity + membership + scope. owner_user_id backfill: existing leads/deals assigned to the primary owner, then re-attributed via the routing/reassign UI.
  • Phase 4 — retire the shared token (per-account opt-in). Once an account has ≥1 real owner identity, rotate/disable admin_token for that account (or downgrade it to a read-only/demo link). Never a global cutover — flip account-by-account after confirming login works (verification ladder; do not probe the kill switch by triggering it — memory feedback_dont_test_kill_switches_by_triggering).

5.2 MVP cut (Beckett — minimum viable: 2 identities + lead ownership)

Ship exactly this, defer everything else:

  1. Supabase Auth login for the realtor backend (email+password + Google OAuth + email OTP). Reuse /sermons/app / /social/app middleware patterns.
  2. realtor_users + realtor_memberships tables. Account = the existing Beckett local_businesses row. realtor_teams deferred (account == team; team_id null).
  3. Two identities, two roles: Terry = team_admin (scope=team, primary owner, full caps incl. team commission view), Sheri = agent (scope=own by default; flip to team if they want shared visibility — confirm with the Becketts, §6 Q1).
  4. owner_user_id on local_business_leads + a lead "owner / assignee" column + picker in the inbox, + a "my leads / all leads" filter. Voice/chat lead capture stamps owner_user_id (default: round-robin or language-based between the two — minimal routing).
  5. Two-scope read enforcement (own vs team) at the API/query layer (resolver-driven) and RLS on local_business_leads. Commission UI out of MVP (Beckett independents don't need split economics between two partners on day one — confirm).
  6. Invite + accept flow (§1.3) so Terry can add Sheri without the founder hand-provisioning a token.
  7. Dual-auth bridge (Phase 1) so nothing breaks during rollout.

Explicitly out of MVP: brokerage tier, realtor_teams hierarchy, custom groups, commission-split tables, full routing engine, MFA enforcement (recommended fast-follow for any commission/billing role), audit-log surface (write the rows in MVP, build the viewer later).


6. Open questions + security risks

Open questions (need founder / Beckett input)

  • Q1 — Beckett visibility default: do Terry and Sheri want to see each other's leads/pipeline (team scope) or keep them siloed (own scope) with only the team_admin seeing both? Drives Sheri's default scope. (Recommend: ask them; default to own — least-surprise, upgrade is one click.)
  • Q2 — Commissions in scope at all for Beckett? Two independent partners may not track splits in-app initially. If yes, build realtor_deal_commissions (§3.3.4a) now; if no, defer the whole commissions:* surface.
  • Q3 — Brokerage relationship (Sutton): is the account ever the brokerage, or always the team/agent? Sutton runs BoldTrail centrally — we likely sell at the team level, so brokerage tier may stay theoretical for a long time. Confirm before investing in realtor_teams.
  • Q4 — Tenant anchor longevity: keep local_businesses as the realtor account anchor, or introduce a RE-native realtor_accounts table (the audit's P0 #1 data-model question)? This spec assumes local_businesses for MVP; a later data-model doc may migrate it — design account_id as an indirection so that swap is non-breaking.
  • Q5 — Auth method floor: require email verification before first dashboard access? (Recommend yes.) Allow password, or OAuth/OTP-only (simpler, fewer credentials to leak)?

Security risks

  • R1 — Fail-open on missing membership. The church rbac-server once risked defaulting NULL role to admin; the fix was a display_only sentinel → zero caps. Realtor resolver MUST fail closed: no active membership, suspended membership, or unknown role ⇒ zero capabilities, never owner. Suspended ≠ deleted, and suspended must not retain access.
  • R2 — Service-role bypasses RLS. Cron / voice agent / webhooks use service-role and skip RLS entirely. If tenancy/scope logic isn't also in their app code, a routing bug can cross-write another agent's leads. RLS + app-layer must share the one resolver (§3.3.6).
  • R3 — Shared instance, cross-vertical blast radius. One Supabase serves churches, realtors, sermons, social. A too-broad realtor policy or a SECURITY DEFINER helper without a tenant filter could leak across products. Run get_advisors security lint on every realtor table; scope every helper to account_id.
  • R4 — JWT role drift. If role/scope is cached in the Supabase JWT app_metadata, a demotion (team_admin → agent) won't take effect until token refresh — a stale-elevated window. Source of truth = realtor_memberships, read fresh server-side per request; treat any JWT claim as an unverified hint.
  • R5 — Legacy token = unauthenticated god-mode during migration. While Phase 1 dual-auth is live, the shared admin_token still grants full owner access with no per-user identity. Keep the rotation/disable plan (Phase 4) on a short leash, store the token hashed, and never embed it in client-visible URLs/emails beyond the one-time setup link.
  • R6 — Invite-link interception. realtor_invites tokens grant account access. Single-use, short TTL, invalidate on accept, bind to the invited email (the accepting Supabase identity's verified email must match), and rate-limit acceptance attempts.
  • R7 — Cross-pipeline leakage via aggregates. Even with row RLS, an analytics endpoint that aggregates "team performance" can leak a siloed agent's numbers. Gate analytics scope by the same scope/capability and compute per-scope, not globally.
  • R8 — PIPEDA / Law 25 (Canada). Per-agent access to a lead's PII must be on a need-to-know basis; over-broad scope=team defaults can be a privacy-law problem, not just a UX choice. Default narrow; log financial/PII reads (audit rows from MVP); honor the consent/suppression model the audit's P0 #3 will define.

7. Build order (when this is implemented)

  1. Tables (realtor_users, realtor_memberships, realtor_invites) + owner_user_id columns — additive, Phase 0.
  2. Generalize src/lib/rbac.ts (Option A) + add realtor capability set, role templates, catalog + requireRealtorCapability guard.
  3. Supabase Auth wiring for the realtor backend (middleware, login, OAuth/OTP) — clone /sermons/app pattern.
  4. Dual-auth bridge (Phase 1) + legacy-token synthetic-owner.
  5. Invite/accept flow.
  6. owner_user_id stamping on lead capture + owner/assignee UI + "my/all" filter + minimal language routing.
  7. RLS policies + helper functions on local_business_leads (+ commission table if Q2=yes); get_advisors clean.
  8. Per-account Phase-4 token retirement after verifying real login (verification ladder; do not trigger the kill switch to test it).

Spec status: design-only, awaiting founder answers to §6 Q1–Q5 (industry-informed answers now in §8.5). No code, migration, or DB change has been made by this document. §8 (industry validation, role taxonomy, auth-method decision, external-partner pattern) added 2026-06-27, research-backed with cited sources.


8. Industry validation + role taxonomy + auth-method decision (research-backed, added 2026-06-27)

Validates the v0 spec against how the leading real-estate CRM/platforms actually implement multi-user access, then firms up the open decisions. Every claim below is cited to a vendor help-center / docs page or a Canadian-privacy source (links in §8.7). Role names were verified against vendor docs; anything not directly confirmed is marked unverified.

8.1 How the leading RE platforms expose roles, seats & lead visibility

PlatformRoles exposedLead visibility modelExternal-partner accessHierarchy
Follow Up BossOwner, Admin, Agent, Lender, ISA; Collaborator (a per-lead grant, not a role) + Team Leader designationAgent = "access to leads that are assigned to them … can view and edit assigned collaborator and pond leads"; Team Leader sees "all leads assigned to any member of the team, and any leads a team member is a collaborator on"; Admin/Owner = allLender = "only access to contacts they are assigned to or are a collaborator on … even fewer actions they can do than an agent"Account → Teams (flat)
BoldTrail / kvCORE (Royal LePage rlpSPHERE, RE/MAX)Company Admin → Office Admin → Team Admin (nested) over a base Agent; LenderAgent "limited to viewing only their data and leads"; admins cascade down their entityLender = "very similar access to Agent users with the exception of not having a personal website or a Web & IDX tab"Company → Office → Team → Agent (4 levels, nested)
Lofty (Chime)Team Owner / Admin, Agent, Lender, Assistant, up to 10 Custom RolesPermission flags "Access All Company Leads", "Access All Office Leads", and "Private Leads" (a lead even all-company users can't see); routing only acts on "Team Leads"Lender is a first-class role with its own routing tabCompany → Office → Team → Agent
Sierra InteractivePrimary Manager, Manager, Agent, Lender, ISAAgent "can view and manage only their assigned leads"; Manager "can view and manage all leads"Lender "can be given limited, customized permissions"; auto-assignable to leads, featured-lender slotAccount → Managers/Agents (flat)
IXACT ContactTeam Leader + team membersTeam Leader authorizes per-member import / export / delete / sync; pipeline viewable team-wide or per-member(no distinct lender role found — unverified)Team (flat)

Three findings the v0 spec didn't have:

  1. A dedicated external "Lender" role is universal (FUB, BoldTrail, Lofty, Sierra all ship it). It is the canonical external-partner pattern, and it is always more restricted than an agent and scoped to only the records it's attached to. This is the founder's exact question, already solved the same way by the whole industry — §8.3.
  2. Per-record "Collaborator" grants (FUB) let you give someone access to specific leads without changing their role — the elegant primitive for scoped/ephemeral access.
  3. Permission flags layered on a base role ("Access All Company Leads", "Team Features", "Private Leads") — additive grants exactly like our membership.capabilities text[]. Our additive-grant model is industry-standard, not novel risk.

8.2 Verdict on the 6-role model

Keep all six (agent, team_admin, transaction_coordinator, broker_admin, brokerage_owner, marketing_assistant) — they map cleanly onto the incumbents (agent→Agent, team_admin→Team Admin/Team Leader, broker_admin→Office/Company Admin, brokerage_owner→Owner). Add three things:

  • external_partner role (a.k.a. "Lender") — ADD (high priority). The one capability column the matrix is missing. Default = read-only, single-resource, attached-leads-only, no commission $, no team mgmt, no billing, no AI config. Fewer actions than agent. This is the load-bearing addition the founder asked for. Full treatment in §8.3.
  • isa role — ADD (medium priority). Universal (FUB ISA, Lofty Assistant, Sierra ISA). Sits between agent and marketing_assistant: can read/reply to assigned + pond conversations and book appointments/tasks, cannot see commissions, edit listings, or manage the team. Worth a distinct template because it's the most common "extra seat" a team buys.
  • Brokerage Office tier — already covered. BoldTrail's 4-level Company→Office→Team→Agent maps onto our realtor_teams.parent_team_id self-reference (§1.2). No schema change; just confirm the resolver walks the team tree for scope=brokerage.

One rename to consider: our marketing_assistant ≈ Lofty's "Assistant" in name but is content/campaign-scoped, whereas the industry "Assistant"/"ISA" is lead-nurture-scoped. Keep marketing_assistant as-is (it's accurate) and add the separate isa role rather than overloading one label.

MVP impact: Beckett (2 partners) still ships with just team_admin + agent (MVP §5.2 unchanged). external_partner, isa, and the collaborator primitive are fast-follows the moment a mortgage broker, lawyer, or appointment-setter needs in — which for a Sutton team is weeks, not years.

8.3 The external / partner access pattern (the founder's explicit ask)

"a brokerage/team owner has several agents + admin staff + others who need to see certain things" — the "others" are external partners: mortgage broker/lender, real-estate lawyer/notary, stager, photographer/videographer, home inspector, transaction coordinator (if outsourced), showing assistant. The industry answer is consistent; here's ours, hardened for Canada.

Two distinct mechanisms — don't conflate internal staff with external partners:

Internal staff (agent, ISA, TC, admin)External partner (lender, lawyer, stager, photographer, inspector)
IdentityPersistent realtor_memberships row with a role templateEither a lightweight external_partner membership (recurring partner, e.g. the team's preferred lender) or a per-record collaborator grant (one-off, e.g. the lawyer on this deal)
Scopeown / team / brokerage breadthSingle-resource by default — only the lead/deal they're attached to
AccessRead + act per the role matrixRead-mostly, narrow write (e.g. lender can mark "pre-approved", lawyer can upload a doc) — never commissions, never other pipelines, never settings/AI/billing
LifetimeUntil removed/suspendedTime-boxed — expires on a date or on deal close; auto-revoke (the one-off partner should not linger with access after closing)
Consent/auditStandardEvery external read is audit-logged; the partner's access to a lead's PII is itself a Law 25 §20 "agent" disclosure (§8.6) — log it, and surface "who outside the team can see this lead" on the lead drawer

Design:

  • Reuse the collaborator primitive (FUB-style) as the universal grant: realtor_collaborators(record_type, record_id, user_id|email, capabilities[], granted_by, expires_at, revoked_at). A collaborator's effective caps = the intersection of the grant's caps and a hard external ceiling (EXTERNAL_PARTNER_CEILING — never commissions/billing/team/settings/AI/account, even if mis-granted).
  • The recurring external_partner role is just a membership whose role template is that ceiling, plus a default scope=own that resolves to "records I'm a collaborator/assignee on" (no team breadth).
  • Time-box is mandatory for external grants — default 30-day TTL, auto-revoke on deal status=closed, owner can extend. This is the single biggest difference from internal staff and the thing the incumbents under-do (FUB collaborators don't expire) — a real Canada/privacy beat for us.
  • Bind to verified email (the accepting Supabase identity's verified email must equal the invited email — already R6) and rate-limit; a leaked partner invite must not become standing access.

8.4 Auth-method decision (firmed up) — the paragraph the founder can act on

Default to passwordless: email magic-link / OTP as the primary sign-in, with optional password and Google + Microsoft (Office 365) social sign-in offered alongside it, all on Supabase Auth. Require MFA (TOTP authenticator app) for any role that can see commissions or manage billing (team_admin with team-commission view, broker_admin, brokerage_owner, primary owner); keep it optional for plain agents and external partners. Reserve SAML/SSO for the brokerage tier (a large brokerage that wants Okta/Azure AD). External-partner (lender/lawyer/stager) invites should use single-use magic-link/OTP only — never a shared password — and inherit the time-box. Rationale: realtors split their day between a desktop and a phone at showings, so a magic link or 6-digit code emailed on demand beats a forgotten password; it also removes a credential to leak (good for the external-partner case). Supabase Auth supports all of these natively at no extra build cost — passwordless magic-link + email OTP + SMS OTP, email/password, Google & Microsoft OAuth, free TOTP MFA on every project, and SAML 2.0 SSO (Azure AD/Okta) on the higher tier — so this is a configuration decision, not an engineering one. MFA for financial access is both a real-estate norm (MLS boards like CRMLS push MFA to protect agent/client data) and a Quebec Law 25 access-control expectation.

Supabase Auth capability check (verified against Supabase docs, §8.7):

MethodSupabase supportUse in WiseAI Realtor
Email magic-linkYes (single-use URL, 1h default expiry)Default sign-in
Email OTP (6-digit code)YesDefault sign-in (code alternative to link)
PasswordYes (+ forgot-password)Optional, offered not forced
Google OAuthYesOptional (most agents have Gmail)
Microsoft/Azure OAuthYesOptional (Office 365 brokerages)
MFA (TOTP)Yes — "free to use and enabled on all Supabase projects by default", any authenticator appRequired for commission/billing roles
SAML 2.0 SSOYes (Azure AD, Okta; Dashboard/API managed)Brokerage tier only
SMS OTPYesOptional fallback (cost per SMS)

This supersedes §6 Q5 ("password, or OAuth/OTP-only?"): offer passwordless-by-default and allow password — don't force one; require email verification before first dashboard access (Q5 "recommend yes" → confirmed yes).

8.5 Industry-informed answers to the §6 open questions

  • Q1 — Beckett visibility default (siloed vs shared): Industry default is siloed — Agent sees only their assigned leads everywhere (FUB, BoldTrail, Sierra all default an agent to own-only). Recommendation: default Sheri to scope=own; make "share pipeline with my partner" a one-click team setting. This is also the Law 25 §20 least-privilege-by-default posture (§8.6), so it's the safe default legally, not just by least-surprise. (v0's recommendation stands and is now legally reinforced.)
  • Q2 — Commissions in scope for Beckett at all? Incumbents treat commission visibility as a separate, gated thing (it's never bundled into "see the deal"). For two independent partners, defer the commissions:* surface entirely for MVP (v0 §5.2) and build realtor_deal_commissions (§3.3.4a) only if/when they ask to track splits in-app. No incumbent forces it on a 2-person team.
  • Q3 — Brokerage relationship (Sutton): Confirmed industry reality — Sutton/Royal LePage/RE/MAX run BoldTrail (kvCORE) centrally and give it to agents at no per-seat cost (audit §"Agent/brokerage floor"). So we sell at the team/agent level, not as the brokerage system-of-record; the brokerage tier stays theoretical for a long time. Keep realtor_teams deferred (MVP §5.2); design account_id as the indirection so a future brokerage swap is non-breaking. Don't invest in the Office/Company hierarchy until a multi-office customer is real.
  • Q4 — Tenant anchor (local_businesses vs RE-native realtor_accounts): Unchanged — keep local_businesses as the anchor for MVP, with account_id as an indirection layer (the data-model doc owns the eventual swap). Nothing in the research forces an earlier migration.
  • Q5 — Auth-method floor: Resolved in §8.4 — passwordless magic-link/OTP default + optional password + Google/Microsoft OAuth; email verification required before first dashboard access; MFA required for commission/billing roles.

8.6 Canadian privacy reality-check (PIPEDA + Quebec Law 25) — this is why "default narrow" is non-negotiable

Quebec Law 25 §20 (in force) makes per-user scoping a legal requirement, not a UX nicety. Verbatim: "authorized employees or agents may have access to personal information without the consent of the person concerned only if the information is needed for the performance of their duties." It legally enforces least privilege ("minimum rights required to perform their job") and need-to-know ("restricts visibility to specific records based on their current tasks"). RBAC is "the most effective and widely accepted method to practically satisfy Section 20." Concretely this adds three obligations the v0 spec should bake in from MVP:

  1. Default-narrow scope (already R8 / Q1) — a scope=team default that lets every agent see every lead's PII is a privacy-law exposure, not just a UX choice.
  2. Access logging"comprehensive system access logs capturing authentication events, authorization changes, and read/write access to … sensitive personal information." Our §2.2.5 "audit every financial read" should extend to PII reads by external partners (the lender/lawyer who opens a lead). Write the audit rows from MVP (the viewer comes later, per §5.2).
  3. Periodic access reviews"review access rights periodically … and immediately upon an employee's role change or termination." Add an owner-facing "who has access" review surface (fast-follow) and auto-revoke on suspend/remove (already R1, fail-closed). The §20 "employees or agents" wording explicitly covers contractors/external partners — so the time-boxed external-partner grants (§8.3) are squarely a Law 25 concern: scope them, expire them, log them.

PIPEDA's safeguards/limiting-use principles point the same way (need-to-know access proportional to sensitivity). For QC clients add French (Bill 96) for the consent/PII surfaces — tracked in the audit, not this doc.

8.7 Sources

Real-estate platform roles & access:

Auth method & Supabase capabilities:

Canadian privacy: