14
🏛️

Tenancy & Identity — Blueprint

Multi-tenant registry · branches · memberships · auth

Blueprint — Tenancy & Identity

Status: LIVE. This blueprint documents the implemented design (Alembic 0005, deployed 2026-04-28). Slug: tenancy · Module #14 · Kind: Cross-cutting · Foundational

1. Module summary

The substrate that makes Aayojana multi-tenant. Every other module's data carries tenant_id and is scoped through Memberships. SaaS sign-up flow creates a tenant + first admin atomically. Auth via password (live) with Google OAuth + email-link planned.

2. Data model — implemented

Schema lives in src/aayojana/models/: - organisation.py — extended with multi-tenant SaaS fields in 0005, billing fields in 0006 - branch.py — physical sub-locations - membership.py — user↔tenant↔branch↔role link - user.py — auth subject (extended in 0005)

# Organisation (extends DISA's existing table)
class Organisation(Base, AuditMixin):
    __tablename__ = "organisations"
    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
    name: Mapped[str] = mapped_column(String(127), nullable=False)
    # ... pre-existing DISA columns ...
    plan_tier: Mapped[str] = mapped_column(String(20), nullable=False, server_default="basic")
    apps_enabled: Mapped[list | None] = mapped_column(JSON, nullable=True)
    verification_status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
    subdomain: Mapped[str | None] = mapped_column(String(63), unique=True, nullable=True)
    custom_domain: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
    sampradaya: Mapped[str | None] = mapped_column(String(127), nullable=True)
    parampara: Mapped[str | None] = mapped_column(String(127), nullable=True)
    # billing — added 0006
    payment_provider: Mapped[str | None]
    razorpay_subscription_id: Mapped[str | None]
    subscription_status: Mapped[str] = mapped_column(server_default="none")
    trial_ends_at: Mapped[datetime | None]
    # ... etc

class Branch(Base, AuditMixin, TenantMixin):
    __tablename__ = "branches"
    __table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_branch_per_tenant"),)
    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
    name: Mapped[str] = mapped_column(String(127), nullable=False)
    # location, contact, is_active

class Membership(Base, AuditMixin, TenantMixin):
    __tablename__ = "memberships"
    __table_args__ = (UniqueConstraint("user_id", "tenant_id", name="uq_membership_user_tenant"),)
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    branch_id: Mapped[str | None] = mapped_column(ForeignKey("branches.id"), nullable=True)
    role: Mapped[str] = mapped_column(String(40), nullable=False)
    status: Mapped[str] = mapped_column(String(20), server_default="pending")
    invited_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
    joined_at: Mapped[datetime | None]

class User(Base):  # extended 0005
    __tablename__ = "users"
    # ... pre-existing DISA columns: id, username, email, hashed_password, enabled, roles, googleId ...
    email_link_token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
    email_link_expires_at: Mapped[datetime | None]
    last_login_at: Mapped[datetime | None]

3. Reuse map

4. API surface — implemented

Method Path Auth Purpose
GET /admin/register public Sign-up form
POST /admin/register public Create tenant + first admin
GET /admin/login public Login form
POST /admin/login public Verify credentials → JWT cookie
GET /admin/logout any Clear cookie
GET /admin/me authenticated Current user info
GET /admin/tenants platform-admin List all tenants on the SaaS instance
GET /admin/saas platform-admin Approval queue, MRR estimate
POST /admin/saas/{org_id}/verify platform-admin Approve a pending tenant
POST /admin/saas/{org_id}/suspend platform-admin Suspend a tenant
GET /admin/select-modules tenant-admin Module picker (post-signup or anytime)
POST /admin/select-modules tenant-admin Save module selection

5. Service layer — implemented

# src/aayojana/services/auth_service.py
def hash_password(plain: str) -> str
def verify_password(plain: str, hashed: str) -> str
def create_access_token(claims: dict, expires_delta: timedelta | None = None) -> str

# src/aayojana/dependencies.py
async def get_current_user(...) -> User
async def get_optional_user(...) -> User | None
def require_role(role: str) -> Callable
async def require_admin_html(...) -> User | RedirectResponse

6. UI / Templates — implemented

7. Migration history

Future: - TBD — Google OAuth code/state plumbing - TBD — Email-link auth: token generation, send-via-Comms, verify-and-login

8. Cross-module dependencies

9. Implementation phases — status

10. Open questions

  1. Email-link auth — same JWT signing key as password-auth, or separate?
  2. Multi-org users — when a user has memberships in multiple tenants, how is "current tenant" picked? (Today: defaults to most recent membership; needs an explicit picker UI.)
  3. Sub-domain routing — when a tenant has subdomain=foo, requests to foo.aayojana.dharmaposhanam.in should auto-set tenant context. Wildcard cert + middleware needed.
  4. Tenant offboarding — soft-delete with data retained for N days vs. hard-delete?
  5. Platform-admin role — separate role string ROLE_PLATFORM_ADMIN or just current ROLE_ADMIN? (Today: ROLE_ADMIN; needs separation when first non-platform tenant onboards.)