Jun 28 Optimize code
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + **MT-0 through MT-3 multi-tenant control plane**)
|
||||
> **Last reviewed:** June 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + **MT-0 through MT-7 complete; MT-8 flag-gated; MT-9 iOS pending**)
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
19. [Infrastructure](#19-infrastructure)
|
||||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||||
21. [Change Philosophy](#21-change-philosophy)
|
||||
22. [Multi-Tenant Architecture (MT-0 → MT-3)](#22-multi-tenant-architecture-mt-0--mt-3)
|
||||
22. [Multi-Tenant Architecture (MT-0 → MT-7)](#22-multi-tenant-architecture-mt-0--mt-7)
|
||||
23. [Coding Rules for AI Assistants](#23-coding-rules-for-ai-assistants)
|
||||
|
||||
---
|
||||
|
||||
@@ -85,11 +86,13 @@ lt_janitorial_quality_control/
|
||||
├── app/
|
||||
│ ├── __init__.py # Application factory — limiter, csrf, db (RoutingSession), mail, login_manager, init_tenancy
|
||||
│ ├── tenancy/ # MT-1 — tenant resolution + DB routing (inert unless MULTI_TENANT_ENABLED=true)
|
||||
│ │ ├── __init__.py # public exports: RoutingSession, init_tenancy, TenantContext
|
||||
│ │ ├── context.py # TenantContext frozen dataclass (g.tenant)
|
||||
│ │ ├── __init__.py # public exports: RoutingSession, init_tenancy, TenantContext, feature_required, quota_soft_check
|
||||
│ │ ├── context.py # TenantContext frozen dataclass (g.tenant) — MT-5: +9 plan fields
|
||||
│ │ ├── engine_cache.py # per-tenant SQLAlchemy engine cache + invalidate()
|
||||
│ │ ├── middleware.py # init_tenancy() — before_request Host→tenant resolver
|
||||
│ │ ├── resolver.py # resolve_tenant(host) → TenantContext | None
|
||||
│ │ ├── gates.py # MT-5: @feature_required (hard 403) + @quota_soft_check (soft warn)
|
||||
│ │ ├── middleware.py # init_tenancy() — before_request Host→tenant resolver + MT-4 impersonation override
|
||||
│ │ ├── quota.py # MT-5: live quota counters (inspections/issues/users/facilities)
|
||||
│ │ ├── resolver.py # resolve_tenant(host) → TenantContext | None — MT-5: loads plan fields
|
||||
│ │ └── routing.py # RoutingSession — routes db.session to g.tenant_engine
|
||||
│ ├── api/ # Mobile REST API
|
||||
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
|
||||
@@ -108,9 +111,15 @@ lt_janitorial_quality_control/
|
||||
│ │ ├── inspection.py # Inspection — mobile_local_id column (Phase B)
|
||||
│ │ ├── issue.py # Issue — mobile_local_id (Phase B), reported_by (Phase 18), mobile_photo_paths (Phase 19)
|
||||
│ │ ├── support.py # SupportTicket, SupportTicketReply (Phase 23)
|
||||
│ │ ├── tenant_settings.py # MT-7: TenantSettings — per-tenant branding (one row per tenant DB)
|
||||
│ │ └── ...
|
||||
│ ├── routes/
|
||||
│ │ ├── support.py # /support/* — AI chat, ticket submit/list/detail (Phase 23)
|
||||
│ │ ├── tenant_settings.py # MT-7: /settings/* — branding, plan view, domain mgmt (@admin_required)
|
||||
│ │ └── ...
|
||||
│ ├── templates/
|
||||
│ │ ├── _quota_warning.html # MT-5: reusable quota exceeded banner partial
|
||||
│ │ ├── tenant_settings/ # MT-7: branding.html, plan.html, domains.html
|
||||
│ │ └── ...
|
||||
│ ├── static/
|
||||
│ │ └── uploads/ # UPLOAD_FOLDER root
|
||||
@@ -157,6 +166,14 @@ lt_janitorial_quality_control/
|
||||
│ ├── seed.py # idempotent plan seeder
|
||||
│ ├── tenant_migrate.py # MT-2 — upgrade_tenant / bootstrap_tenant / chain_head CLI
|
||||
│ ├── time_utils.py # now_eastern() mirror (no app import)
|
||||
│ ├── panel/ # MT-4 — superadmin control panel (standalone Flask app)
|
||||
│ │ ├── __init__.py # create_panel_app() factory
|
||||
│ │ ├── auth.py # /login, /logout (session-based, no Flask-Login)
|
||||
│ │ ├── decorators.py # @superadmin_required
|
||||
│ │ ├── impersonate.py # HMAC-SHA256 signed token generator/validator
|
||||
│ │ ├── tenants.py # CRUD + plan/suspend/resume/domain/migrate/provision routes
|
||||
│ │ ├── wsgi_panel.py # WSGI entry point — Gunicorn on port 8001
|
||||
│ │ └── templates/panel/ # base.html, login.html, tenants_list.html, tenant_detail.html, provision.html
|
||||
│ └── migrations/ # control Alembic chain (control{N}_…)
|
||||
│ └── versions/control0001_init.py ← HEAD
|
||||
└── ...
|
||||
@@ -187,10 +204,12 @@ lt_janitorial_quality_control/
|
||||
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
|
||||
| `PROVISION_DB_URL` | MySQL account that can `CREATE DATABASE` / `CREATE USER` / `GRANT`. e.g. `mysql+pymysql://jqc_provisioner:pw@127.0.0.1/`. |
|
||||
| `TENANT_BASE_DOMAIN` | Apex domain for subdomains, e.g. `jqc.app`. Used by provisioner to build `<slug>.jqc.app`. |
|
||||
| `MULTI_TENANT_EXEMPT_PATHS` | Comma-separated path prefixes that bypass the tenant gate (e.g. `/health`). `/static/` is always exempt. |
|
||||
| `MULTI_TENANT_EXEMPT_PATHS` | Comma-separated path prefixes that bypass the tenant gate (e.g. `/health,/api/v1/discover`). `/static/` is always exempt. |
|
||||
| `TENANT_ENGINE_POOL_SIZE` | Per-tenant engine pool size (default 5). |
|
||||
| `TENANT_ENGINE_MAX_OVERFLOW` | Per-tenant pool max overflow (default 5). |
|
||||
| `TENANT_ENGINE_POOL_RECYCLE` | Pool recycle in seconds (default 1800). |
|
||||
| `PANEL_SECRET_KEY` | Flask secret for the superadmin panel app (separate from `SECRET_KEY`). Generate: `python -c "import secrets; print(secrets.token_hex(32))"` |
|
||||
| `PANEL_IMPERSONATE_KEY` | HMAC key for impersonation tokens. Must be identical between panel and main app env. Generate same way. |
|
||||
|
||||
### Email SSL Auto-Detection
|
||||
|
||||
@@ -418,6 +437,8 @@ api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version
|
||||
| `api_notifications` | `/api/v1` | `GET /notifications`, `PATCH /notifications/mark-read` |
|
||||
| `api_stats` | `/api/v1` | `GET /stats/dashboard` — inspector-scoped KPIs with severity breakdown (Phase B) |
|
||||
| `api_comments` | `/api/v1` | `GET /issues/<id>/comments`, `POST /issues/<id>/comments` (Phase D) |
|
||||
| `api_discovery` | `/api/v1` | `GET /discover?subdomain=` or `?email=`, `GET /tenant` — MT-9 server side (public, no auth, exempt from tenant middleware) |
|
||||
| `tenant_settings` | `/settings` | MT-7: `GET/POST /branding`, `GET /plan`, `GET /domains`, `POST /domains/request`, `POST /domains/<id>/delete` |
|
||||
|
||||
---
|
||||
|
||||
@@ -666,7 +687,7 @@ limiter = Limiter(
|
||||
|
||||
## 17. Alembic Migration Chain
|
||||
|
||||
**Current HEAD:** `phase32_device_token_columns` (30 migrations total).
|
||||
**Current HEAD:** `phase33_tenant_settings` (31 migrations total).
|
||||
|
||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||
|
||||
@@ -689,7 +710,8 @@ limiter = Limiter(
|
||||
→ phase25_inspection_gps
|
||||
→ phase26_issue_vendor
|
||||
→ phase27_score_alerts
|
||||
→ ... → phase32_device_token_columns ← HEAD
|
||||
→ ... → phase32_device_token_columns
|
||||
→ phase33_tenant_settings ← HEAD
|
||||
```
|
||||
|
||||
### Fresh DB provisioning (multi-tenant)
|
||||
@@ -1152,7 +1174,7 @@ set -a; . /etc/jqc/control.env; set +a
|
||||
|
||||
---
|
||||
|
||||
## 22. Multi-Tenant Architecture (MT-0 → MT-3)
|
||||
## 22. Multi-Tenant Architecture (MT-0 → MT-7)
|
||||
|
||||
See `MULTI_TENANT_PLAN.md` for the full phased roadmap. This section summarises what is built and operational.
|
||||
|
||||
@@ -1164,6 +1186,199 @@ Shared codebase + **database-per-tenant**. One Flask/Gunicorn process serves all
|
||||
|
||||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `base.py` | `ControlBase`, `control_session()` context manager, engine from `CONTROL_DATABASE_URL` |
|
||||
| `models.py` | `Plan`, `PlanFeature`, `Tenant`, `TenantDomain`, `Superadmin`, `ProvisioningJob`, `TenantAudit` |
|
||||
| `crypto.py` | Fernet encrypt/decrypt for `tenants.db_password_enc` |
|
||||
| `seed.py` | Idempotent plan seeder (Free / Starter / Pro / Enterprise) |
|
||||
| `cli.py` | `seed`, `create-superadmin`, `list-plans` |
|
||||
| `tenant_migrate.py` | `bootstrap_tenant()`, `upgrade_tenant()`, `chain_head()`, `current_revision()` + CLI |
|
||||
| `provision.py` | `create_tenant()`, `register_tenant_zero()`, `delete_tenant()` + CLI |
|
||||
| `panel/` | Standalone Flask app for superadmin — see MT-4 below |
|
||||
|
||||
### Superadmin control panel (MT-4)
|
||||
|
||||
Separate Flask WSGI app at `admin.jqc.app`, Gunicorn port 8001, `jqc-panel.service` systemd unit.
|
||||
|
||||
**Critical Nginx ordering:** the `admin.jqc.app` server block must appear **before** the `*.jqc.app` wildcard block. If the wildcard catches `admin.jqc.app` first, requests go to port 8000 (main app) which returns 404 "Workspace not found".
|
||||
|
||||
**Impersonation:** panel generates HMAC-SHA256 signed token (`PANEL_IMPERSONATE_KEY`, TTL 60 s) → redirects to `/auth/impersonate?token=<t>` on the tenant → main app validates + sets `session['impersonating_tenant_id']` → tenancy middleware short-circuits Host resolution → "End impersonation" banner clears key.
|
||||
|
||||
### Data-plane changes (`app/`)
|
||||
|
||||
| File / Package | Change |
|
||||
|---|---|
|
||||
| `app/__init__.py` | `db = SQLAlchemy(session_options={'class_': RoutingSession})` + `init_tenancy(app)` + `inject_tenant_branding()` context processor + `hex_to_rgb` Jinja2 filter + `tenant_settings` blueprint |
|
||||
| `config.py` | `MULTI_TENANT_ENABLED` (default `false`) + pool tunables |
|
||||
| `app/tenancy/` | MT-1: resolution + routing. MT-5: `gates.py`, `quota.py`, extended `context.py` + `resolver.py` |
|
||||
| `app/models/tenant_settings.py` | MT-7: per-tenant branding settings (one row per tenant DB) |
|
||||
| `app/routes/tenant_settings.py` | MT-7: `/settings/` blueprint — branding, plan view, domain mgmt |
|
||||
| `app/templates/tenant_settings/` | MT-7: branding, plan, domains templates |
|
||||
| `app/templates/_quota_warning.html` | MT-5: reusable quota exceeded banner partial |
|
||||
| `app/templates/base.html` | MT-7: tenant logo/name in navbar, CSS colour vars injection |
|
||||
|
||||
### Plan tiers
|
||||
|
||||
| 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 | ✗ | ✓ | ✓ | ✓ |
|
||||
| Scheduled reports | ✗ | ✗ | ✓ | ✓ |
|
||||
| Branding | ✗ | ✗ | ✓ | ✓ |
|
||||
| Custom domain | ✗ | ✗ | ✓ | ✓ |
|
||||
| Subdomain | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
Quota-exceed behaviour: **soft warn** — allow submit, flag for upgrade, never reject.
|
||||
Feature gate (`@feature_required`): hard 403 — route is blocked.
|
||||
Both decorators are inert when `MULTI_TENANT_ENABLED=false`.
|
||||
|
||||
### Gate decorator stack order
|
||||
|
||||
```python
|
||||
@bp.route(...)
|
||||
@login_required # 1 — must be authenticated
|
||||
@admin_required # 2 — role check (if applicable)
|
||||
@feature_required('x') # 3 — plan feature gate (hard 403)
|
||||
@quota_soft_check('y') # 4 — quota warn (never blocks)
|
||||
def my_route(): ...
|
||||
```
|
||||
|
||||
### Tenant self-service (MT-7) — `/settings/`
|
||||
|
||||
| Tab | Route | What it does |
|
||||
|---|---|---|
|
||||
| Branding | `/settings/branding` | Company name, logo upload, primary/accent colours, support email |
|
||||
| Plan & Usage | `/settings/plan` | Read-only plan info, live quota progress bars |
|
||||
| Domains | `/settings/domains` | List domains, request custom domain (TXT/CNAME verification), delete pending requests |
|
||||
|
||||
`TenantSettings.get_or_default()` returns safe defaults when no row exists — zero migration burden for tenant-zero.
|
||||
|
||||
### Provisioner MySQL account (required grants)
|
||||
|
||||
```sql
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'jqc_provisioner'@'localhost' WITH GRANT OPTION;
|
||||
GRANT CREATE USER ON *.* TO 'jqc_provisioner'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### CLI quick-reference
|
||||
|
||||
```bash
|
||||
# Source env first — CONTROL_DATABASE_URL not in interactive shell by default
|
||||
set -a; . /etc/jqc/control.env; set +a
|
||||
|
||||
# Control schema + plans + first superadmin (run once)
|
||||
alembic -c control/migrations/alembic.ini upgrade head
|
||||
python -m control.cli seed
|
||||
python -m control.cli create-superadmin --username admin --email you@example.com
|
||||
|
||||
# Adopt existing LT database as tenant-zero (run once, no data move)
|
||||
python -m control.provision register-tenant-zero \
|
||||
--slug lts --name "LT Services" --plan enterprise \
|
||||
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
|
||||
--custom-domain jqc.ltservicesinc.com --base-domain jqc.app
|
||||
|
||||
# Provision a new tenant
|
||||
python -m control.provision create-tenant \
|
||||
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
|
||||
|
||||
# Delete / deregister a tenant
|
||||
python -m control.provision delete-tenant --slug ztest --drop-db --yes
|
||||
python -m control.provision delete-tenant --slug lts --yes # adopted DB — no --drop-db
|
||||
|
||||
# Migration status + upgrades
|
||||
python -m control.tenant_migrate heads
|
||||
python -m control.tenant_migrate current --tenant all
|
||||
python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+)
|
||||
python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
|
||||
```
|
||||
|
||||
### Enable multi-tenancy (cutover sequence)
|
||||
|
||||
```bash
|
||||
# 1. Register LT as tenant-zero (see above)
|
||||
# 2. Smoke-test routing (flag still off)
|
||||
curl -sI -H "Host: lts.jqc.app" http://127.0.0.1:8000/ | head -2
|
||||
curl -sI -H "Host: ztest.jqc.app" http://127.0.0.1:8000/ | head -2
|
||||
# 3. Wildcard DNS: *.jqc.app A <SERVER_IP>
|
||||
# 4. Nginx config — admin.jqc.app block BEFORE *.jqc.app wildcard block (see §19)
|
||||
# 5. Wildcard TLS cert (DNS-01, certbot)
|
||||
# 6. Add to /etc/jqc/control.env: MULTI_TENANT_ENABLED=true
|
||||
# 7. sudo systemctl daemon-reload && sudo systemctl restart jqc jqc-panel
|
||||
```
|
||||
|
||||
### Known MT-specific gotchas (rules 71–83)
|
||||
|
||||
| # | Rule |
|
||||
|---|---|
|
||||
| 71 | `CONTROL_FERNET_KEY` must be identical between CLI sessions and Gunicorn — use the same `/etc/jqc/control.env` |
|
||||
| 72 | `MULTI_TENANT_ENABLED` must be flipped only after tenant-zero is registered and domains verified |
|
||||
| 73 | Tenant DB passwords use `_gen_password()` — MySQL `validate_password` MEDIUM requires lower+upper+digit+special |
|
||||
| 74 | `bootstrap_tenant()` for fresh DBs; `upgrade_tenant()` for incremental (phase33+) |
|
||||
| 75 | `delete_tenant()` + `_add_domains()` are retry-safe |
|
||||
| 76 | `register-tenant-zero` never bootstraps and never drops the DB |
|
||||
| 77 | `CONTROL_DATABASE_URL` not in interactive shell — always `set -a; . /etc/jqc/control.env; set +a` before CLI |
|
||||
| 78 | Nginx `admin.jqc.app` must be a separate `server {}` block before `*.jqc.app` — exact name wins only in its own block |
|
||||
| 79 | `jqc.app` apex has no registered tenant — Nginx redirects to `lts.jqc.app` at the server level |
|
||||
| 80 | `@feature_required` before `@quota_soft_check` in decorator stack — no point counting if feature is blocked |
|
||||
| 81 | `TenantSettings.get_or_default()` returns a transient (non-persisted) default instance — `db.session.add(row)` required before first save |
|
||||
| 82 | `inject_tenant_branding()` context processor wraps the DB call in try/except — branding failure must never break page rendering |
|
||||
| 83 | `hex_to_rgb` Jinja2 filter required for `--bs-primary-rgb` CSS var — register in `app/__init__.py` |
|
||||
|
||||
---
|
||||
|
||||
## 23. Coding Rules for AI Assistants
|
||||
|
||||
These rules apply to every change made to this codebase, without exception.
|
||||
|
||||
### Before Writing Any Code
|
||||
|
||||
**Rule 1 — Read the actual file on disk first.**
|
||||
Use the view or read tool on every file that will be modified. Never rely on output from a previous turn — a prior edit invalidates earlier view output. Always re-read before a second edit to the same file.
|
||||
|
||||
**Rule 2 — Trace the full request path.**
|
||||
For any bug, follow the request from the browser through: decorator → form validation → route logic → DB write → template render. Identify the exact layer where the failure occurs before proposing a fix.
|
||||
|
||||
**Rule 3 — Find the root cause. No assumptions.**
|
||||
"It might be X" is not sufficient. Confirm X by reading the relevant code. State the root cause explicitly in the response.
|
||||
|
||||
**Rule 4 — Check both layers of enforcement.**
|
||||
Feature gates and quota checks must be applied in BOTH web routes AND `/api/v1` API endpoints. A web-only check is bypassable by the iPad app.
|
||||
|
||||
### Making Changes
|
||||
|
||||
**Rule 5 — Make the smallest possible change.**
|
||||
Do not rewrite surrounding code. Do not rename variables, restructure functions, or reformat blocks unless explicitly requested.
|
||||
|
||||
**Rule 6 — Never remove functionality** that was not explicitly asked to be removed.
|
||||
|
||||
**Rule 7 — Preserve all route names, function names, and variable names** unless explicitly directed otherwise.
|
||||
|
||||
**Rule 8 — Keep all log handlers.**
|
||||
|
||||
**Rule 9 — One atomic commit per logical transaction.**
|
||||
|
||||
### Before Providing the Output
|
||||
|
||||
**Rule 10 — Verify the fix in the file on disk.**
|
||||
After applying a patch, read the changed section back and confirm the intended change is present, no surrounding code was accidentally removed, and import statements are consistent.
|
||||
|
||||
**Rule 11 — Check for introduced bugs.**
|
||||
Ask: Does this change break any other code path that uses the modified function, field, or query?
|
||||
|
||||
**Rule 12 — State the root cause explicitly** in the deployment instructions. "Changed X to Y" is not enough — explain *why* the old code failed and *how* the fix resolves it.
|
||||
|
||||
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
||||
|
||||
**Rule 14 — Migrations are required for any schema change.**
|
||||
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase33_tenant_settings`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
||||
|
||||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `base.py` | `ControlBase`, `control_session()` context manager, engine from `CONTROL_DATABASE_URL` |
|
||||
|
||||
+158
-49
@@ -2,7 +2,7 @@
|
||||
|
||||
> **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.
|
||||
> **Status:** MT-0 through MT-7 complete and deployed. MT-8 flag-gated (future). MT-9 (iOS) pending.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,7 +74,7 @@ tenant_audit -- superadmin actions (separate from per-ten
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +82,7 @@ Submission quotas (inspections/issues per month) are enforced by **live-counting
|
||||
|
||||
**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):
|
||||
**Routing without rewriting every `db.session` call** — use a routing Session so the entire existing codebase keeps using `db.session` unchanged:
|
||||
|
||||
```python
|
||||
# app/tenancy/routing.py
|
||||
@@ -91,31 +91,22 @@ 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.
|
||||
> a `__control_plane__` flag. 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, small pools + `pool_recycle`.
|
||||
- **Net code change to existing models/routes: zero.**
|
||||
|
||||
- `_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`.
|
||||
**MT-5 addition:** Plan limits and feature flags are loaded into `TenantContext` by the resolver in the same control-DB session — zero extra queries per request. `g.tenant.allow_mobile_api`, `g.tenant.max_users`, etc. are available everywhere.
|
||||
|
||||
---
|
||||
|
||||
@@ -123,16 +114,20 @@ g.tenant_engine = _engine_cache.get_or_create(tenant) # create_engine(tenant.d
|
||||
|
||||
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`.
|
||||
1. **Tenant schema** — existing chain (HEAD: **`phase33_tenant_settings`**). Runs per-tenant DB. New tenant features continue as `phase34_…` per existing naming.
|
||||
2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0001_init`.
|
||||
|
||||
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.
|
||||
**CLI (always source env first):**
|
||||
```bash
|
||||
set -a; . /etc/jqc/control.env; set +a
|
||||
python -m control.tenant_migrate upgrade --tenant all
|
||||
```
|
||||
|
||||
Deploy ordering rule carries over (CLAUDE.md Rule 23): run tenant migrations **before** shipping app code that references new columns.
|
||||
Deploy ordering rule: run tenant migrations **before** shipping app code that references new columns.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan-tier matrix (starting point — tune in §9)
|
||||
## 6. Plan-tier matrix
|
||||
|
||||
| Axis | Free | Starter | Pro | Enterprise |
|
||||
|---|---|---|---|---|
|
||||
@@ -146,37 +141,88 @@ Deploy ordering rule carries over (CLAUDE.md Rule 23): run tenant migrations **b
|
||||
| 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).
|
||||
Enforcement: `@feature_required('mobile_api')` hard-blocks (403) disabled features. `@quota_soft_check('inspections')` sets `g.quota_warning` but **never rejects** — over-limit submits succeed, UI shows upgrade prompt. Both decorators are **inert** when `MULTI_TENANT_ENABLED=false`.
|
||||
|
||||
Feature + quota checks live in **both** web routes and `/api/v1` endpoints (iPad submits via API — web-only check is bypassable).
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased roadmap
|
||||
|
||||
Each phase additive; existing tenant-zero traffic keeps working throughout.
|
||||
**MT-0 — Control-plane scaffold. ✅ DONE.**
|
||||
Self-contained `control/` package: own `ControlBase` + engine + session, own Alembic chain (`control0001_init`), 7 models, Fernet-encrypted tenant creds, idempotent plan seeder, operator CLI. Zero imports into `app/`.
|
||||
|
||||
**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. ✅ DONE.**
|
||||
`app/tenancy/` package: `RoutingSession`, `resolve_tenant()`, `engine_cache`, `TenantContext`, `init_tenancy()` before_request hook + branded 404. Gated behind `MULTI_TENANT_ENABLED=false` — fully inert until flipped.
|
||||
|
||||
**MT-1 — Tenant resolution + routing. ✅ DONE.** `app/tenancy/` package: `routing.py` (`RoutingSession` subclassing the Flask-SQLAlchemy session), `resolver.py` (Host→tenant via control plane, lazy import), `engine_cache.py` (per-tenant engines), `context.py` (`TenantContext`), `middleware.py` (`init_tenancy` before_request hook + branded unknown-host 404). Edits: `db` init in `app/__init__.py` (+`init_tenancy(app)` call) and `MULTI_TENANT_ENABLED` + pool flags in `config.py`. Gated behind `MULTI_TENANT_ENABLED` (default False) — fully inert until flipped.
|
||||
**MT-2 — Per-tenant migration runner. ✅ DONE.**
|
||||
`migrations_tenant/env.py` + `control/tenant_migrate.py` (`upgrade_tenant`, `bootstrap_tenant`, `chain_head`, CLI). Guarded squashed baseline `0003_add_user_active` restores chain root. Fresh DBs use `bootstrap_tenant` (baseline → stamp head, skips unguarded phase migrations). Incremental upgrades use `upgrade_tenant` (phase33+).
|
||||
|
||||
**MT-2 — Per-tenant migration runner. ✅ DONE (with blocker found).** Standalone `migrations_tenant/env.py` (reads URL from config, reuses `migrations/versions` via `version_locations`, no Flask) + `control/tenant_migrate.py` (`upgrade_tenant()`, `current_revision()`, `chain_head()`, CLI: `python -m control.tenant_migrate upgrade|current|heads --tenant <id|slug|all>`). Records `tenants.alembic_head` + a `ProvisioningJob('migrate')` per run. Existing `migrations/env.py` untouched (normal `flask db` still works).
|
||||
> **⚠ Blocker found by MT-2 — RESOLVED.** Chain had no base; 14/30 phase migrations unguarded. Fixed by guarded squashed baseline. Fresh DB provisioning must use `bootstrap_tenant`, never raw `flask db upgrade`.
|
||||
|
||||
> **⚠ Blocker found by MT-2 — RESOLVED.** The live `migrations/versions` chain had **no base**: `phase1_projects_roles.down_revision = '0003_add_user_active'` pointed at a missing revision, and 14/30 phase migrations are not idempotent. **Fix:** a guarded squashed baseline `migrations/versions/0003_add_user_active.py` (`down_revision = None`) that recreates the full current schema (generated from the models, 25 tables, INFORMATION_SCHEMA-guarded) — restoring the chain root. Fresh tenants are built via `bootstrap_tenant()` = upgrade to the baseline **then `stamp head`**, so the non-idempotent phase migrations are never replayed. Existing DBs (LT) are at a later head, so the baseline is treated as applied ancestry and never runs. Ongoing migrations (phase33+, which MUST be guarded) apply incrementally to all tenants via `upgrade_tenant()`.
|
||||
>
|
||||
> **Operational note:** any *fresh* database (including a new dev DB) must use the bootstrap flow, not a naive `flask db upgrade` from empty, because the historical phase replay still hits the unguarded migrations. Cross-check the baseline against `mysqldump --no-data` of LT and test `bootstrap` on a scratch MySQL before going live.
|
||||
**MT-3 — Provisioning service. ✅ DONE.**
|
||||
`control/provision.py`: `create_tenant()` (DB + user + schema + admin seed + domain), `register_tenant_zero()` (adopt LT DB in place), `delete_tenant()`. Fernet-encrypted creds, `provisioning_jobs` logged. CLI: `python -m control.provision`.
|
||||
|
||||
**MT-3 — Provisioning service. ✅ DONE.** `control/provision.py`: `create_tenant()` (admin account from `PROVISION_DB_URL` creates per-tenant DB + least-privilege user + grant → `bootstrap_tenant` builds schema + stamps head → `seed_admin` inserts the first admin with a set-password token → registers subdomain + optional custom domain → status active; best-effort rollback drops the DB/user + tenant row on failure). `register_tenant_zero()` adopts an existing DB in place (no DB/user creation, no bootstrap; reads its current head, maps `lts.<base>` + LT's live domain as a verified custom domain — zero data movement). `delete_tenant()` removes control records, optional guarded DB drop (refuses non-provisioner-named DBs, protecting tenant-zero). CLI: `python -m control.provision create-tenant|register-tenant-zero|delete-tenant`. Admin setup link uses `/customers/set-password/<token>` (the route that sets `password_set=True`). Creds Fernet-encrypted; `provisioning_jobs`-logged.
|
||||
**MT-4 — Superadmin control panel. ✅ DONE.**
|
||||
Standalone Flask app at `admin.jqc.app` (`control/panel/`). WSGI entry: `control/panel/wsgi_panel.py` → Gunicorn on port 8001 (`jqc-panel.service`). Separate Nginx server block — **must appear before the `*.jqc.app` wildcard block** or Nginx routes `admin.jqc.app` to port 8000 (main app).
|
||||
|
||||
**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.
|
||||
Routes: tenant list/detail, plan change, suspend/resume, domain CRUD (add/verify/delete), migration status + upgrade trigger, provision new tenant, impersonation.
|
||||
|
||||
**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).
|
||||
**Impersonation flow:**
|
||||
1. Panel generates HMAC-SHA256 signed token (`PANEL_IMPERSONATE_KEY`, TTL 60 s).
|
||||
2. Redirects to `https://<tenant-primary-domain>/auth/impersonate?token=<t>`.
|
||||
3. Main app validates token, sets `session['impersonating_tenant_id']`.
|
||||
4. Tenancy middleware reads this key and short-circuits Host resolution.
|
||||
5. "End impersonation" banner clears key, redirects back to `admin.jqc.app`.
|
||||
|
||||
**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.
|
||||
Required env vars (add to `/etc/jqc/control.env`):
|
||||
```
|
||||
PANEL_SECRET_KEY=<hex32>
|
||||
PANEL_IMPERSONATE_KEY=<hex32>
|
||||
```
|
||||
|
||||
**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-5 — Plans + quota/feature gating. ✅ DONE.**
|
||||
`app/tenancy/quota.py` — live counters (inspections/issues this month, total users/facilities) against tenant DB.
|
||||
`app/tenancy/gates.py` — `@feature_required(key)` (hard 403) and `@quota_soft_check(axis)` (sets `g.quota_warning`, never rejects).
|
||||
`app/tenancy/context.py` — `TenantContext` extended with 9 plan fields (all default to unlimited/True → single-tenant unchanged).
|
||||
`app/tenancy/resolver.py` — loads `plan` in the same control session, populates `TenantContext` plan fields.
|
||||
`app/templates/_quota_warning.html` — reusable upgrade-prompt banner partial.
|
||||
|
||||
**MT-8 — Billing (future).** Stripe per-plan; lifecycle (trial/suspend/dunning). Flag-gated.
|
||||
Gated routes:
|
||||
|
||||
**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.
|
||||
| Route | Gate |
|
||||
|---|---|
|
||||
| `inspections.start()` | `@quota_soft_check('inspections')` |
|
||||
| `issues.create()` | `@quota_soft_check('issues')` |
|
||||
| `auth.create_user()` | `@quota_soft_check('users')` |
|
||||
| `facilities.create_facility()` | `@quota_soft_check('facilities')` |
|
||||
| `scheduled_reports.index()` + `create()` | `@feature_required('scheduled_reports')` |
|
||||
| `api.create_inspection()` | `@feature_required('mobile_api')` + `@quota_soft_check('inspections')` |
|
||||
| `api.create_issue()` | `@feature_required('mobile_api')` + `@quota_soft_check('issues')` |
|
||||
|
||||
Decorator stack order: `@login_required` → `@role_required` → `@feature_required` → `@quota_soft_check`.
|
||||
|
||||
**MT-6 — Custom domain + TLS. ⚙ INFRASTRUCTURE ONLY — no Python deliverables.**
|
||||
Wildcard cert `*.jqc.app` via DNS-01 challenge (certbot + DNS plugin). Custom-domain TLS via Caddy on-demand TLS. Domain-verification flow (TXT/CNAME) already in MT-7 self-service UI; superadmin marks `verified=True` in the control panel after DNS check. When Caddy is deployed, update Nginx to pass custom domains to Caddy rather than directly to port 8000.
|
||||
|
||||
**MT-7 — Tenant self-service. ✅ DONE.**
|
||||
`app/models/tenant_settings.py` — `TenantSettings` model, one row per tenant DB, `get_or_default()` returns transient defaults when no row exists (zero migration burden for existing tenants).
|
||||
`app/routes/tenant_settings.py` — blueprint at `/settings/`, `@admin_required`.
|
||||
`app/templates/tenant_settings/` — `branding.html`, `plan.html`, `domains.html`.
|
||||
Migration: `phase33_tenant_settings` (INFORMATION_SCHEMA guarded, safe to re-run).
|
||||
|
||||
Branding injection: `inject_tenant_branding()` context processor in `app/__init__.py` pushes `tenant_branding` into every template. `base.html` patches: navbar brand reads logo/name from `tenant_branding`; CSS vars `--bs-primary`, `--jqc-accent` injected via inline `<style>` from `tenant_branding.primary_color` / `accent_color`. Jinja2 filter `hex_to_rgb` registered for Bootstrap RGB var.
|
||||
|
||||
Self-service features:
|
||||
- **Branding**: company name, logo upload (magic-byte validated), primary/accent colours, support email. Gated by `allow_branding` — shows warning but doesn't block form (soft, consistent with quota philosophy).
|
||||
- **Plan & Usage**: read-only plan info + live quota progress bars (reads from control DB + tenant DB live counts). "Request upgrade" mailto link.
|
||||
- **Domains**: lists all `TenantDomain` rows. Admin can request a custom domain (creates unverified row, shows TXT/CNAME DNS instructions). Delete unverified custom domains. Superadmin does final verification via control panel.
|
||||
|
||||
**MT-8 — Billing. 🔲 FUTURE — flag-gated.**
|
||||
Stripe per-plan subscription. Lifecycle: trial → active → past_due → suspended → cancelled. Dunning emails. All behind a `BILLING_ENABLED` feature flag. No code written.
|
||||
|
||||
**MT-9 — iOS multi-tenant. 🔲 PENDING.**
|
||||
Server side: `GET /api/v1/discover?subdomain=acme` and `GET /api/v1/tenant` public endpoints — exempt from tenant middleware via `MULTI_TENANT_EXEMPT_PATHS`. iOS side: pending (web-first priority).
|
||||
|
||||
---
|
||||
|
||||
@@ -185,23 +231,86 @@ Each phase additive; existing tenant-zero traffic keeps working throughout.
|
||||
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.
|
||||
2. Insert `tenant_domains`: primary subdomain `lts.jqc.app`; plus `jqc.ltservicesinc.com` (kind=custom, verified) so existing web users keep working.
|
||||
3. Point wildcard DNS / Nginx 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.
|
||||
Result: existing users notice nothing; LT is now "tenant 1".
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisions resolved — MT-0 unblocked
|
||||
## 9. Decisions resolved
|
||||
|
||||
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.
|
||||
1. **Quota-exceed** → soft warn (allow + flag upgrade). Never reject.
|
||||
2. **Tenant DB credentials** → per-tenant MySQL user + password; creds encrypted at rest.
|
||||
3. **Tenant-zero** → register existing live LT DB in place, no data move.
|
||||
4. **Impersonation** → HMAC-signed token (60 s TTL), panel→tenant redirect, session key short-circuits Host resolution.
|
||||
5. **Branding gate** → soft (shows warning, form still usable) — consistent with quota philosophy.
|
||||
|
||||
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.
|
||||
## 10. Nginx — block ordering (critical)
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/jqc
|
||||
|
||||
# 1. PANEL — exact match, must be FIRST
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.jqc.app;
|
||||
location / { proxy_pass http://127.0.0.1:8001; ... }
|
||||
}
|
||||
|
||||
# 2. APEX redirect — jqc.app has no registered tenant
|
||||
server {
|
||||
listen 80;
|
||||
server_name jqc.app;
|
||||
return 301 http://lts.jqc.app$request_uri;
|
||||
}
|
||||
|
||||
# 3. WILDCARD — all tenant subdomains
|
||||
server {
|
||||
listen 80;
|
||||
server_name *.jqc.app;
|
||||
location / { proxy_pass http://127.0.0.1:8000; ... }
|
||||
}
|
||||
```
|
||||
|
||||
If `admin.jqc.app` is in the same block as `*.jqc.app`, Nginx routes it to port 8000 (main app), which returns "Workspace not found" because `admin.jqc.app` is not a registered tenant domain.
|
||||
|
||||
---
|
||||
|
||||
## 11. CLI quick-reference
|
||||
|
||||
```bash
|
||||
# Source env first — CONTROL_DATABASE_URL not in interactive shell by default
|
||||
set -a; . /etc/jqc/control.env; set +a
|
||||
|
||||
# Control schema + plans + first superadmin (run once)
|
||||
alembic -c control/migrations/alembic.ini upgrade head
|
||||
python -m control.cli seed
|
||||
python -m control.cli create-superadmin --username admin --email you@example.com
|
||||
|
||||
# Adopt existing LT database as tenant-zero (run once, no data move)
|
||||
python -m control.provision register-tenant-zero \
|
||||
--slug lts --name "LT Services" --plan enterprise \
|
||||
--db-host 127.0.0.1 --db-name <LT_DB> --db-user <LT_USER> --db-password '<pw>' \
|
||||
--custom-domain jqc.ltservicesinc.com --base-domain jqc.app
|
||||
|
||||
# Provision a new tenant
|
||||
python -m control.provision create-tenant \
|
||||
--slug acme --name "Acme Corp" --plan pro --admin-email ops@acme.com
|
||||
|
||||
# Delete / deregister a tenant
|
||||
python -m control.provision delete-tenant --slug ztest --drop-db --yes
|
||||
python -m control.provision delete-tenant --slug lts --yes # no --drop-db for adopted DB
|
||||
|
||||
# Migration status + upgrades
|
||||
python -m control.tenant_migrate heads
|
||||
python -m control.tenant_migrate current --tenant all
|
||||
python -m control.tenant_migrate upgrade --tenant all # incremental (phase33+)
|
||||
python -m control.tenant_migrate bootstrap --tenant acme # fresh DB only
|
||||
|
||||
# Superadmin panel
|
||||
sudo systemctl status jqc-panel
|
||||
sudo systemctl restart jqc-panel
|
||||
```
|
||||
+58
-1
@@ -518,4 +518,61 @@ def reset_password(token):
|
||||
flash('Your password has been reset successfully. Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/reset_password.html', form=form, user=user)
|
||||
return render_template('auth/reset_password.html', form=form, user=user)
|
||||
|
||||
|
||||
# ── MT-4: Superadmin impersonation ────────────────────────────────────────────
|
||||
|
||||
@bp.route('/impersonate')
|
||||
def impersonate_entry():
|
||||
"""
|
||||
Validate a superadmin impersonation token and bind the session to a tenant.
|
||||
Called by the control panel redirect:
|
||||
GET /auth/impersonate?token=<signed_token>
|
||||
Sets session['impersonating_tenant_id'] which the tenancy middleware reads
|
||||
to short-circuit normal Host resolution for the duration of the session.
|
||||
This route is CSRF-exempt by nature — the HMAC token already provides auth.
|
||||
"""
|
||||
from flask import session as flask_session
|
||||
token = request.args.get('token', '')
|
||||
if not token:
|
||||
flash('Missing impersonation token.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
try:
|
||||
from control.panel.impersonate import validate_token
|
||||
payload = validate_token(token)
|
||||
except ValueError as e:
|
||||
logger.warning('AUTH | impersonate_invalid | reason=%s', e)
|
||||
flash('Invalid or expired impersonation link.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
tenant_id = payload.get('tid')
|
||||
superadmin_id = payload.get('said')
|
||||
|
||||
flask_session['impersonating_tenant_id'] = tenant_id
|
||||
flask_session['impersonating_superadmin_id'] = superadmin_id
|
||||
|
||||
logger.info('AUTH | impersonate_start | sa=%s tenant=%s', superadmin_id, tenant_id)
|
||||
|
||||
import os
|
||||
panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}"
|
||||
flash(
|
||||
f'Impersonating tenant #{tenant_id} as superadmin. '
|
||||
f'<a href="{url_for(\"auth.impersonate_end\")}" class="alert-link">'
|
||||
f'End impersonation</a>',
|
||||
'warning',
|
||||
)
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
|
||||
@bp.route('/impersonate/end')
|
||||
def impersonate_end():
|
||||
"""Clear impersonation session keys and redirect back to the control panel."""
|
||||
from flask import session as flask_session
|
||||
import os
|
||||
flask_session.pop('impersonating_tenant_id', None)
|
||||
flask_session.pop('impersonating_superadmin_id', None)
|
||||
panel_url = f"https://admin.{os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')}"
|
||||
logger.info('AUTH | impersonate_end | redirecting to panel')
|
||||
return redirect(panel_url)
|
||||
@@ -71,6 +71,25 @@ def _save_logo(file_obj):
|
||||
return f'uploads/logos/{filename}'
|
||||
|
||||
|
||||
def _delete_logo(logo_url):
|
||||
"""Remove a logo file from disk. Silently ignores missing files.
|
||||
Safety guard: only deletes files inside the uploads/logos/ subfolder."""
|
||||
try:
|
||||
# Reconstruct the absolute path from the relative URL stored in the DB
|
||||
# logo_url is like "uploads/logos/<filename>"
|
||||
rel = logo_url.replace('uploads/', '', 1) # → "logos/<filename>"
|
||||
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], rel)
|
||||
logos_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'logos')
|
||||
abs_path = os.path.abspath(full_path)
|
||||
abs_logos = os.path.abspath(logos_dir)
|
||||
# Path traversal guard — only remove files inside logos/
|
||||
if abs_path.startswith(abs_logos + os.sep) and os.path.isfile(abs_path):
|
||||
os.remove(abs_path)
|
||||
logger.info('SETTINGS | logo_deleted | path=%s', abs_path)
|
||||
except Exception as exc:
|
||||
logger.warning('SETTINGS | logo_delete_failed | url=%s err=%s', logo_url, exc)
|
||||
|
||||
|
||||
def _mt_enabled():
|
||||
return current_app.config.get('MULTI_TENANT_ENABLED', False)
|
||||
|
||||
@@ -135,8 +154,13 @@ def branding():
|
||||
row.accent_color = accent_color or '#16a34a'
|
||||
row.support_email = support_email
|
||||
if new_logo_url:
|
||||
# Delete old logo file from disk before replacing
|
||||
if row.logo_url:
|
||||
_delete_logo(row.logo_url)
|
||||
row.logo_url = new_logo_url
|
||||
elif request.form.get('clear_logo'):
|
||||
if row.logo_url:
|
||||
_delete_logo(row.logo_url)
|
||||
row.logo_url = None
|
||||
row.updated_at = now_eastern()
|
||||
row.updated_by = current_user.id
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_quota_warning.html' %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_quota_warning.html' %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card shadow-sm">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New Inspection{% endblock %}
|
||||
{% block content %}
|
||||
{% include '_quota_warning.html' %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include '_quota_warning.html' %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
|
||||
@@ -7,6 +7,8 @@ Wires tenant resolution into the Flask request lifecycle.
|
||||
* always clears g.tenant / g.tenant_engine (so downstream code can rely on them)
|
||||
* does NOTHING further when MULTI_TENANT_ENABLED is False → today's behaviour
|
||||
* bypasses static + configured exempt paths (health checks)
|
||||
* MT-4: checks session['impersonating_tenant_id'] and short-circuits Host
|
||||
resolution when a superadmin is impersonating a tenant
|
||||
* otherwise resolves the Host header to a tenant and selects its engine
|
||||
* returns a 404 page for an unknown / unverified / suspended host
|
||||
|
||||
@@ -15,7 +17,7 @@ so each request rebinds via RoutingSession.get_bind against the fresh
|
||||
g.tenant_engine — no teardown handler is needed here.
|
||||
"""
|
||||
|
||||
from flask import g, request, current_app, Response
|
||||
from flask import g, request, current_app, Response, session
|
||||
|
||||
from app.tenancy.resolver import resolve_tenant
|
||||
from app.tenancy.engine_cache import get_tenant_engine
|
||||
@@ -60,6 +62,43 @@ def init_tenancy(app):
|
||||
if _is_exempt(request.path):
|
||||
return
|
||||
|
||||
# ── MT-4: Superadmin impersonation override ───────────────────────
|
||||
# When the control panel places a signed token in the session via
|
||||
# /auth/impersonate, bypass Host resolution and bind directly to that
|
||||
# tenant's DB. The session is server-signed so this is safe.
|
||||
imp_id = session.get('impersonating_tenant_id')
|
||||
if imp_id is not None:
|
||||
from control.base import control_session
|
||||
from control.models import Tenant
|
||||
from app.tenancy.context import TenantContext
|
||||
with control_session() as s:
|
||||
t = s.get(Tenant, imp_id)
|
||||
if t and t.status == 'active':
|
||||
plan = t.plan
|
||||
ctx = TenantContext(
|
||||
id=t.id,
|
||||
slug=t.slug,
|
||||
name=t.name,
|
||||
plan_id=t.plan_id,
|
||||
db_uri=t.db_uri,
|
||||
plan_code=plan.code if plan else None,
|
||||
max_users=plan.max_users if plan else None,
|
||||
max_facilities=plan.max_facilities if plan else None,
|
||||
max_inspections_month=plan.max_inspections_month if plan else None,
|
||||
max_issues_month=plan.max_issues_month if plan else None,
|
||||
allow_mobile_api=plan.allow_mobile_api if plan else True,
|
||||
allow_scheduled_reports=plan.allow_scheduled_reports if plan else True,
|
||||
allow_branding=plan.allow_branding if plan else True,
|
||||
allow_custom_domain=plan.allow_custom_domain if plan else True,
|
||||
)
|
||||
g.tenant = ctx
|
||||
g.tenant_engine = get_tenant_engine(ctx)
|
||||
return # skip normal Host resolution
|
||||
# Impersonation target invalid or suspended — clear and fall through
|
||||
session.pop('impersonating_tenant_id', None)
|
||||
session.pop('impersonating_superadmin_id', None)
|
||||
|
||||
# ── Normal Host → tenant resolution ──────────────────────────────
|
||||
host = (request.host or '').split(':')[0].strip().lower()
|
||||
tenant = resolve_tenant(host)
|
||||
if tenant is None:
|
||||
|
||||
@@ -20,13 +20,17 @@ Env vars required (same /etc/jqc/control.env sourced by the main app):
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
from .auth import bp as auth_bp
|
||||
from .tenants import bp as tenants_bp
|
||||
|
||||
csrf = CSRFProtect()
|
||||
|
||||
|
||||
def create_panel_app():
|
||||
app = Flask(__name__, template_folder='templates')
|
||||
@@ -39,6 +43,20 @@ def create_panel_app():
|
||||
)
|
||||
app.secret_key = secret
|
||||
|
||||
# ── Session lifetime ──────────────────────────────────────────────────
|
||||
# Superadmin sessions expire after 4 hours of inactivity.
|
||||
# Flask default when permanent=True is 31 days — too long for privileged access.
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=4)
|
||||
|
||||
# ── CSRF protection ───────────────────────────────────────────────────
|
||||
# Protects all POST routes in the panel against cross-site request forgery.
|
||||
# Templates include: <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
csrf.init_app(app)
|
||||
|
||||
# Make csrf_token() available in all panel templates
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
app.jinja_env.globals['csrf_token'] = generate_csrf
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
log_level = logging.INFO
|
||||
formatter = logging.Formatter(
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" placeholder="superadmin" required autofocus>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('tenants.provision_tenant') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold" style="font-size:.85rem;">Slug <span class="text-danger">*</span></label>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.migrate_tenant', tenant_id=tenant.id) }}"
|
||||
onsubmit="return confirm('Run upgrade_tenant for {{ tenant.slug }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-up-circle me-1"></i> Upgrade to Head
|
||||
</button>
|
||||
@@ -110,6 +111,7 @@
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.verify_domain', tenant_id=tenant.id, domain_id=d.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-xs btn-outline-success" style="font-size:.72rem; padding:.1rem .4rem;">
|
||||
Verify
|
||||
</button>
|
||||
@@ -124,6 +126,7 @@
|
||||
action="{{ url_for('tenants.delete_domain', tenant_id=tenant.id, domain_id=d.id) }}"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Delete domain {{ d.domain }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-xs btn-outline-danger" style="font-size:.72rem; padding:.1rem .4rem;">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@@ -141,6 +144,7 @@
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.add_domain', tenant_id=tenant.id) }}"
|
||||
class="row g-2 align-items-end">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="domain" class="form-control form-control-sm"
|
||||
placeholder="custom.example.com" style="width:220px;">
|
||||
@@ -208,6 +212,7 @@
|
||||
<div class="card-body py-2">
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.change_plan', tenant_id=tenant.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<select name="plan_id" class="form-select form-select-sm">
|
||||
{% for p in plans %}
|
||||
@@ -252,6 +257,7 @@
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.resume_tenant', tenant_id=tenant.id) }}"
|
||||
onsubmit="return confirm('Resume tenant {{ tenant.slug }}?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-outline-success w-100">
|
||||
<i class="bi bi-play-circle me-1"></i> Resume
|
||||
</button>
|
||||
@@ -292,6 +298,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('tenants.suspend_tenant', tenant_id=tenant.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-body">
|
||||
<label class="form-label" style="font-size:.85rem;">Reason (optional)</label>
|
||||
<input type="text" name="reason" class="form-control form-control-sm"
|
||||
|
||||
Reference in New Issue
Block a user