Authentication, authorization, and multi-tenancy
This document defines how agents (real-estate agencies and their staff) and leads (prospects who interact with listings) authenticate and are authorized on a single platform with many agencies (tenants). It aligns naming and data with the canonical business schema under db/architecture_setup/—not legacy Django SellerCompany terminology.
Related: ADR 0001: Single active tenant per JWT
1. Goals
- One login identity per person across the platform: the same mobile (lead) or the same agent staff member must not require duplicate global accounts when they interact with more than one agency context.
- Tenant isolation: data for agency A must not leak to agency B; authorization must default-deny across tenants.
- Two primary actor types: (1) agency staff—create and manage listings, quotes, CRM; (2) leads—view properties, express interest, manage preferences per agency where they have a relationship.
- Canonical schema: tenants are
agency, staff areagency_partner, CRM contacts arelead(and related tables), all keyed byagency_id. SeeDATA_MODEL.md.
2. Glossary
| Term | Definition |
|---|---|
| Agency | Top-level tenant: a real-estate brokerage using the platform. Table: agency. |
| Agency partner | A person who works for an agency (owner, manager, agent, ops). Table: agency_partner, unique (agency_id, email). |
| Lead (CRM) | A prospect record for one agency. Table: lead, scoped by agency_id. Same phone number may appear on multiple lead rows for different agencies. |
| Global account | The single platform identity used for credentials (e.g. login, OTP): one row per real person for authentication purposes. |
| Membership | Binding between a global account and a role in a specific agency context (e.g. “lead for agency 7”, “agent partner id 42”). |
| Session context | The active agency + role for this request (drives JWT claims and queries). |
Deprecated in new APIs and documentation: “seller company” — map to agency (see §9).
3. Current state vs target
3.1 Legacy Django (to be migrated)
| Area | Today | Issue |
|---|---|---|
| Tenant | companies.SellerCompany |
Naming and shape diverge from agency. |
| Agent user | mera_brand.users.User with seller_company FK |
One Django user per signup; no model for “same person, two agencies” as lead. |
| Signup | companies.serializers.SignupSerializer creates company + user, global email uniqueness |
Does not express multi-agency lead identity. |
| Auth | SimpleJWT + username/password | No agency_id in token; no lead OTP flow. |
| “Lead” | leads.models.Lead — anonymous inquiry on Property |
Not the CRM lead table; no cross-tenant story. |
3.2 Canonical database (db/architecture_setup/schema.sql)
agency— tenant root.agency_partner— staff;role IN ('owner','manager','agent','ops');UNIQUE (agency_id, email).lead— CRM row per agency (agency_id,phonerequired, pipelinestatus, assignment toagency_partner, etc.).- Related:
lead_contact_preference,lead_property_interest,lead_property_shortlist, …
The seed db/architecture_setup/seeds/002_multi_agency_network.sql demonstrates one phone, two lead rows under two agencies. Authentication design adds a global account so those rows refer to one person without two separate login users.
4. Core concept: global account + memberships
flowchart LR
subgraph globalLayer [Global_identity]
GA[GlobalAccount]
end
subgraph tenantA [Agency_A]
M1[Membership_lead]
M2[Membership_agent]
end
subgraph tenantB [Agency_B]
M3[Membership_lead]
end
GA --> M1
GA --> M2
GA --> M3
- Global account holds credentials (e.g. password hash for partners, OTP verification state for leads) and a canonical mobile (and optional email) for identity matching.
- Memberships link that account to:
agency_partner(agent staff), orlead(CRM prospect for that agency),
Implementation may store memberships in Django tables, in the same Postgres database as agency / lead, or a dedicated account_membership table—exact DDL is a Phase B task. The invariant is: one global account, many (agency_id, role) links.
Rule: When a lead with mobile M signs up on agency B’s site after already having an account from agency A, the system resolves the existing global account and creates a new membership (and lead row if missing)—no second User row for the same person.
5. Identifiers
| Actor | Primary identifier | Uniqueness | Notes |
|---|---|---|---|
| Lead (credentials) | Mobile | Unique on global account (after E.164 normalization) | OTP delivery; optional email on profile only. |
| Agency partner (credentials) | Email + mobile | Email unique per agency_id (agency_partner); mobile should be verified for high-risk actions |
Login should include which agency (see §8). |
Normalization: Store and compare phones in E.164 (e.g. +9198xxxxxxx for India). Reject ambiguous local formats at signup.
Login identifier for partners: Prefer agency_id (or slug) + email in the login payload so the same email can exist at different agencies without collision in username fields.
6. Authentication mechanisms (design level)
| Actor | Recommended mechanism | Notes |
|---|---|---|
| Agency partner | Password (+ optional MFA) | Align with enterprise expectations; audit login. |
| Lead | OTP to mobile | Rate-limit, device/session binding; document SIM-swap and OTP abuse in threat model (§11). |
Refresh tokens: Rotate on use; bind to client where practical.
Switching agency context: If a user has multiple memberships, use a switch context endpoint that issues a new access token with updated agency_id and membership_id (see ADR 0001).
7. JWT contract (access token)
Claims should be sufficient for authorization without extra DB round-trips for the common path, while keeping tokens small.
| Claim | Required | Description |
|---|---|---|
sub |
Yes | Global account ID (stable UUID or bigint). |
agency_id |
Yes* | Active tenant. *Omit only for purely global endpoints (rare). |
role |
Yes | Coarse type: partner | lead (fine-grained role in agency_partner.role for partners). |
membership_id |
Recommended | Stable id for the active membership row. |
partner_id |
If role=partner |
FK to agency_partner.id when applicable. |
lead_id |
If role=lead |
FK to lead.id for the active agency. |
exp, iat |
Yes | Standard JWT. |
Naming: use agency_id in tokens and headers to match db/architecture_setup/schema.sql.
8. Tenant resolution (white-label / multi-site)
Resolve which agency a request belongs to before issuing or validating session-scoped tokens:
- Host / subdomain — e.g.
skyline.example.com→agency_id(preferred for branded sites). - Path prefix — e.g.
/a/{agency_slug}/.... - Header —
X-Agency-IdorX-Agency-Slugfor API clients (never trust alone without client authentication).
Document the chosen order in deployment config. Public listing pages must resolve tenant before showing data.
9. Terminology rename (legacy → canonical)
| Legacy (Django / old docs) | Canonical |
|---|---|
| Seller company | Agency (agency) |
| Company signup | Agency onboarding |
| seller_company FK | agency relationship |
| “Real estate agent” group (Django) | Agency partner roles in agency_partner.role |
Anonymous leads.Lead inquiry |
Superseded by architecture lead + lead_property_interest where applicable |
10. Authorization model
10.1 Layers
- RBAC — Partner:
owner>manager>agent>ops(exact permissions TBD). Lead: capabilities limited to self-service (profile, interests, shortlist). - Tenant isolation — Every query for tenant data must include
agency_idmatching the token. Enforce in middleware, queryset defaults, and row-level checks. - Object-level — Partners may only mutate resources owned by their
agency_id. Leads may only access rows tied to theirlead_id(and thatlead.agency_idmatches token).
10.2 Example matrix (illustrative)
| Resource | Lead | agent (partner) | manager | owner |
|---|---|---|---|---|
Own lead row |
R/W self | R (CRM) | R/W | R/W |
property (same agency) |
R (public/policy) | R/W | R/W | R/W |
property (other agency) |
— | Deny | Deny | Deny |
agency_partner users |
— | R self | R/W team | R/W |
(Final matrix should be expanded in Phase B with Django permissions or Casbin-style policy.)
11. Threat model (summary)
| Risk | Mitigation |
|---|---|
| Cross-tenant IDOR | Mandatory agency_id check on every mutation; tests for horizontal privilege escalation. |
| Token theft | Short access TTL, refresh rotation, HTTPS only. |
| OTP brute force | Rate limits, lockouts, CAPTCHA on abuse. |
| SIM swap (lead OTP) | Step-up for sensitive actions; optional email backup; monitoring. |
| Admin abuse | Break-glass accounts, audit log, MFA. |
12. Linking global account to agency_partner and lead
agency_partner: Each staff user is one row per agency; global account has one membership per agency where they work (multiple agencies ⇒ multipleagency_partnerrows, same person ⇒ samesub).lead: For each(global account, agency)there should be at most oneleadrow used for CRM; add nullableglobal_account_id(or a join table) in Phase B if not present in schema yet—do not duplicate login users.
Duplicate detection: match by normalized phone when creating lead for an agency; merge flows if an existing global account is found.
13. API conventions
- Authenticated requests:
Authorization: Bearer <access_token>. - Tenant hint (if not in token):
X-Agency-SlugorX-Agency-Idonly when consistent with resolution rules (§8). - Error codes:
401unauthenticated;403wrong role or wrong tenant; avoid leaking existence of resources across tenants.
14. Migration phases
| Phase | Scope |
|---|---|
| A | This document + ADRs (identifiers, JWT, tenant resolution). |
| B | Schema for global account + membership; Django/API implementation; OTP and partner login. |
| C | Migrate SellerCompany → agency, agent users → agency_partner; wire Property and CRM to agency_id; retire legacy naming in code. |
15. Open decisions
| Topic | Options | Recommendation |
|---|---|---|
| OTP provider | Twilio / MSG91 / etc. | Choose by region and compliance. |
| JWT scope | Single active agency_id vs many in one token |
Single active tenant per JWT for simpler authz. |
| Data residency / delete | Per-tenant export vs full account deletion | Policy + legal review. |
16. Django implementation (accounts app)
Implemented in the repo (Phase B subset):
| Endpoint | Purpose |
|---|---|
POST /api/auth/agency/signup/ |
Create Agency, owner AgencyPartner, JWT with agency_id. |
POST /api/auth/partner/login/ |
Body: agency_slug, email, password. |
POST /api/auth/lead/signup/ |
Body: agency_slug, phone, password — reuses User by phone_e164 when present. |
POST /api/auth/lead/login/ |
Same identifiers; checks LeadIdentity.is_active for that agency only. |
POST /api/auth/token/refresh/ |
Extends SimpleJWT refresh to copy agency_id and role claims onto new access tokens. |
GET/POST /api/auth/agency/partners/ |
List / invite partner (owner or manager; only owner may add a manager). |
PATCH /api/auth/agency/partners/<id>/ |
Set partner is_active (not owner; not self). |
GET /api/auth/agency/lead-identities/ |
List leads for the JWT agency. |
PATCH /api/auth/agency/lead-identities/<id>/ |
Set lead is_active for this agency only. |
Global identity: Django users.User with optional phone_e164 (unique). Partners use AgencyPartner OneToOne to User (one agency per partner user). Leads use LeadIdentity (user + agency).
Tests: python manage.py test accounts.tests.test_auth
17. Known limitations and drawbacks (current architecture)
| Area | Limitation |
|---|---|
| Postgres business schema | Django models (Agency, LeadIdentity) are not yet synced to db/architecture_setup/schema.sql agency / lead tables — dual sources of truth until a migration/ETL layer exists. |
| Leads use password | Production should use OTP to mobile; passwords are acceptable for dev/tests only. |
| Partner email | AgencyPartner.email is globally unique across agencies (one partner row per email). Inviting the same email to another agency is rejected. |
| Phone uniqueness | User.phone_e164 is unique; partner staff cannot share a phone with another user. A person could be a lead (phone-based) and later a partner only if phone is not already taken by another User. |
| Owner deactivation | Owner cannot be deactivated via API (by design); no “transfer ownership” flow yet. |
| JWT | Long-lived access tokens in config are dev-friendly; rotate often and shorten TTL in production. |
| Legacy | SellerCompany, properties.Property.seller_company, and old companies signup still exist alongside the new accounts API. |
| Refresh token | Custom claims copied in ContextTokenRefreshView; if SimpleJWT internals change, re-verify this view. |