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 are agency_partner, CRM contacts are lead (and related tables), all keyed by agency_id. See DATA_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, phone required, pipeline status, assignment to agency_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), or
  • lead (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:

  1. Host / subdomain — e.g. skyline.example.comagency_id (preferred for branded sites).
  2. Path prefix — e.g. /a/{agency_slug}/....
  3. HeaderX-Agency-Id or X-Agency-Slug for 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

  1. RBAC — Partner: owner > manager > agent > ops (exact permissions TBD). Lead: capabilities limited to self-service (profile, interests, shortlist).
  2. Tenant isolation — Every query for tenant data must include agency_id matching the token. Enforce in middleware, queryset defaults, and row-level checks.
  3. Object-level — Partners may only mutate resources owned by their agency_id. Leads may only access rows tied to their lead_id (and that lead.agency_id matches 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 ⇒ multiple agency_partner rows, same person ⇒ same sub).
  • lead: For each (global account, agency) there should be at most one lead row used for CRM; add nullable global_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-Slug or X-Agency-Id only when consistent with resolution rules (§8).
  • Error codes: 401 unauthenticated; 403 wrong 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 SellerCompanyagency, 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.

18. References