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 branchfeat/re-auth-bridge-phase6(Supabase Auth — magic-link/OTP default + password + Google + Microsoft OAuth + MFA/TOTP; there_legacylegacy-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 onlocal_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:
| Layer | Church platform (premium_churches) | Realtor / local-business platform (local_businesses) — where Beckett lives |
|---|---|---|
| Tenant anchor | premium_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_session → church_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-user | Yes — church_team_members (per-member access_token, role, group_ids, capabilities) + church_custom_groups (12 templates) | None. Zero team rows, zero RBAC, zero identities. |
| Authorization engine | Model C resolver in src/lib/rbac.ts — effectivePermissions(member, groups), can(), requireCapability() (server guard) over a 57-key capability taxonomy | None. The token either resolves the whole dashboard or nothing. |
| Authentication primitive | Custom cw_session cookie (hashed token → church_admin_identities). NOT Supabase Auth. | None beyond the shared admin_token string. |
| True Supabase Auth in the codebase | Used by SermonWise (/sermons/app, handleSermonAuth), ShareWiseAI (/social/app, handleSocialAuth), ITW | Not 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_identitiesscheme 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.tsinto 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.tsif 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_teamsis deferred for MVP (account == team whenteam_idis 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)
- Owner invites from Settings → Team: enters email + role + scope (+ team for brokerage tier). Writes a
realtor_invitesrow with a single-use, expiring token (e.g. 7-day TTL). Sends a branded email via Resend on the property's own host (wiseaiagency.comfor the realtor brand — never the churchwiseai.com default; memoryfeedback_outreach_links_per_property_host). This is a transactional account email, not a campaign, so it is exempt from the founder-press-send gate. - 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). - On first successful auth, link
auth.uid()→ createrealtor_usersprofile + flip therealtor_invitesrow to accepted + create therealtor_membershipsrow with the invited role/scope/team. Markstatus='active'. - 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). - Primary-owner transfer (
is_primary_owner) is its own gated capability (team:transfer_ownership), mirroring the churchchurch:transfer_ownershipadmin-only cap.
1.4 Session model
- Supabase Auth session (JWT access + refresh) stored in HTTP-only cookies via
@supabase/ssr, exactly as/sermons/appand/social/appalready do. Middleware refreshes the session and resolvesauth.uid(). - Tenant + role resolution per request: from
auth.uid(), look up the active membership for the requestedaccount_id(the account is in the URL or the active-account cookie). Injectx-realtor-account-id,x-realtor-user-id,x-realtor-role,x-realtor-scopeheaders (mirrors the church middleware'sx-church-id/x-identity-id/x-identity-rolepattern) 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_idcookie; the membership lookup is always re-validated server-side (cookie is a hint, not authority). - Server guard:
requireRealtorCapability(request, cap)— the realtor twin ofrequireCapability()— resolves membership, computes effective caps via the reused resolver, returns 401 (no session/membership) or 403 (missing cap). Request-scoped WeakMap cache, identical torbac-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 domain | agent | team_admin | transaction_coordinator | broker_admin | brokerage_owner | marketing_assistant |
|---|---|---|---|---|---|---|
| Leads / contacts | E (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 / financial | V (own only) | V (team) | — ² | V (bkg) | E (bkg) | — |
| Content (blog · newsletter · social · guides) | E (own) | E (team) | — | E (bkg) | E (bkg) | E (team) |
| Reviews / testimonials | E (own) | E (team) | V | E (bkg) | E (bkg) | E (team) |
| AI config (chat agent · voice lines · scripts · routing) | V³ | E (team) | — | E (bkg) | E (bkg) | V |
| Analytics / attribution | V (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 & plan | — | — | — | V | E | — |
| Settings (business profile·hours·branding·compliance) | V | E (team) | V | E (bkg) | E (bkg) | V (branding only) |
| Integrations / API keys / CRM sync | — | E (team) | V | E (bkg) | E (bkg) | — |
| Ownership transfer / delete account | — | — | — | — | E | — |
¹ 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
isarole (inside sales agent / appointment-setter) — universal (FUB "ISA", Lofty "Assistant", Sierra "ISA"). Distinct fromtransaction_coordinator(deal mechanics) andmarketing_assistant(content). Nurtures/qualifies leads, books appointments, no commission/billing.- Add the
collaboratorgrant 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):
- 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. - 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. - Other agents' pipeline is invisible by default.
leads:lead:read:ownis the agent floor. Seeing a teammate's leads/conversations/deals requires:teamscope on the membership AND the corresponding:teamcapability. 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. - 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:confidentialredaction pattern). - Audit every financial read.
commissions:amount:read:*and billing actions write anaudit: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_idvia 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_idnull) visible to anyone with:teamread until claimed. - Reassignment (
leads:lead:reassign) changesowner_user_idand 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)
| Scope | Sees | Typical roles |
|---|---|---|
own | only records where owner_user_id = me (+ the unassigned pond, read-only) | agent (default) |
team | all records where team_id ∈ my teams (or account_id = mine when teams aren't modelled) | team_admin, TC, marketing_assistant |
brokerage | all 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:
-
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. -
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.
-
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 pondcreate policy leads_select on local_business_leadsfor 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') -
Financial columns (
commission_amount, splits) cannot be hidden by row RLS alone (RLS is row-, not column-, granular in a singleselect *). Two options: (a) put commission figures in a separaterealtor_deal_commissionstable with its own stricter RLS keyed oncommissions: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). -
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. -
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:
| Tier | Trigger | Nav / capabilities that unlock |
|---|---|---|
| Solo agent | 1 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 memberships | Unlock: 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. |
| Brokerage | plan = brokerage OR realtor_teams count > 1 | Unlock: 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). Addowner_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 legacyadmin_token. A legacy-token request resolves to a synthetic owner membership (full caps,scope=brokerage) for that account — identical to howresolveTokenreturnsrole:'admin'for a bare churchadmin_tokentoday. 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_idbackfill: 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_tokenfor 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 — memoryfeedback_dont_test_kill_switches_by_triggering).
5.2 MVP cut (Beckett — minimum viable: 2 identities + lead ownership)
Ship exactly this, defer everything else:
- Supabase Auth login for the realtor backend (email+password + Google OAuth + email OTP). Reuse
/sermons/app//social/appmiddleware patterns. realtor_users+realtor_membershipstables. Account = the existing Beckettlocal_businessesrow.realtor_teamsdeferred (account == team;team_idnull).- Two identities, two roles: Terry =
team_admin(scope=team, primary owner, full caps incl. team commission view), Sheri =agent(scope=ownby default; flip toteamif they want shared visibility — confirm with the Becketts, §6 Q1). owner_user_idonlocal_business_leads+ a lead "owner / assignee" column + picker in the inbox, + a "my leads / all leads" filter. Voice/chat lead capture stampsowner_user_id(default: round-robin or language-based between the two — minimal routing).- Two-scope read enforcement (
ownvsteam) at the API/query layer (resolver-driven) and RLS onlocal_business_leads. Commission UI out of MVP (Beckett independents don't need split economics between two partners on day one — confirm). - Invite + accept flow (§1.3) so Terry can add Sheri without the founder hand-provisioning a token.
- 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 toown— 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 wholecommissions:*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_businessesas the realtor account anchor, or introduce a RE-nativerealtor_accountstable (the audit's P0 #1 data-model question)? This spec assumeslocal_businessesfor MVP; a later data-model doc may migrate it — designaccount_idas 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-serveronce risked defaulting NULL role toadmin; the fix was adisplay_onlysentinel → 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_advisorssecurity lint on every realtor table; scope every helper toaccount_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_tokenstill 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_invitestokens 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=teamdefaults 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)
- Tables (
realtor_users,realtor_memberships,realtor_invites) +owner_user_idcolumns — additive, Phase 0. - Generalize
src/lib/rbac.ts(Option A) + add realtor capability set, role templates, catalog +requireRealtorCapabilityguard. - Supabase Auth wiring for the realtor backend (middleware, login, OAuth/OTP) — clone
/sermons/apppattern. - Dual-auth bridge (Phase 1) + legacy-token synthetic-owner.
- Invite/accept flow.
owner_user_idstamping on lead capture + owner/assignee UI + "my/all" filter + minimal language routing.- RLS policies + helper functions on
local_business_leads(+ commission table if Q2=yes);get_advisorsclean. - 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
| Platform | Roles exposed | Lead visibility model | External-partner access | Hierarchy |
|---|---|---|---|---|
| Follow Up Boss | Owner, Admin, Agent, Lender, ISA; Collaborator (a per-lead grant, not a role) + Team Leader designation | Agent = "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 = all | Lender = "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; Lender | Agent "limited to viewing only their data and leads"; admins cascade down their entity | Lender = "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 Roles | Permission 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 tab | Company → Office → Team → Agent |
| Sierra Interactive | Primary Manager, Manager, Agent, Lender, ISA | Agent "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 slot | Account → Managers/Agents (flat) |
| IXACT Contact | Team Leader + team members | Team 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:
- 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.
- Per-record "Collaborator" grants (FUB) let you give someone access to specific leads without changing their role — the elegant primitive for scoped/ephemeral access.
- 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_partnerrole (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 thanagent. This is the load-bearing addition the founder asked for. Full treatment in §8.3.isarole — ADD (medium priority). Universal (FUB ISA, Lofty Assistant, Sierra ISA). Sits betweenagentandmarketing_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
Officetier — already covered. BoldTrail's 4-level Company→Office→Team→Agent maps onto ourrealtor_teams.parent_team_idself-reference (§1.2). No schema change; just confirm the resolver walks the team tree forscope=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) | |
|---|---|---|
| Identity | Persistent realtor_memberships row with a role template | Either 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) |
| Scope | own / team / brokerage breadth | Single-resource by default — only the lead/deal they're attached to |
| Access | Read + act per the role matrix | Read-mostly, narrow write (e.g. lender can mark "pre-approved", lawyer can upload a doc) — never commissions, never other pipelines, never settings/AI/billing |
| Lifetime | Until removed/suspended | Time-boxed — expires on a date or on deal close; auto-revoke (the one-off partner should not linger with access after closing) |
| Consent/audit | Standard | Every 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
collaboratorprimitive (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_partnerrole is just a membership whose role template is that ceiling, plus a defaultscope=ownthat 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_adminwith 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):
| Method | Supabase support | Use in WiseAI Realtor |
|---|---|---|
| Email magic-link | Yes (single-use URL, 1h default expiry) | Default sign-in |
| Email OTP (6-digit code) | Yes | Default sign-in (code alternative to link) |
| Password | Yes (+ forgot-password) | Optional, offered not forced |
| Google OAuth | Yes | Optional (most agents have Gmail) |
| Microsoft/Azure OAuth | Yes | Optional (Office 365 brokerages) |
| MFA (TOTP) | Yes — "free to use and enabled on all Supabase projects by default", any authenticator app | Required for commission/billing roles |
| SAML 2.0 SSO | Yes (Azure AD, Okta; Dashboard/API managed) | Brokerage tier only |
| SMS OTP | Yes | Optional 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 buildrealtor_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
brokeragetier stays theoretical for a long time. Keeprealtor_teamsdeferred (MVP §5.2); designaccount_idas 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_businessesvs RE-nativerealtor_accounts): Unchanged — keeplocal_businessesas the anchor for MVP, withaccount_idas 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:
- Default-narrow scope (already R8 / Q1) — a
scope=teamdefault that lets every agent see every lead's PII is a privacy-law exposure, not just a UX choice. - 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).
- 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:
- Follow Up Boss — Users, Roles & Permissions, Collaborators, Teams, Working with Lenders, Pricing
- BoldTrail / kvCORE — Defining User Roles, BoldTrail Admin Overview
- Lofty (Chime) — Organization · Permission Profiles, Lead Routing Rules, Agent-to-Agent Lead Collaboration
- Sierra Interactive — Adding and Managing Users
- IXACT Contact — CRM for Real Estate Teams
Auth method & Supabase capabilities:
- Supabase — Auth overview, Passwordless email logins (magic link / OTP), MFA (TOTP), Auth (social + SSO/SAML)
- Real-estate MFA expectation — CRMLS: Multifactor Authentication for your MLS Platform
Canadian privacy:
- Quebec Law 25 §20 internal-access limits — WatchDog Security: Law 25 §20 Internal Access Limitations, Act respecting the protection of personal information in the private sector (s.20)
- PIPEDA — OPC: PIPEDA requirements in brief