Tenancy & Identity — Blueprint
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
usersis DISA's existing table — extended with auth columns, not duplicatedorganisationsis DISA's — extended with SaaS fields, not duplicatedTenantMixinandAuditMixinfrommodels/base.py— every other module inherits
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
templates/admin/register.html— sign-uptemplates/admin/login.html— logintemplates/admin/dashboard.html— post-login dashboardtemplates/admin/tenants.html— flat tenant listtemplates/admin/saas.html— SaaS admin console with stats + approval queuetemplates/select_modules.html— module picker with quota meter
7. Migration history
- 0005 (2026-04-28) — initial multi-tenant identity scaffold
- 0006 (2026-05-04) — added billing columns + payment_events table
Future: - TBD — Google OAuth code/state plumbing - TBD — Email-link auth: token generation, send-via-Comms, verify-and-login
8. Cross-module dependencies
- Read by ALL other modules — every router scopes queries by
tenant_idfrom the JWT - Writes to: Audit Trail (every membership change, role escalation, suspension)
- Comms triggers: welcome email on tenant verification, invitation email on membership invite
9. Implementation phases — status
- ✅ Phase A: Sign-up + login + JWT + tenant/branch/membership schema
- ⏳ Phase B: Google OAuth + email-link fallback
- ⏳ Phase C: Email confirmation, password reset, MFA, session management
10. Open questions
- Email-link auth — same JWT signing key as password-auth, or separate?
- 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.)
- Sub-domain routing — when a tenant has
subdomain=foo, requests tofoo.aayojana.dharmaposhanam.inshould auto-set tenant context. Wildcard cert + middleware needed. - Tenant offboarding — soft-delete with data retained for N days vs. hard-delete?
- Platform-admin role — separate role string
ROLE_PLATFORM_ADMINor just currentROLE_ADMIN? (Today: ROLE_ADMIN; needs separation when first non-platform tenant onboards.)