Jun 26 MT-0 phase
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# Multi-Tenant Plan — JQC
|
||||
|
||||
> **Audience:** AI assistants and developers extending JQC into a multi-tenant SaaS.
|
||||
> **Companion to:** `CLAUDE.md` (single-tenant architecture reference).
|
||||
> **Status:** Planning. No code written yet. Existing single-tenant deploy stays live and untouched until cutover.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decisions (locked)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Isolation model | **Shared codebase + database-per-tenant** (one deploy, dynamic Host routing). NOT fork-per-tenant. |
|
||||
| "Managed by tenant" | Tenant-admin self-serves branding / users / settings / in-plan feature toggles. No per-tenant code edits. |
|
||||
| Plan-tier axes | (1) user quota, (2) facility quota, (3) inspections/month quota, (4) issues/month quota, (5) feature gates (mobile API, scheduled reports, …), (6) branding/white-label, (7) custom-domain vs subdomain-only. |
|
||||
| Existing LT deploy | Migrated in as **tenant-zero** — register existing live DB in place, no data move (§8). |
|
||||
| Quota-exceed behavior | **Soft warn** — allow submit, flag for upgrade. Never reject. |
|
||||
| Tenant DB credentials | **Per-tenant MySQL user + password**. Provisioning creates the user/grants; creds encrypted at rest in control DB. |
|
||||
|
||||
Terminology: **control plane** = manages tenants (registry, plans, provisioning). **data plane** = serves tenant traffic (the existing Flask app, now tenant-aware).
|
||||
|
||||
---
|
||||
|
||||
## 2. Why this model
|
||||
|
||||
- **DB-per-tenant** → true data isolation; satisfies "separate database". Each tenant = own MySQL database, e.g. `jqc_<slug>`.
|
||||
- **Shared code** → one codebase to patch. As a solo maintainer, N forks would mean N bug-fix deploys. Avoided.
|
||||
- App already uses the **application-factory pattern** (`create_app`) and **relative `/api/v1` paths** — both ideal for adding Host-based tenant routing with minimal churn.
|
||||
- Migrations already use `INFORMATION_SCHEMA` existence checks (CLAUDE.md Rule 14) → safe to re-run across every tenant DB unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Control-plane database (new, separate schema)
|
||||
|
||||
Lives in its own MySQL database (e.g. `jqc_control`), its own Alembic env (chain prefix `control{N}_…`), never routed by tenant middleware.
|
||||
|
||||
```
|
||||
plans
|
||||
id, code (slug: free/starter/pro/enterprise), name, active,
|
||||
max_users INT NULL, -- NULL = unlimited
|
||||
max_facilities INT NULL,
|
||||
max_inspections_month INT NULL,
|
||||
max_issues_month INT NULL,
|
||||
allow_custom_domain BOOL,
|
||||
allow_mobile_api BOOL,
|
||||
allow_scheduled_reports BOOL,
|
||||
allow_branding BOOL, -- white-label
|
||||
price_cents INT NULL, -- future billing
|
||||
billing_period VARCHAR(16) NULL
|
||||
|
||||
plan_features -- optional EAV escape hatch for future boolean flags
|
||||
id, plan_id (FK), feature_key, enabled BOOL
|
||||
|
||||
tenants
|
||||
id, slug (subdomain label, UNIQUE), name, plan_id (FK),
|
||||
status ENUM(provisioning, active, suspended, deleted),
|
||||
db_host, db_port, db_name, db_user, db_password_enc, -- creds encrypted at rest
|
||||
alembic_head VARCHAR(64), -- last tenant-schema rev applied
|
||||
created_at, suspended_at, notes
|
||||
|
||||
tenant_domains
|
||||
id, tenant_id (FK), domain (UNIQUE), kind ENUM(subdomain, custom),
|
||||
is_primary BOOL, verified BOOL, verification_token,
|
||||
tls_status ENUM(pending, active, failed), created_at
|
||||
|
||||
superadmins -- cross-tenant accounts, control-plane only
|
||||
id, username, email, password_hash, active, created_at
|
||||
|
||||
provisioning_jobs
|
||||
id, tenant_id (FK), action ENUM(create_db, migrate, seed, suspend, delete),
|
||||
status ENUM(queued, running, ok, failed), log TEXT, created_at, finished_at
|
||||
|
||||
tenant_audit -- superadmin actions (separate from per-tenant log_action)
|
||||
id, superadmin_id, action, tenant_id, details, ip_address, created_at
|
||||
```
|
||||
|
||||
Submission quotas (inspections/issues per month) are enforced by **live-counting current-period rows in the tenant DB** at submit time (cheap with an index on the date column). No separate counter table required; always accurate. A cached `usage_counters` table is an optional later optimization if submit-path counts ever show up in profiling.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tenant resolution + DB routing (the core mechanism)
|
||||
|
||||
**Resolution** (`before_request`): `host = request.host.split(':')[0]` → look up `tenant_domains.domain == host` where `verified AND tenant.status='active'` → load `tenant` + `plan` into `g`. Unknown/unverified/suspended host → branded error/landing page (no tenant DB touched).
|
||||
|
||||
**Routing without rewriting every `db.session` call** — use a routing Session so the entire existing codebase keeps using `db.session` unchanged (preserves Change-Philosophy Rules 5/7):
|
||||
|
||||
```python
|
||||
# app/tenancy/routing.py
|
||||
from sqlalchemy.orm import Session
|
||||
from flask import g
|
||||
|
||||
class RoutingSession(Session):
|
||||
def get_bind(self, mapper=None, clause=None, **kw):
|
||||
# Tenant models (app.db.Model) bind to the per-request tenant engine.
|
||||
# The control plane is a SEPARATE Base/engine/session (control/base.py),
|
||||
# so it never passes through this session — no special-casing needed here.
|
||||
return getattr(g, 'tenant_engine', None) or _default_engine
|
||||
```
|
||||
|
||||
> **MT-0 refinement (implemented):** the control plane uses its own
|
||||
> `ControlBase` + engine + session (`control/base.py`), **not** `db.Model` with
|
||||
> a `__control_plane__` flag. Stronger isolation, zero metadata mixing with the
|
||||
> tenant schema, and the control package imports nothing from `app/`. The
|
||||
> `RoutingSession` (MT-1) therefore only handles tenant models.
|
||||
|
||||
```python
|
||||
# app/__init__.py (one-line change to the existing db init)
|
||||
db = SQLAlchemy(session_options={'class_': RoutingSession})
|
||||
```
|
||||
|
||||
```python
|
||||
# before_request
|
||||
g.tenant_engine = _engine_cache.get_or_create(tenant) # create_engine(tenant.db_uri)
|
||||
```
|
||||
|
||||
- `_engine_cache`: `dict[tenant_id -> Engine]`, lazily built. Use small pools + `pool_recycle`; switch to `NullPool` if tenant count grows large (connection-count = Σ tenant pools). Documented scaling lever.
|
||||
- The **control plane is a separate Base/engine/session** (`control/base.py`); it never routes through `RoutingSession`. See §3 MT-0 refinement.
|
||||
- **Net code change to existing models/routes: zero.** They keep importing `from app import db` and calling `db.session`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrations
|
||||
|
||||
Two independent Alembic chains:
|
||||
|
||||
1. **Tenant schema** — the existing chain (current HEAD on disk: **`phase27_score_alerts`**; note `CLAUDE.md` text says phase23 — stale). Runs per-tenant DB. New tenant features continue this chain as `phase28_…`, `phase29_…` per existing naming + `down_revision` → current HEAD rule.
|
||||
2. **Control schema** — new env, chain `control{N}_…`, runs once against `jqc_control`.
|
||||
|
||||
CLI added: `flask tenant db upgrade --tenant <id|all>` loops tenants, sets `sqlalchemy.url` to each tenant URI, runs `command.upgrade(cfg, 'head')`, writes resulting head back to `tenants.alembic_head`. All existing migrations are re-run-safe (Rule 14), so this is idempotent across the fleet.
|
||||
|
||||
Deploy ordering rule carries over (CLAUDE.md Rule 23): run tenant migrations **before** shipping app code that references new columns.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan-tier matrix (starting point — tune in §9)
|
||||
|
||||
| Axis | Free | Starter | Pro | Enterprise |
|
||||
|---|---|---|---|---|
|
||||
| Max users | 3 | 15 | 50 | unlimited |
|
||||
| Max facilities | 2 | 10 | 50 | unlimited |
|
||||
| Inspections / month | 50 | 500 | 5 000 | unlimited |
|
||||
| Issues / month | 50 | 500 | 5 000 | unlimited |
|
||||
| Mobile API (iPad app) | ✗ | ✓ | ✓ | ✓ |
|
||||
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
|
||||
| Branding / white-label | ✗ | ✗ | ✓ | ✓ |
|
||||
| Custom domain | ✗ | ✗ | ✓ | ✓ |
|
||||
| Subdomain (`*.jqc.app`) | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
Enforcement: `@feature_required('mobile_api')` reads `g.tenant.plan` and **blocks** disabled features. Quota checks (`@quota_check('inspections')`) are **soft** — over-limit submits still succeed but the response/UI flags an upgrade prompt and the event is recorded; submits are never rejected. **Feature + quota checks must live in BOTH web routes and `/api/v1` endpoints** — the iPad submits via API, so a web-only check is bypassable (mirrors existing dual-enforcement Rules 39/56/57).
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased roadmap
|
||||
|
||||
Each phase additive; existing tenant-zero traffic keeps working throughout.
|
||||
|
||||
**MT-0 — Control-plane scaffold. ✅ DONE.** Self-contained `control/` package at repo root: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/` — existing app untouched.
|
||||
|
||||
**MT-1 — Tenant resolution + routing.** `app/tenancy/`: `resolver.py`, `routing.py` (RoutingSession), `engine_cache.py`, `before_request`/`teardown` hooks. One-line `db` init change. Unknown-host landing page.
|
||||
|
||||
**MT-2 — Per-tenant migration runner.** `flask tenant db upgrade --tenant <id|all>`; record `alembic_head` per tenant.
|
||||
|
||||
**MT-3 — Provisioning service.** Create DB → create **per-tenant MySQL user + password** + grant scoped to that DB only → upgrade to head → seed first tenant-admin → invite email. Creds encrypted into the `tenants` row. Idempotent, `provisioning_jobs`-logged.
|
||||
|
||||
**MT-4 — Superadmin control panel.** Tenant CRUD, plan assign, suspend/resume, domain mgmt, migration status, impersonation (scoped login into a tenant for support). At `admin.jqc.app`, separate blueprint, superadmin-gated.
|
||||
|
||||
**MT-5 — Plans + quota/feature gating.** `@feature_required`, `@quota_check`; new cross-tenant `superadmin` (control plane). Existing tenant `admin` = top role within a tenant (unchanged ENUM).
|
||||
|
||||
**MT-6 — Custom domain + TLS.** Wildcard cert for `*.jqc.app`; Caddy on-demand TLS in front for arbitrary custom domains. Domain-verification flow (TXT/CNAME) before activation.
|
||||
|
||||
**MT-7 — Tenant self-service.** Tenant-admin: branding (logo/name/colors), domain request, plan view, in-plan feature toggles. User mgmt auto tenant-scoped by routing.
|
||||
|
||||
**MT-8 — Billing (future).** Stripe per-plan; lifecycle (trial/suspend/dunning). Flag-gated.
|
||||
|
||||
**MT-9 — iOS multi-tenant.** Replace hardcoded `ServerOption` enum (`Utils/Constants.swift`) with onboarding: enter subdomain or work-email → discovery endpoint resolves base URL → store per-tenant in Keychain. All `/api/v1` calls unchanged (server resolves tenant by Host). Tenant-zero keeps `jqc.ltservicesinc.com` as a custom domain so existing builds keep working through rollout.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tenant-zero (LT Services) migration
|
||||
|
||||
Register the **existing live LT database in place** — no dump/reload:
|
||||
|
||||
1. Insert `tenants` row (id 1), `db_*` pointing at the current LT DB + creds. Record its current alembic head.
|
||||
2. Insert `tenant_domains`: primary subdomain `lts.jqc.app`; plus `jqc.ltservicesinc.com` (kind=custom, verified) so existing web users **and** existing iPad builds keep working with zero disruption.
|
||||
3. Point wildcard DNS / Caddy at the (now tenant-aware) app. Resolver returns tenant 1 for both hosts.
|
||||
|
||||
Result: existing users notice nothing; LT is now just "tenant 1" inside the new model.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions resolved — MT-0 unblocked
|
||||
|
||||
1. **Quota-exceed** → soft warn (allow + flag upgrade). Never reject. §6.
|
||||
2. **Tenant DB credentials** → per-tenant MySQL user + password; provisioning creates user/grants scoped to the single tenant DB; creds encrypted at rest. §3, MT-3.
|
||||
3. **Tenant-zero** → register existing live LT DB in place, no data move. §8.
|
||||
|
||||
All planning decisions locked. Ready to begin **MT-0** (control-plane scaffold) on request.
|
||||
|
||||
### MT-0 first deliverables (preview)
|
||||
- `jqc_control` database + control Alembic env (`control0001_init`).
|
||||
- Control models on a dedicated `ControlBase`: `Plan`, `PlanFeature`, `Tenant`, `TenantDomain`, `Superadmin`, `ProvisioningJob`, `TenantAudit`.
|
||||
- Plan seeder (Free / Starter / Pro / Enterprise per §6 matrix).
|
||||
- No data-plane change in MT-0 — existing app keeps running against the LT DB exactly as today.
|
||||
Reference in New Issue
Block a user