Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781df9d109 | ||
|
|
af4dae50e4 | ||
|
|
235d391d4f | ||
|
|
d353fabf11 | ||
|
|
08edde726b | ||
|
|
cce404b6a3 | ||
|
|
899cd275f9 | ||
|
|
cb7de68b3c | ||
|
|
28d3bd1525 | ||
|
|
6cd665e95a | ||
|
|
b923b3ed15 | ||
|
|
a36d91fe90 | ||
|
|
7ab9cc3bee | ||
|
|
d68145effa | ||
|
|
cb81429595 | ||
|
|
276daa7fc7 | ||
|
|
ee62cb4963 | ||
|
|
abf0219587 | ||
|
|
f3deee5479 | ||
|
|
53d6e92399 | ||
|
|
df83ec42d0 | ||
|
|
41c91554f2 | ||
|
|
bab1c3d718 | ||
|
|
f200604ca5 | ||
|
|
9fff9fa9c1 | ||
|
|
a091c11214 | ||
|
|
2689adfb7a | ||
|
|
dc711fe82c | ||
|
|
5fc241e59c | ||
|
|
83def84f15 | ||
|
|
e16f7c30af | ||
|
|
512c39e4f4 | ||
|
|
c3017b81da | ||
|
|
4c9fa8bc45 | ||
|
|
9b41301b09 | ||
|
|
571894b890 | ||
|
|
2678e9548a | ||
|
|
a1dc191a06 | ||
|
|
7c326dea94 | ||
|
|
8a8ecc3d2e | ||
|
|
249e03aa95 | ||
|
|
0f0fcd6de3 | ||
|
|
a9b189b463 | ||
|
|
bcbd0ccc5a | ||
|
|
6e9acc13eb | ||
|
|
bd4f0f1e1d | ||
|
|
1d832b9eda | ||
|
|
e5895d058f | ||
|
|
d6981d7123 | ||
|
|
56408cadbe | ||
|
|
935bbc18f6 |
+8
-12
@@ -1,13 +1,13 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=11001
|
||||
PORT=10001
|
||||
APP_ENV=development
|
||||
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=https://fulfilment-dev.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://fulfilment-dev.maskantech.in
|
||||
FRONTEND_URL=https://saas-dev.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://saas-dev.maskantech.in
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -20,13 +20,12 @@ SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_development
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6382
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# Every setting the application reads, with the required ones marked.
|
||||
#
|
||||
# Twenty-two of these have no default, so a deployment missing one fails at
|
||||
# startup — one at a time, in whatever order pydantic happens to check. This file
|
||||
# exists so that is discoverable by reading rather than by crashing.
|
||||
#
|
||||
# Copy to `.env.local` (development) or set them in the environment (production).
|
||||
# Nothing here is a real credential.
|
||||
#
|
||||
# A note on the committed `.env.*` files: they hold live values and are in the
|
||||
# working tree. Rotating those secrets and purging them from git history is still
|
||||
# outstanding, and every value in a committed file should be treated as known.
|
||||
|
||||
|
||||
# ── Application ───────────────────────────────────────────────── REQUIRED ────
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
APP_ENV=local
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
# Where the browser reaches the console. Used in emails and CORS.
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
|
||||
# ── Secrets ───────────────────────────────────────────────────── REQUIRED ────
|
||||
# Three separate secrets on purpose: a token minted for one purpose must not
|
||||
# verify as another. Generate with `python -c "import secrets;
|
||||
# print(secrets.token_urlsafe(64))"` and never reuse one across environments.
|
||||
SECRET_KEY=change-me-a-long-random-string
|
||||
ACCESS_TOKEN_SECRET=change-me-a-different-long-random-string
|
||||
REFRESH_TOKEN_SECRET=change-me-a-third-long-random-string
|
||||
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────── REQUIRED ────
|
||||
# In production this should be the unprivileged role created by
|
||||
# `scripts/create_app_role.py`, NOT the owner. Row-level security does not apply
|
||||
# to a role that can bypass it, and nothing in the schema shows the difference —
|
||||
# `check_rls_enforced()` reports it.
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/saas
|
||||
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────────── REQUIRED ────
|
||||
# Rate limiting, SSO grant storage, the token blacklist and the replay-nonce
|
||||
# store. The application starts without it; those features degrade rather than
|
||||
# fail, except v2 replay controls, which refuse rather than wave requests
|
||||
# through.
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_ENABLED=True
|
||||
|
||||
|
||||
# ── Email ─────────────────────────────────────────────────────── REQUIRED ────
|
||||
# Password reset codes and subscription notices. A workspace whose subscription
|
||||
# lapses hears about it here or not at all.
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASSWORD=change-me
|
||||
SMTP_SECURE=False
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
|
||||
# ── First administrator ───────────────────────────────────────── REQUIRED ────
|
||||
# Used by `scripts/seed_superadmin.py`. Change the password immediately after
|
||||
# the first sign-in; it is in a file on disk.
|
||||
SUPER_ADMIN_EMAIL=admin@example.com
|
||||
SUPER_ADMIN_PASSWORD=change-me-Str0ng!
|
||||
|
||||
|
||||
# ── Payments ──────────────────────────────────────────────────── REQUIRED ────
|
||||
PAYPAL_CLIENT_ID=change-me
|
||||
PAYPAL_CLIENT_SECRET=change-me
|
||||
PAYPAL_MODE=sandbox
|
||||
|
||||
|
||||
# ── Signup ────────────────────────────────────────────────────── optional ────
|
||||
# Off by default, deliberately. Public signup used to create accounts belonging
|
||||
# to no workspace, which the code then treated as platform superadmins
|
||||
# (finding S-1). Turn it on only with a signup flow that assigns a workspace.
|
||||
ALLOW_PUBLIC_SIGNUP=False
|
||||
|
||||
|
||||
# ── Module identity ───────────────────────────────────────────── optional ────
|
||||
# Signs the tokens returned by the grant-exchange endpoint. Unset means that one
|
||||
# endpoint answers 503; nothing else is affected, because the sign-on handoff and
|
||||
# the event channel use per-environment HMAC.
|
||||
#
|
||||
# Generate with: python scripts/generate_module_key.py --env
|
||||
# The public half is published at /.well-known/jwks.json for modules to verify
|
||||
# against. Rotate by giving the new key a new SAAS_KEY_ID.
|
||||
SAAS_PRIVATE_KEY=
|
||||
SAAS_KEY_ID=saas-key-v1
|
||||
|
||||
|
||||
# ── Module trust ──────────────────────────────────────────────── optional ────
|
||||
# Requires inbound module requests to carry a timestamp and nonce (signature
|
||||
# version 2). Leave off until the modules have shipped it — turning it on first
|
||||
# refuses every legitimate call. See docs/MODULE_CONTRACT.md §2.
|
||||
MODULE_TRUST_REQUIRE_REPLAY_CONTROLS=False
|
||||
MODULE_TRUST_MAX_SKEW_SECONDS=120
|
||||
|
||||
|
||||
# ── Alerting ──────────────────────────────────────────────────── optional ────
|
||||
# Both empty means alerting is built and silent: the loop returns immediately
|
||||
# rather than computing counts nobody will see. Setting either turns it on.
|
||||
#
|
||||
# Three conditions are sent: events not getting through, events that gave up
|
||||
# entirely, and refresh tokens presented after the real client had spent them.
|
||||
ALERT_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
ALERT_RENOTIFY_MINUTES=60
|
||||
ALERT_STUCK_EVENTS_THRESHOLD=5
|
||||
ALERT_STUCK_EVENTS_CRITICAL=50
|
||||
ALERT_TOKEN_REUSE_WINDOW_HOURS=24
|
||||
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────── optional ────
|
||||
# Comma-separated. Leave both empty to allow only FRONTEND_URL.
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
CORS_ALLOW_ORIGIN_REGEX=
|
||||
|
||||
# --- Documents -------------------------------------------------------------
|
||||
# Where uploaded files are written. A directory the application can write to and
|
||||
# that is NOT served by a web server: downloads go through the API so the
|
||||
# workspace and the permission are checked on every read.
|
||||
DOCUMENT_STORAGE_PATH=./storage/documents
|
||||
# Largest single upload, in bytes. 25 MB.
|
||||
DOCUMENT_MAX_BYTES=26214400
|
||||
# Total live bytes one workspace may hold. 2 GB.
|
||||
DOCUMENT_QUOTA_BYTES=2147483648
|
||||
|
||||
# --- Audit retention -------------------------------------------------------
|
||||
# The application role has UPDATE and DELETE revoked on audit_logs, so the trail
|
||||
# cannot be rewritten by anything reaching the database as the application.
|
||||
# Retention still has to remove expired rows, and connects as its own role with
|
||||
# SELECT and DELETE on that one table.
|
||||
# python scripts/create_audit_retention_role.py
|
||||
# Leave unset and the retention job refuses to run — which is the point: a job
|
||||
# that no-ops forever while reporting success is worse than one that fails.
|
||||
AUDIT_RETENTION_DATABASE_URL=
|
||||
|
||||
# For the same reason, the one-off attribution backfill is an UPDATE and cannot
|
||||
# run as the application either. It runs as the role that owns the schema — the
|
||||
# one that runs the migrations — and only when somebody runs it by hand:
|
||||
# python scripts/backfill_audit_tenants.py --dry-run
|
||||
# Set here or passed as --database-url. Not needed by the running application.
|
||||
AUDIT_BACKFILL_DATABASE_URL=
|
||||
+12
-16
@@ -1,5 +1,5 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=Fulfillment And Logistics
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=8000
|
||||
APP_ENV=local
|
||||
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -20,33 +20,29 @@ SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://saas_user:nMCuFvGoG%28%23Q%40Q4%5E@106.51.104.95:5432/saas_local
|
||||
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_local
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6381
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
EMAIL_FROM=info.maskantech@gmail.com
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
|
||||
+2
-1
@@ -38,10 +38,11 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
COOKIE_SECURE=true
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
|
||||
+12
-16
@@ -1,13 +1,13 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=11002
|
||||
PORT=10002
|
||||
APP_ENV=testing
|
||||
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=https://fulfilment-test.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://fulfilment-test.maskantech.in
|
||||
FRONTEND_URL=https://saas-test.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in,https://docqube.com,https://docqubeapp-test.maskantech.in
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -20,33 +20,29 @@ SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_test
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6383
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
EMAIL_FROM=info.maskantech@gmail.com
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
|
||||
+8
-1
@@ -11,4 +11,11 @@ node_modules
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.idea/
|
||||
|
||||
# Environment files. .env was ignored but .env.production was not, which is
|
||||
# how .env.development/.env.local/.env.production/.env.testing came to be
|
||||
# committed with live secrets in them.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
# Backend handover
|
||||
|
||||
For the next developer. What this service was, what it is now, what changed, and
|
||||
what is still waiting for somebody.
|
||||
|
||||
Two companion documents sit one directory up and go deeper:
|
||||
|
||||
- `../REVIEW.md` — the assessment. What was wrong, what was built, what I would
|
||||
not claim. Read §1 and §10 if you read nothing else.
|
||||
- `../SAAS_HARDENING.md` — the chronological log, including the mistakes and the
|
||||
reasoning behind each decision.
|
||||
|
||||
This file is the orientation. Those two are the detail.
|
||||
|
||||
---
|
||||
|
||||
## 1. In one paragraph
|
||||
|
||||
This is a multi-tenant SaaS control plane: workspaces (tenants), the people in
|
||||
them, what those people are allowed to do, what their workspace has paid for, and
|
||||
which downstream modules they can reach. It was a working application with a
|
||||
serious isolation problem — **any signed-in user of any customer could read every
|
||||
other customer's data through several endpoints**, because tenancy was a
|
||||
convention in the query layer rather than a rule in the database. That is now
|
||||
enforced by PostgreSQL row-level security, and roughly twenty capabilities a
|
||||
business customer expects have been built on top.
|
||||
|
||||
---
|
||||
|
||||
## 2. What was here before
|
||||
|
||||
Fifteen migrations, and these concerns:
|
||||
|
||||
| Area | What existed |
|
||||
|---|---|
|
||||
| Auth | Email/password sign-in, JWT access tokens, refresh tokens |
|
||||
| Tenancy | A `tenant_id` column, filtered by hand in each query |
|
||||
| Roles | Roles and access codes per workspace |
|
||||
| Subscriptions | Plans, and an assignment of plan to workspace |
|
||||
| Modules | A registry of downstream modules and their environments |
|
||||
| SSO **outbound** | Signing users *into* modules — the platform as identity provider |
|
||||
| Audit | An `audit_logs` table and a service writing to it |
|
||||
| Theme | Colour palettes |
|
||||
|
||||
**Not present:** row-level security, MFA, inbound SSO, SCIM, invitations, API
|
||||
keys, webhooks, notifications, org units, documents, reference data, sessions
|
||||
anybody could see, seat enforcement, rate limiting, an outbox, or a test suite.
|
||||
|
||||
The state of the code is recoverable exactly: **everything in this repository is
|
||||
uncommitted on branch `furqan`.** `git diff` shows every change to a pre-existing
|
||||
file, and `git ls-files --others --exclude-standard` shows every new one. Nothing
|
||||
was committed by design — see §7.
|
||||
|
||||
---
|
||||
|
||||
## 3. The single most important change
|
||||
|
||||
**Workspace isolation is now a database rule, not a coding convention.**
|
||||
|
||||
Migration `c3d5e7f9a801` enables and FORCEs row-level security on every
|
||||
workspace-owned table. The application connects as a role created by
|
||||
`scripts/create_app_role.py`, which is `NOBYPASSRLS` — so a query that forgets
|
||||
its `WHERE tenant_id = ...` returns nothing rather than everything.
|
||||
|
||||
Three pieces make it work, and you need all three:
|
||||
|
||||
1. `app/core/tenant_context.py` — a `contextvar` holding the current workspace.
|
||||
`scoped_to(tenant_id)` sets it; `unscoped()` lifts it for genuine
|
||||
platform-wide work.
|
||||
2. `app/middleware/tenant_scope_middleware.py` — sets it per request from the
|
||||
access token.
|
||||
3. A session listener that pushes the value into PostgreSQL via `set_config`
|
||||
before each statement.
|
||||
|
||||
**The trap:** `unscoped()` only affects sessions wired to that listener. A raw
|
||||
`create_engine` session has no listener, so the flag never reaches PostgreSQL and
|
||||
every policy hides every row. Such a session must issue
|
||||
`SELECT set_config('app.bypass_rls', 'on', false)` itself.
|
||||
`app/services/system/audit_retention.py` is the worked example.
|
||||
|
||||
---
|
||||
|
||||
## 4. What was built, grouped
|
||||
|
||||
Twenty-six migrations were added. Rather than list them, here is what they
|
||||
bought, roughly in dependency order. Each has tests; `../REVIEW.md` §9 argues why
|
||||
each was built the way it was.
|
||||
|
||||
### Security and correctness
|
||||
|
||||
| Capability | Why it mattered |
|
||||
|---|---|
|
||||
| **Row-level security** | Above. The reason for everything else. |
|
||||
| **Encrypted module trust credentials** | They were stored in plaintext. |
|
||||
| **Superadmin as an explicit flag** | It was an *absent* `tenant_id`, so a signup that omitted the workspace produced a platform superadmin. |
|
||||
| **MFA (TOTP) + lockout** | No second factor existed, for anyone, including superadmins. |
|
||||
| **Sessions people can see and end** | Refresh tokens rotated and revoked correctly; nobody could ever *see* the result. |
|
||||
| **Rate limiting** | Credential endpoints had none. |
|
||||
| **One account per address** | `Alice@` and `alice@` were two accounts. Matched on `lower(email)` now. |
|
||||
| **Append-only audit log** | `UPDATE`/`DELETE` revoked from the application role. |
|
||||
| **Soft delete** | On `tenants` and `org_units`, where a hard delete destroyed history. |
|
||||
|
||||
### Customer-facing capability
|
||||
|
||||
| Capability | What it is |
|
||||
|---|---|
|
||||
| **Inbound SSO** | Sign in with the customer's own Azure AD / Okta / Google. The opposite direction to the outbound SSO that already existed. |
|
||||
| **SCIM 2.0** | Provisioning and de-provisioning from the customer's directory. Without it, leavers kept their accounts. |
|
||||
| **Invitations** | Inviting somebody instead of choosing their password for them. |
|
||||
| **API keys** | Keys a customer can automate against, scoped. |
|
||||
| **Webhooks** | Telling a customer's systems when something happens. HMAC-signed. |
|
||||
| **Notifications + preferences** | In-product notices, and the ability to turn each kind off. |
|
||||
| **Org units** | Departments and branches, with user administration scoped to a sub-tree. |
|
||||
| **Seat allocation** | Seats divided between branches, not only counted per workspace. |
|
||||
| **Documents** | Files attached to things, with content sniffed by magic bytes. |
|
||||
| **Reference data** | Generic lookup lists — the things dropdowns are made of. |
|
||||
| **Per-workspace outgoing email** | Sending from the customer's own address. |
|
||||
| **Subscription lifecycle** | Grace periods, cancellation, expiry notices, history. |
|
||||
|
||||
### Operational
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| **Background worker**, six jobs | `app/services/system/worker.py` — outbox, alerts, sessions, webhooks, notices, audit-retention |
|
||||
| **Event outbox** | Events survive a crash between the write and the send |
|
||||
| **Idempotency** | A retried request is safe to retry |
|
||||
| **Alert state** | Remembers which alerts are already open, so they do not re-fire hourly |
|
||||
| **Operations endpoints** | `/api/admin/operations/*` — what the background work has been doing |
|
||||
|
||||
---
|
||||
|
||||
## 5. Where to look
|
||||
|
||||
```
|
||||
app/
|
||||
core/
|
||||
tenant_context.py Read this first. Workspace scoping.
|
||||
rls.py Policy helpers and the enforcement check.
|
||||
crypto.py Encryption at rest for module credentials.
|
||||
ssrf.py Resolve-then-pin. Used by webhooks and SSO discovery.
|
||||
document_storage.py Four functions. Swap for S3 here and nowhere else.
|
||||
file_types.py Magic-byte sniffing. Never trusts Content-Type.
|
||||
middleware/
|
||||
tenant_scope_middleware.py Sets the workspace per request.
|
||||
rate_limit.py Credential endpoints.
|
||||
idempotency_middleware.py Safe retries.
|
||||
services/
|
||||
auth/ Workspace-owned concerns.
|
||||
system/ Platform concerns, plus worker.py.
|
||||
routes/ Thin. Validation and permission checks only.
|
||||
scripts/ Operational. See §6.
|
||||
tests/ 52 files, 878 tests.
|
||||
docs/
|
||||
MODULE_CONTRACT.md Hand to the authors of downstream modules.
|
||||
SCIM.md, WEBHOOKS.md Customer-facing integration docs.
|
||||
```
|
||||
|
||||
**56 pre-existing files were modified.** The largest changes are in
|
||||
`app/__init__.py` (router mounting, middleware order), `app/config/settings.py`,
|
||||
and the tenant/user/role services, which had their hand-written workspace filters
|
||||
replaced by policy-backed queries.
|
||||
|
||||
---
|
||||
|
||||
## 6. Running it
|
||||
|
||||
```bash
|
||||
python -m venv venv && venv/Scripts/pip install -r requirements.txt
|
||||
|
||||
# Tests. The role matters — see the note below.
|
||||
TEST_DATABASE_URL=postgresql://saas_app:...@localhost/saas_test \
|
||||
python -m pytest tests/ -q # 878 pass, 3 skip
|
||||
|
||||
python scripts/verify_security_fixes.py # 23 checks
|
||||
python scripts/report_drift.py # read-only; what the live data looks like
|
||||
python scripts/measure_query_plans.py # query plans at volume
|
||||
```
|
||||
|
||||
**Run the suite as `saas_app`, not as `postgres`.** A superuser bypasses RLS, so
|
||||
the policy tests cannot prove anything and skip themselves with a message saying
|
||||
so. Three audit-backfill tests do the reverse — they need a role that *can*
|
||||
`UPDATE audit_logs`, so they skip on the app role. Between the two
|
||||
configurations everything is covered; in either one alone, something is not.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pending — needs you, not me
|
||||
|
||||
These cannot be done from this machine. `../REVIEW.md` §5 has the full text.
|
||||
|
||||
### 7.1 Apply the migrations, in this order
|
||||
|
||||
RLS FORCEs its policies, which applies them to the owner too. Without the
|
||||
context-setting code already deployed, every query returns nothing.
|
||||
|
||||
```
|
||||
1. Deploy the code first
|
||||
2. alembic upgrade head
|
||||
3. python scripts/create_app_role.py # prints a password once
|
||||
4. Put that role in DATABASE_URL, restart
|
||||
5. python scripts/create_audit_retention_role.py # prints a second password once
|
||||
6. Put that one in AUDIT_RETENTION_DATABASE_URL, restart
|
||||
```
|
||||
|
||||
- **Until step 4 the policies exist but do not apply.** The application is still
|
||||
connecting as a role that bypasses them, and nothing in the schema shows the
|
||||
difference. `check_rls_enforced()` reports it.
|
||||
- **Step 3 must be re-run even if it was run before**, because it now also
|
||||
revokes `UPDATE, DELETE` on `audit_logs`. A role created by the earlier version
|
||||
still holds those rights.
|
||||
- **Steps 5–6 are not optional.** The nightly retention sweep is a DELETE and the
|
||||
application role can no longer do it. Unconfigured, the job raises on every run
|
||||
— deliberately: a retention job that no-ops for a year while its last-run
|
||||
timestamp keeps updating is the failure nobody notices until the table is why a
|
||||
query times out.
|
||||
- Rolling back is `alembic downgrade b2e1d4f5a602`.
|
||||
|
||||
### 7.2 Rotate the secrets
|
||||
|
||||
`.env.local` and `.env.production` are in the working tree with live values and
|
||||
have been for the whole history. **Treat every value in them as known.** Rotating
|
||||
them and purging the files from git history is outstanding and is not something I
|
||||
should do to your repository.
|
||||
|
||||
### 7.3 Run the drift report against real data
|
||||
|
||||
```bash
|
||||
python scripts/report_drift.py
|
||||
```
|
||||
|
||||
Read-only. It has only ever run against an empty local database, so it currently
|
||||
proves the plumbing works and **nothing about your data**. Nobody yet knows how
|
||||
many workspaces are over their seat limit, how many roles grant permissions their
|
||||
plan never allowed, or how many accounts are stranded with no workspace. Expect
|
||||
the seat number to be non-zero — the limit was never enforced, so the data can
|
||||
exceed it.
|
||||
|
||||
### 7.4 Ship signature version 2 in the modules
|
||||
|
||||
The receiving half is built, tested and **off by default**.
|
||||
`MODULE_TRUST_REQUIRE_REPLAY_CONTROLS` cannot be turned on until the modules send
|
||||
the new headers — turning it on first refuses every legitimate call. Hand
|
||||
`docs/MODULE_CONTRACT.md` to their authors.
|
||||
|
||||
### 7.5 Point the alerts somewhere
|
||||
|
||||
`ALERT_WEBHOOK_URL` and `ALERT_EMAIL` are unset, so alerting is built and silent.
|
||||
Where they go, and who answers at three in the morning, is not a code decision.
|
||||
|
||||
### 7.6 Decide about `/internal/sso/exchange`
|
||||
|
||||
It works, and **nothing issues a grant code**, so no module can be using it. I
|
||||
finished it rather than deleting it, because removing a mounted public endpoint
|
||||
is your call. I would lean towards deleting it — the signed-payload handoff
|
||||
supersedes it.
|
||||
|
||||
### 7.7 Backfill audit attribution (optional)
|
||||
|
||||
Entries written before `audit_logs` had a workspace column are superadmin-only.
|
||||
`scripts/backfill_audit_tenants.py` recovers most of them from `entity_id` and
|
||||
has a `--dry-run`. It needs the **schema owner**, not the application role,
|
||||
passed as `--database-url` or `AUDIT_BACKFILL_DATABASE_URL` — `audit_logs` is
|
||||
append-only for the application now. It says so and stops if you forget.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deliberately not built
|
||||
|
||||
Named as decisions so the next person finds an answer rather than a gap.
|
||||
|
||||
- **`locations`, `cost_codes`, `currencies`** exist in the base application.
|
||||
They are the customer's domain, not the platform's — a construction business
|
||||
needs cost codes; a platform that hosts one does not. The reference-list tables
|
||||
hold them if you want them, with no migration.
|
||||
- **Virus scanning on uploads is absent, and named as absent.** A function that
|
||||
pretends to scan is worse than an honest gap: it converts "we do not check"
|
||||
into "we checked", which is the belief that gets a file opened.
|
||||
- **`user_sessions` is outside RLS on purpose.** It is written during sign-in
|
||||
before workspace context exists and read during refresh after the access token
|
||||
has expired. A policy there isolates nothing and breaks signing in. Every query
|
||||
on it is keyed on `user_id` or an unguessable `jti`.
|
||||
- **No reference-list content is seeded.** Which currencies and document types
|
||||
the platform publishes is a decision;
|
||||
`lookup_service.seed_platform_list` is where it goes.
|
||||
|
||||
---
|
||||
|
||||
## 9. What I would not claim
|
||||
|
||||
Being explicit, because you are inheriting this.
|
||||
|
||||
- **Nothing has been verified against production data.** Every test runs against
|
||||
a local scratch database.
|
||||
- **CI has never been run by GitHub Actions.** The repository has no remote.
|
||||
Every step has been run locally in order against a database created from
|
||||
scratch, which is most of the value, but the YAML has only been parsed.
|
||||
- **Coverage is ~78%, not 100%.** The thinnest areas are the email service, parts
|
||||
of `role_service`, and route modules generally.
|
||||
- **The migration chain is verified up from empty and back down to base** on a
|
||||
throwaway database — never against anything shared.
|
||||
- **Defects were introduced and caught in the same pass.** Several. They are
|
||||
listed in `../REVIEW.md` §7 rather than quietly omitted, because the pattern is
|
||||
the useful part: each was caught by a test written for a different reason.
|
||||
@@ -20,9 +20,10 @@ A multi-tenant SaaS backend built with FastAPI, PostgreSQL, and SQLAlchemy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 12+
|
||||
- Python 3.10+
|
||||
- PostgreSQL 12+
|
||||
- Node.js (for npm scripts)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -44,7 +45,7 @@ python -m venv venv
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npm install # For cross-env support in npm scripts
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Environment Configuration
|
||||
@@ -112,11 +113,11 @@ alembic revision --autogenerate -m "Initial schema"
|
||||
#### Run Migrations
|
||||
|
||||
```bash
|
||||
# Using npm scripts (recommended - handles APP_ENV automatically)
|
||||
npm run migrate:local
|
||||
npm run migrate:dev
|
||||
npm run migrate:prod
|
||||
npm run migrate:test
|
||||
# Using manage.py (recommended - handles APP_ENV automatically)
|
||||
python manage.py migrate --env local
|
||||
python manage.py migrate --env development
|
||||
python manage.py migrate --env production
|
||||
python manage.py migrate --env testing
|
||||
|
||||
# Or using alembic directly
|
||||
$env:APP_ENV="local" # Set environment first
|
||||
@@ -148,20 +149,20 @@ After running migrations, seed the database with initial data:
|
||||
|
||||
```bash
|
||||
# Seed super admin user
|
||||
npm run seed:superadmin:local
|
||||
python manage.py seed superadmin --env local
|
||||
|
||||
# Seed default color palettes
|
||||
npm run seed:palettes:local
|
||||
python manage.py seed palettes --env local
|
||||
```
|
||||
|
||||
### 7. Run the Application
|
||||
|
||||
```bash
|
||||
# Using npm scripts (recommended)
|
||||
npm run local # Local environment
|
||||
npm run dev # Development environment
|
||||
npm run prod # Production environment
|
||||
npm run test # Testing environment
|
||||
# Using manage.py (recommended)
|
||||
python manage.py run --env local # Local environment
|
||||
python manage.py run --env development # Development environment
|
||||
python manage.py run --env production # Production environment
|
||||
python manage.py run --env testing # Testing environment
|
||||
|
||||
# Or using Python directly
|
||||
python run.py
|
||||
@@ -169,6 +170,31 @@ python run.py
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# One-time: a local scratch database the suite owns
|
||||
createdb saas_test
|
||||
APP_ENV=pytest python -m alembic upgrade head
|
||||
|
||||
python -m pytest tests/ -q # run
|
||||
python -m pytest tests/ --cov=app # with coverage
|
||||
python scripts/verify_security_fixes.py # the security regression harness
|
||||
```
|
||||
|
||||
**The suite never touches the shared database.** `app/config/settings.py` calls
|
||||
`load_dotenv(..., override=True)`, so `.env.local` — which points at the remote
|
||||
shared server — beats any environment variable you set. That is a real trap: the
|
||||
obvious way to redirect a run does not work.
|
||||
|
||||
`tests/conftest.py` therefore forces `DATABASE_URL` before the application is
|
||||
imported, and refuses to start at all if the target is not local. Better a suite
|
||||
that will not run than one that quietly writes to production.
|
||||
|
||||
Every test runs inside a transaction that is rolled back, so nothing survives and
|
||||
tests can run in any order.
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once the application is running, visit:
|
||||
@@ -209,7 +235,8 @@ backend/
|
||||
│ ├── seed_palettes.py
|
||||
│ └── seed_superadmin.py
|
||||
├── alembic.ini # Alembic configuration
|
||||
├── package.json # NPM scripts
|
||||
├── alembic.ini # Alembic configuration
|
||||
├── manage.py # Management CLI script
|
||||
├── requirements.txt # Python dependencies
|
||||
└── run.py # Application entry point
|
||||
```
|
||||
@@ -275,6 +302,23 @@ The RBAC system supports:
|
||||
- Flexible role-to-permission mapping
|
||||
- Tenant-scoped roles
|
||||
|
||||
## DocQube ➔ SaaS Data Migration
|
||||
|
||||
To migrate tenants, roles, and users from DocQube DB to SaaS DB:
|
||||
|
||||
```bash
|
||||
# 1. Preview changes (Dry Run - No changes committed):
|
||||
python scripts/migrate_docqube_to_saas.py --dry-run
|
||||
|
||||
# 2. Execute and commit to SaaS DB:
|
||||
python scripts/migrate_docqube_to_saas.py --execute
|
||||
```
|
||||
|
||||
### Password Verification & Automatic Upgrade
|
||||
- Users migrated from DocQube with `pbkdf2_sha256` password hashes can log in seamlessly.
|
||||
- Upon first successful login, their password hash is automatically and transparently upgraded to standard SaaS `bcrypt`.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
+5
-19
@@ -8,62 +8,48 @@ from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Load environment variables before importing app
|
||||
from dotenv import load_dotenv
|
||||
|
||||
app_env = os.getenv("APP_ENV", "local")
|
||||
env_filename = f".env.{app_env}"
|
||||
|
||||
# Define paths
|
||||
base_path = Path(__file__).resolve().parent.parent
|
||||
backend_path = base_path
|
||||
|
||||
# Load environment variables
|
||||
_explicit = dict(os.environ)
|
||||
|
||||
load_dotenv(dotenv_path=base_path / '.env')
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
|
||||
# Override with specific environment config
|
||||
if (base_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||
if (backend_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||
|
||||
# Import app settings and database
|
||||
os.environ.update(_explicit)
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.config.database import Base
|
||||
|
||||
# Import all models for autogenerate support
|
||||
import app.models.auth.user_model
|
||||
import app.models.auth.role_model
|
||||
import app.models.auth.tenant_model
|
||||
import app.models.auth.access_model
|
||||
import app.models.auth.role_access_model
|
||||
import app.models.theme.color_palette_model
|
||||
import app.models.auth.subscription_plan_model
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Set the database URL from app settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""create_event_log_model
|
||||
|
||||
Revision ID: 03a1b1f05e99
|
||||
Revises: cd8ba77ffd9e
|
||||
Create Date: 2026-01-20 17:00:41.702680
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '03a1b1f05e99'
|
||||
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('event_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('event_id', sa.UUID(), nullable=False),
|
||||
sa.Column('event_type', sa.String(), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('target_module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('target_environment_slug', sa.String(), nullable=False),
|
||||
sa.Column('target_url', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('retry_count', sa.Integer(), nullable=True),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('error_log', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_event_logs_event_id'), 'event_logs', ['event_id'], unique=False)
|
||||
op.create_index(op.f('ix_event_logs_next_retry_at'), 'event_logs', ['next_retry_at'], unique=False)
|
||||
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f('ix_event_logs_status'), table_name='event_logs')
|
||||
op.drop_index(op.f('ix_event_logs_next_retry_at'), table_name='event_logs')
|
||||
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
|
||||
op.drop_table('event_logs')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add tenant lifecycle fields
|
||||
|
||||
Revision ID: 5f2e9c1a7b44
|
||||
Revises: 720027c97104
|
||||
Create Date: 2026-04-18 14:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "5f2e9c1a7b44"
|
||||
down_revision: Union[str, Sequence[str], None] = "720027c97104"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS start_date DATE")
|
||||
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS end_date DATE")
|
||||
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS status VARCHAR")
|
||||
op.execute("ALTER TABLE tenants ALTER COLUMN status SET DEFAULT 'ACTIVE'")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tenants
|
||||
SET status = CASE
|
||||
WHEN is_active = true THEN 'ACTIVE'
|
||||
ELSE 'INACTIVE'
|
||||
END
|
||||
WHERE status IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE tenants ALTER COLUMN status SET NOT NULL")
|
||||
op.execute("ALTER TABLE tenants ALTER COLUMN status DROP DEFAULT")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tenants_status ON tenants (status)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenants_status")
|
||||
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS status")
|
||||
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS end_date")
|
||||
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS start_date")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""split_module_access_table
|
||||
|
||||
Revision ID: 63b95ea5b967
|
||||
Revises: 88cfc7dee19d
|
||||
Create Date: 2026-01-23 11:07:09.706000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '63b95ea5b967'
|
||||
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_code', sa.String(), nullable=False),
|
||||
sa.Column('category', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('sync_checksum', sa.String(), nullable=True),
|
||||
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('module_id', 'access_code', name='uq_module_access_code')
|
||||
)
|
||||
op.create_index(op.f('ix_module_accesses_access_code'), 'module_accesses', ['access_code'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_category'), 'module_accesses', ['category'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_id'), 'module_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_module_id'), 'module_accesses', ['module_id'], unique=False)
|
||||
op.drop_index(op.f('ix_access_code_module'), table_name='accesses', postgresql_where='(module_id IS NOT NULL)')
|
||||
op.drop_index(op.f('ix_access_code_saas'), table_name='accesses', postgresql_where='(module_id IS NULL)')
|
||||
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
|
||||
op.drop_column('accesses', 'module_id')
|
||||
op.drop_column('accesses', 'scope')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.add_column('accesses', sa.Column('scope', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
op.add_column('accesses', sa.Column('module_id', sa.UUID(), autoincrement=False, nullable=True))
|
||||
op.create_foreign_key(op.f('accesses_module_id_fkey'), 'accesses', 'modules', ['module_id'], ['id'])
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_code_saas'), 'accesses', ['access_code'], unique=True, postgresql_where='(module_id IS NULL)')
|
||||
op.create_index(op.f('ix_access_code_module'), 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where='(module_id IS NOT NULL)')
|
||||
op.drop_index(op.f('ix_module_accesses_module_id'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_id'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_category'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_access_code'), table_name='module_accesses')
|
||||
op.drop_table('module_accesses')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add duration days to subscription plans
|
||||
|
||||
Revision ID: 6a1b2c3d4e55
|
||||
Revises: 5f2e9c1a7b44
|
||||
Create Date: 2026-04-18 15:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "6a1b2c3d4e55"
|
||||
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS duration_days INTEGER"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans DROP COLUMN IF EXISTS duration_days"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add_follow_up_event_to_event_logs
|
||||
|
||||
Revision ID: 720027c97104
|
||||
Revises: 9283c3f52a76
|
||||
Create Date: 2026-04-07 12:56:10.500329
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '720027c97104'
|
||||
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('event_logs', sa.Column('follow_up_event', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('event_logs', 'follow_up_event')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""create audit logs table
|
||||
|
||||
Revision ID: 73b754d5b2c5
|
||||
Revises: c37ba6143f83
|
||||
Create Date: 2026-03-30 15:43:16.849980
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '73b754d5b2c5'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('audit_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('action_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_module_name'), 'audit_logs', ['module_name'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f('ix_audit_logs_module_name'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
|
||||
op.drop_table('audit_logs')
|
||||
@@ -0,0 +1,147 @@
|
||||
"""add_module_registry
|
||||
|
||||
Revision ID: 74b6ccfaee8e
|
||||
Revises: 8acd83604252
|
||||
Create Date: 2026-01-20 15:11:19.596874
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from app.core.migration_helpers import drop_foreign_key_on
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '74b6ccfaee8e'
|
||||
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('modules',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.String(), nullable=False),
|
||||
sa.Column('module_name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('icon_url', sa.String(), nullable=True),
|
||||
sa.Column('display_order', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_modules_module_id'), 'modules', ['module_id'], unique=True)
|
||||
|
||||
op.create_table('module_environments',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('slug', sa.String(), nullable=False),
|
||||
sa.Column('frontend_base_url', sa.String(), nullable=False),
|
||||
sa.Column('sso_entry_path', sa.String(), nullable=True),
|
||||
sa.Column('backend_base_url', sa.String(), nullable=False),
|
||||
sa.Column('sso_exchange_endpoint', sa.String(), nullable=True),
|
||||
sa.Column('permission_sync_endpoint', sa.String(), nullable=True),
|
||||
sa.Column('trust_type', sa.String(), nullable=False),
|
||||
sa.Column('trust_credentials', sa.JSON(), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('module_id', 'slug', name='uq_module_env_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_module_environments_module_id'), 'module_environments', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_module_environments_slug'), 'module_environments', ['slug'], unique=False)
|
||||
|
||||
op.create_table('tenant_modules',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('assigned_environment_slug', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('activated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deactivated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('plan_tier', sa.String(), nullable=True),
|
||||
sa.Column('module_config', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id', 'module_id', name='uq_tenant_module')
|
||||
)
|
||||
op.create_index(op.f('ix_tenant_modules_module_id'), 'tenant_modules', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_tenant_modules_tenant_id'), 'tenant_modules', ['tenant_id'], unique=False)
|
||||
|
||||
op.create_table('sso_grants',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('grant_code', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=True),
|
||||
sa.Column('environment_slug', sa.String(), nullable=False),
|
||||
sa.Column('redirect_url', sa.String(), nullable=False),
|
||||
sa.Column('is_used', sa.Boolean(), nullable=True),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sso_grants_grant_code'), 'sso_grants', ['grant_code'], unique=True)
|
||||
op.create_index(op.f('ix_sso_grants_module_id'), 'sso_grants', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_sso_grants_tenant_id'), 'sso_grants', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_sso_grants_user_id'), 'sso_grants', ['user_id'], unique=False)
|
||||
|
||||
op.add_column('accesses', sa.Column('scope', sa.String(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('module_id', sa.UUID(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('sync_checksum', sa.String(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
|
||||
|
||||
op.alter_column('accesses', 'scope', nullable=False)
|
||||
|
||||
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
fks = inspector.get_foreign_keys('users')
|
||||
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
|
||||
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
|
||||
|
||||
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||
if 'palette_id' in columns:
|
||||
op.drop_column('users', 'palette_id')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.add_column('users', sa.Column('palette_id', sa.UUID(), autoincrement=False, nullable=True))
|
||||
op.create_foreign_key(op.f('users_palette_id_fkey'), 'users', 'color_palettes', ['palette_id'], ['id'])
|
||||
drop_foreign_key_on("accesses", "module_id")
|
||||
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
|
||||
op.drop_column('accesses', 'last_synced_at')
|
||||
op.drop_column('accesses', 'sync_checksum')
|
||||
op.drop_column('accesses', 'module_id')
|
||||
op.drop_column('accesses', 'scope')
|
||||
op.drop_index(op.f('ix_sso_grants_user_id'), table_name='sso_grants')
|
||||
op.drop_index(op.f('ix_sso_grants_tenant_id'), table_name='sso_grants')
|
||||
op.drop_index(op.f('ix_sso_grants_module_id'), table_name='sso_grants')
|
||||
op.drop_index(op.f('ix_sso_grants_grant_code'), table_name='sso_grants')
|
||||
op.drop_table('sso_grants')
|
||||
op.drop_index(op.f('ix_tenant_modules_tenant_id'), table_name='tenant_modules')
|
||||
op.drop_index(op.f('ix_tenant_modules_module_id'), table_name='tenant_modules')
|
||||
op.drop_table('tenant_modules')
|
||||
op.drop_index(op.f('ix_module_environments_slug'), table_name='module_environments')
|
||||
op.drop_index(op.f('ix_module_environments_module_id'), table_name='module_environments')
|
||||
op.drop_table('module_environments')
|
||||
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
|
||||
op.drop_table('modules')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add max users allowed to subscription plans
|
||||
|
||||
Revision ID: 7b2c4d5e6f77
|
||||
Revises: 6a1b2c3d4e55
|
||||
Create Date: 2026-04-28 10:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "7b2c4d5e6f77"
|
||||
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS max_users_allowed INTEGER"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans DROP COLUMN IF EXISTS max_users_allowed"
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""scoped_access_code_uniqueness
|
||||
|
||||
Revision ID: 88cfc7dee19d
|
||||
Revises: 03a1b1f05e99
|
||||
Create Date: 2026-01-22 19:44:27.431776
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '88cfc7dee19d'
|
||||
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index('ix_access_code_module', 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index('ix_access_code_saas', table_name='accesses', postgresql_where=sa.text('module_id IS NULL'))
|
||||
op.drop_index('ix_access_code_module', table_name='accesses', postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""initial migration
|
||||
|
||||
Revision ID: 8acd83604252
|
||||
Revises:
|
||||
Create Date: 2026-01-17 16:23:50.226790
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '8acd83604252'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_code', sa.String(), nullable=False),
|
||||
sa.Column('category', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('parent_id', sa.UUID(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['accesses.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
op.create_index(op.f('ix_accesses_category'), 'accesses', ['category'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_id'), 'accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_parent_id'), 'accesses', ['parent_id'], unique=False)
|
||||
op.create_table('color_palettes',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('colors', sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('tenants',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_name', sa.String(), nullable=False),
|
||||
sa.Column('tenant_domain', sa.String(), nullable=False),
|
||||
sa.Column('tenant_logo_url', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tenants_id'), 'tenants', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tenants_tenant_domain'), 'tenants', ['tenant_domain'], unique=True)
|
||||
op.create_index(op.f('ix_tenants_tenant_name'), 'tenants', ['tenant_name'], unique=True)
|
||||
op.create_table('roles',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_name', sa.String(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_roles_tenant_id'), 'roles', ['tenant_id'], unique=False)
|
||||
op.create_table('role_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['access_id'], ['accesses.id'], ),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('role_id', 'access_id', name='uq_role_access')
|
||||
)
|
||||
op.create_index(op.f('ix_role_accesses_access_id'), 'role_accesses', ['access_id'], unique=False)
|
||||
op.create_index(op.f('ix_role_accesses_role_id'), 'role_accesses', ['role_id'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('password', sa.String(), nullable=False),
|
||||
sa.Column('first_name', sa.String(), nullable=False),
|
||||
sa.Column('last_name', sa.String(), nullable=True),
|
||||
sa.Column('phone_number', sa.String(), nullable=True),
|
||||
sa.Column('preferred_language', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=True),
|
||||
sa.Column('role_id', sa.UUID(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('password_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_role_id'), 'users', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_role_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_role_accesses_role_id'), table_name='role_accesses')
|
||||
op.drop_index(op.f('ix_role_accesses_access_id'), table_name='role_accesses')
|
||||
op.drop_table('role_accesses')
|
||||
op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles')
|
||||
op.drop_index(op.f('ix_roles_id'), table_name='roles')
|
||||
op.drop_table('roles')
|
||||
op.drop_index(op.f('ix_tenants_tenant_name'), table_name='tenants')
|
||||
op.drop_index(op.f('ix_tenants_tenant_domain'), table_name='tenants')
|
||||
op.drop_index(op.f('ix_tenants_id'), table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
op.drop_table('color_palettes')
|
||||
op.drop_index(op.f('ix_accesses_parent_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_category'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.drop_table('accesses')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add_parent_id_to_module_access
|
||||
|
||||
Revision ID: 91cc93992a91
|
||||
Revises: 63b95ea5b967
|
||||
Create Date: 2026-01-23 11:16:02.998798
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from app.core.migration_helpers import drop_foreign_key_on
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '91cc93992a91'
|
||||
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('role_module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_access_id'], ['module_accesses.id'], ),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access')
|
||||
)
|
||||
op.create_index(op.f('ix_role_module_accesses_module_access_id'), 'role_module_accesses', ['module_access_id'], unique=False)
|
||||
op.create_index(op.f('ix_role_module_accesses_role_id'), 'role_module_accesses', ['role_id'], unique=False)
|
||||
op.add_column('module_accesses', sa.Column('parent_id', sa.UUID(), nullable=True))
|
||||
op.create_index(op.f('ix_module_accesses_parent_id'), 'module_accesses', ['parent_id'], unique=False)
|
||||
op.create_foreign_key(None, 'module_accesses', 'module_accesses', ['parent_id'], ['id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
drop_foreign_key_on("module_accesses", "parent_id")
|
||||
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
|
||||
op.drop_column('module_accesses', 'parent_id')
|
||||
op.drop_index(op.f('ix_role_module_accesses_role_id'), table_name='role_module_accesses')
|
||||
op.drop_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
|
||||
op.drop_table('role_module_accesses')
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Add subscription model
|
||||
|
||||
Revision ID: 9283c3f52a76
|
||||
Revises: f9cf173f48f9
|
||||
Create Date: 2026-04-06 14:26:41.926020
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from app.core.migration_helpers import drop_foreign_key_on
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '9283c3f52a76'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table('subscription_plans',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_subscription_plans_id'), 'subscription_plans', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_subscription_plans_name'), 'subscription_plans', ['name'], unique=True)
|
||||
op.create_table('plan_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('plan_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['access_id'], ['accesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['subscription_plans.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('plan_id', 'access_id', name='uq_plan_access')
|
||||
)
|
||||
op.create_index(op.f('ix_plan_accesses_access_id'), 'plan_accesses', ['access_id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_accesses_id'), 'plan_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_accesses_plan_id'), 'plan_accesses', ['plan_id'], unique=False)
|
||||
op.create_table('plan_module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('plan_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_access_id'], ['module_accesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['subscription_plans.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access')
|
||||
)
|
||||
op.create_index(op.f('ix_plan_module_accesses_id'), 'plan_module_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_module_accesses_module_access_id'), 'plan_module_accesses', ['module_access_id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_module_accesses_plan_id'), 'plan_module_accesses', ['plan_id'], unique=False)
|
||||
op.add_column('tenants', sa.Column('plan_id', sa.UUID(), nullable=True))
|
||||
op.create_index(op.f('ix_tenants_plan_id'), 'tenants', ['plan_id'], unique=False)
|
||||
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
drop_foreign_key_on("tenants", "plan_id")
|
||||
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
|
||||
op.drop_column('tenants', 'plan_id')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_plan_id'), table_name='plan_module_accesses')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_module_access_id'), table_name='plan_module_accesses')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_id'), table_name='plan_module_accesses')
|
||||
op.drop_table('plan_module_accesses')
|
||||
op.drop_index(op.f('ix_plan_accesses_plan_id'), table_name='plan_accesses')
|
||||
op.drop_index(op.f('ix_plan_accesses_id'), table_name='plan_accesses')
|
||||
op.drop_index(op.f('ix_plan_accesses_access_id'), table_name='plan_accesses')
|
||||
op.drop_table('plan_accesses')
|
||||
op.drop_index(op.f('ix_subscription_plans_name'), table_name='subscription_plans')
|
||||
op.drop_index(op.f('ix_subscription_plans_id'), table_name='subscription_plans')
|
||||
op.drop_table('subscription_plans')
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Seats allocated to a branch, not just to a workspace.
|
||||
|
||||
A workspace buys fifty seats. Nothing stops the Lahore branch using forty-eight
|
||||
of them, and nobody finds out until Karachi cannot add anybody. The workspace
|
||||
limit is real and enforced; it is simply the wrong grain for an organisation with
|
||||
branches that have their own budgets.
|
||||
|
||||
## What an allocation is, and is not
|
||||
|
||||
It is a **cap on a unit**, not a reservation. Nine seats allocated to a branch
|
||||
with two people in it does not stop the other forty-one being used elsewhere —
|
||||
it stops that branch exceeding nine.
|
||||
|
||||
A unit with **no allocation row is unconstrained**, and that is the default. Most
|
||||
workspaces will never want this, and the ones that do will want it on two or
|
||||
three units rather than on all of them. Requiring a row per unit would mean
|
||||
inventing a number for every branch nobody has an opinion about.
|
||||
|
||||
## The rule that makes the numbers add up
|
||||
|
||||
The sum of every allocation may not exceed what the workspace has bought. Without
|
||||
it, allocations become a set of promises that cannot all be kept, and the branch
|
||||
that discovers this is whichever one hires last.
|
||||
|
||||
Revision ID: a1d4f7c3e95b
|
||||
Revises: f8c3a6e1b72d
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "a1d4f7c3e95b"
|
||||
down_revision: Union[str, Sequence[str], None] = "f8c3a6e1b72d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_unit_seat_allocations",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("org_unit_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("seat_limit", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint("org_unit_id", name="uq_seat_allocation_unit"),
|
||||
)
|
||||
op.create_index("ix_seat_allocations_tenant", "org_unit_seat_allocations",
|
||||
["tenant_id"])
|
||||
|
||||
op.execute("ALTER TABLE org_unit_seat_allocations ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE org_unit_seat_allocations FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON org_unit_seat_allocations
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON org_unit_seat_allocations")
|
||||
op.drop_index("ix_seat_allocations_tenant",
|
||||
table_name="org_unit_seat_allocations")
|
||||
op.drop_table("org_unit_seat_allocations")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Make superadmin an explicit flag instead of an implied null tenant.
|
||||
|
||||
Before this migration, `is_superadmin(user)` was `user.tenant_id is None`. Any
|
||||
code path that created a user without a tenant therefore created a platform
|
||||
superadmin — including the public signup endpoint, which the frontend calls with
|
||||
no tenant header at all. The flag makes the privilege something a row states
|
||||
rather than something the absence of a value implies.
|
||||
|
||||
Backfill is deliberately conservative: only accounts that are BOTH tenant-less
|
||||
AND hold the seeded platform 'superadmin' role are marked. Any other tenant-less
|
||||
account is left as a non-superadmin and is reported by the accompanying audit
|
||||
query in Phase 1.1 — those are the accounts that should not exist.
|
||||
|
||||
Revision ID: a1f0c2d3e401
|
||||
Revises: 7b2c4d5e6f77
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a1f0c2d3e401"
|
||||
down_revision: Union[str, Sequence[str], None] = "7b2c4d5e6f77"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"is_superadmin",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET is_superadmin = true
|
||||
WHERE tenant_id IS NULL
|
||||
AND role_id IN (
|
||||
SELECT id FROM roles
|
||||
WHERE role_name = 'superadmin' AND tenant_id IS NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_users_is_superadmin",
|
||||
"users",
|
||||
["is_superadmin"],
|
||||
postgresql_where=sa.text("is_superadmin"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_is_superadmin", table_name="users")
|
||||
op.drop_column("users", "is_superadmin")
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Departments, branches, teams — and administration scoped to one.
|
||||
|
||||
A workspace is currently flat: everybody who can manage users can manage all of
|
||||
them. That is fine for ten people and wrong for a thousand, where the practical
|
||||
requirement is "the Lahore branch manager administers the Lahore branch".
|
||||
|
||||
## What this deliberately is and is not
|
||||
|
||||
**It is** structure and membership, plus one scoping rule: a person's user
|
||||
administration can be confined to a unit and everything under it.
|
||||
|
||||
**It is not** a scoping dimension on every record in the product. Adding a column
|
||||
to every domain table and a filter to every query, in advance of anything needing
|
||||
it, is how a platform ends up with a permission model nobody can reason about and
|
||||
half the queries quietly ignoring it. The place that needed it is user
|
||||
administration, and that is what it covers. When a domain table needs the same,
|
||||
the tables here are what it hangs off.
|
||||
|
||||
## The path column
|
||||
|
||||
`path` holds the ancestry as a materialised path (`/root-id/child-id/`). A
|
||||
recursive CTE would answer "everything under this unit" without it, and would
|
||||
cost a recursive scan on every permission check — which is every request an
|
||||
administrator makes. A prefix match on an indexed text column is one index scan.
|
||||
|
||||
The cost is that moving a unit rewrites the paths of its descendants. That is a
|
||||
rare, deliberate act, and it is far better to pay there than on every request.
|
||||
|
||||
Revision ID: a4d7f2c9e63b
|
||||
Revises: f3c6e1b8d52a
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "a4d7f2c9e63b"
|
||||
down_revision: Union[str, Sequence[str], None] = "f3c6e1b8d52a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCOPED = ("org_units", "user_org_units", "user_admin_scopes")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_units",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(160), nullable=False),
|
||||
sa.Column("code", sa.String(60), nullable=True),
|
||||
sa.Column("parent_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="RESTRICT"), nullable=True),
|
||||
sa.Column("path", sa.Text(), nullable=False, server_default="/"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", "code", name="uq_org_units_code"),
|
||||
)
|
||||
op.create_index("ix_org_units_tenant", "org_units", ["tenant_id"])
|
||||
op.create_index("ix_org_units_parent", "org_units", ["parent_id"])
|
||||
op.execute(
|
||||
"CREATE INDEX ix_org_units_path ON org_units (path text_pattern_ops)"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_org_units",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("org_unit_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("is_lead", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_org_unit"),
|
||||
)
|
||||
op.create_index("ix_user_org_units_user", "user_org_units", ["user_id"])
|
||||
op.create_index("ix_user_org_units_unit", "user_org_units", ["org_unit_id"])
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX uq_user_primary_org_unit ON user_org_units (user_id) "
|
||||
"WHERE is_primary"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_admin_scopes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("org_unit_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_admin_scope"),
|
||||
)
|
||||
op.create_index("ix_user_admin_scopes_user", "user_admin_scopes", ["user_id"])
|
||||
|
||||
for table in SCOPED:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in SCOPED:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.drop_index("ix_user_admin_scopes_user", table_name="user_admin_scopes")
|
||||
op.drop_table("user_admin_scopes")
|
||||
op.execute("DROP INDEX IF EXISTS uq_user_primary_org_unit")
|
||||
op.drop_index("ix_user_org_units_unit", table_name="user_org_units")
|
||||
op.drop_index("ix_user_org_units_user", table_name="user_org_units")
|
||||
op.drop_table("user_org_units")
|
||||
op.execute("DROP INDEX IF EXISTS ix_org_units_path")
|
||||
op.drop_index("ix_org_units_parent", table_name="org_units")
|
||||
op.drop_index("ix_org_units_tenant", table_name="org_units")
|
||||
op.drop_table("org_units")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tell customers before their subscription lapses, not after.
|
||||
|
||||
The lifecycle was enforced silently: the first a customer heard that their
|
||||
subscription had ended was a save failing, or — once the banner shipped — a
|
||||
notice on a workspace that had already gone read-only. Nothing warned them while
|
||||
there was still time to act.
|
||||
|
||||
Adds:
|
||||
|
||||
- `tenants.billing_email` — who to tell. Without it there is no one specific: a
|
||||
workspace has users, not an owner or a billing contact, so the alternative is
|
||||
emailing everybody, which is how notices become noise and stop being read.
|
||||
- `subscription_notices` — what has already been sent. Keyed on the end date it
|
||||
was sent *for*, so a renewal starts a fresh cycle and the same warning fires
|
||||
again next time, while a worker running twice in a day sends nothing twice.
|
||||
|
||||
Revision ID: a7c9e3f5b205
|
||||
Revises: f6b8c2d4e104
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "a7c9e3f5b205"
|
||||
down_revision: Union[str, Sequence[str], None] = "f6b8c2d4e104"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tenants", sa.Column("billing_email", sa.String(255), nullable=True))
|
||||
|
||||
op.create_table(
|
||||
"subscription_notices",
|
||||
sa.Column(
|
||||
"id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.String(30), nullable=False),
|
||||
sa.Column("for_end_date", sa.Date(), nullable=True),
|
||||
sa.Column("sent_to", sa.String(255), nullable=True),
|
||||
sa.Column(
|
||||
"sent_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"uq_subscription_notice_once",
|
||||
"subscription_notices",
|
||||
["tenant_id", "kind", "for_end_date"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE subscription_notices ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE subscription_notices FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON subscription_notices
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON subscription_notices")
|
||||
op.drop_index("uq_subscription_notice_once", table_name="subscription_notices")
|
||||
op.drop_table("subscription_notices")
|
||||
op.drop_column("tenants", "billing_email")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Keys a customer can automate against.
|
||||
|
||||
Every integration a customer builds today has to hold a **person's password**,
|
||||
sign in as them, and keep a session alive. That is worse than it sounds: the
|
||||
credential cannot be scoped down, cannot be rotated without locking somebody out
|
||||
of their own account, and cannot be told apart from that person in the audit
|
||||
trail. When they leave, either the integration breaks or their account is kept
|
||||
alive after they have gone.
|
||||
|
||||
An API key is a credential that belongs to the integration.
|
||||
|
||||
**Stored as a SHA-256, with a readable prefix beside it.** The prefix is what a
|
||||
customer sees in a list and what the lookup uses; the secret is checked by hash
|
||||
and constant-time comparison. The same reasoning as invitations — the platform
|
||||
only ever needs to *check* a key, never to read one back — with the addition that
|
||||
a prefix makes a leaked key identifiable in a log without the log holding the
|
||||
key.
|
||||
|
||||
**A key never outranks its owner.** Its scopes are a subset of what the issuing
|
||||
user could do, and that is re-checked on every request rather than frozen at
|
||||
issue time. So deactivating somebody disables their keys in the same moment,
|
||||
which is the case that otherwise goes wrong quietly: a leaver's integration
|
||||
outliving their account.
|
||||
|
||||
Revision ID: a7d9f1c3e80b
|
||||
Revises: f5c7e9b1d70a
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision: str = "a7d9f1c3e80b"
|
||||
down_revision: Union[str, Sequence[str], None] = "f5c7e9b1d70a"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(120), nullable=False),
|
||||
sa.Column("prefix", sa.String(16), nullable=False),
|
||||
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("scopes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("prefix", name="uq_api_keys_prefix"),
|
||||
)
|
||||
op.create_index("ix_api_keys_tenant", "api_keys", ["tenant_id"])
|
||||
op.create_index("ix_api_keys_user", "api_keys", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_api_keys_live", "api_keys", ["prefix"],
|
||||
postgresql_where=sa.text("revoked_at IS NULL"),
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE api_keys FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON api_keys
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON api_keys")
|
||||
op.drop_index("ix_api_keys_live", table_name="api_keys")
|
||||
op.drop_index("ix_api_keys_user", table_name="api_keys")
|
||||
op.drop_index("ix_api_keys_tenant", table_name="api_keys")
|
||||
op.drop_table("api_keys")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Encrypt module trust credentials at rest.
|
||||
|
||||
`module_environments.trust_credentials` was a plain JSON column holding the
|
||||
`hmac_secret` / `secret_key` values every module integration is authenticated
|
||||
with. This adds an encrypted column, moves each row's secrets into it, and blanks
|
||||
the plaintext.
|
||||
|
||||
Requires ENCRYPTION_KEY to be set, and it must be the same value the application
|
||||
runs with — otherwise the application cannot read back what this wrote.
|
||||
|
||||
Revision ID: b2e1d4f5a602
|
||||
Revises: a1f0c2d3e401
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import table, column
|
||||
|
||||
revision: str = "b2e1d4f5a602"
|
||||
down_revision: Union[str, Sequence[str], None] = "a1f0c2d3e401"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from app.core.crypto import encrypt_json
|
||||
|
||||
op.add_column(
|
||||
"module_environments",
|
||||
sa.Column("trust_credentials_enc", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
envs = table(
|
||||
"module_environments",
|
||||
column("id", sa.String),
|
||||
column("trust_credentials", sa.JSON),
|
||||
column("trust_credentials_enc", sa.Text),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, trust_credentials FROM module_environments")
|
||||
).fetchall()
|
||||
|
||||
migrated = 0
|
||||
for row_id, creds in rows:
|
||||
if not creds:
|
||||
continue
|
||||
if isinstance(creds, str):
|
||||
import json
|
||||
|
||||
creds = json.loads(creds)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE module_environments "
|
||||
"SET trust_credentials_enc = :enc, trust_credentials = '{}'::json "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"enc": encrypt_json(creds), "id": row_id},
|
||||
)
|
||||
migrated += 1
|
||||
|
||||
print(f" encrypted trust credentials for {migrated} module environment(s)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
from app.core.crypto import decrypt_json
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, trust_credentials_enc FROM module_environments "
|
||||
"WHERE trust_credentials_enc IS NOT NULL"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
import json
|
||||
|
||||
for row_id, enc in rows:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE module_environments SET trust_credentials = CAST(:c AS json) "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"c": json.dumps(decrypt_json(enc)), "id": row_id},
|
||||
)
|
||||
|
||||
op.drop_column("module_environments", "trust_credentials_enc")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Soft delete where it earns its place, and only there.
|
||||
|
||||
The base application applies a `SoftDeleteMixin` to six models. Applying it to
|
||||
all six here would be scaffolding: a `deleted_at` column on a table nothing ever
|
||||
filters is worse than none, because it looks like a guarantee and is not.
|
||||
|
||||
So it goes on the two where it changes an outcome:
|
||||
|
||||
**`tenants`.** Deleting a workspace is the most destructive act in the product.
|
||||
It is already refused while the workspace has members — which is what stops it
|
||||
taking a customer's accounts with it — but the workspace row itself is the thing
|
||||
every audit entry, every subscription record and every history row points at.
|
||||
Removing it makes all of them unattributable at once, and there is no way back.
|
||||
|
||||
**`org_units`.** A deleted branch is still named by audit entries, by seat
|
||||
allocations, and by whatever a report grouped by last quarter. Deletion is
|
||||
already refused while it holds members or children, so the row that goes is
|
||||
empty — but the *references to it* are not, and they stop resolving.
|
||||
|
||||
## Where it deliberately does not go
|
||||
|
||||
`color_palettes` and `identity_providers` are both recoverable by re-creating
|
||||
them, and neither is referenced by anything historical. A column there would be
|
||||
one more filter every query has to remember, bought for nothing.
|
||||
|
||||
Revision ID: b5e8c2a7f31d
|
||||
Revises: a1d4f7c3e95b
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "b5e8c2a7f31d"
|
||||
down_revision: Union[str, Sequence[str], None] = "a1d4f7c3e95b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
TABLES = ("tenants", "org_units")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in TABLES:
|
||||
op.add_column(table, sa.Column("deleted_at", sa.DateTime(timezone=True),
|
||||
nullable=True))
|
||||
op.add_column(table, sa.Column("deleted_by_id", UUID(as_uuid=True),
|
||||
nullable=True))
|
||||
op.create_index(
|
||||
f"ix_{table}_deleted_at", table, ["deleted_at"],
|
||||
postgresql_where=sa.text("deleted_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in TABLES:
|
||||
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||
op.drop_column(table, "deleted_by_id")
|
||||
op.drop_column(table, "deleted_at")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Letting a workspace send from its own address.
|
||||
|
||||
Every message the platform sends — an invitation, a password code, a
|
||||
subscription notice — goes out from one SMTP account and one `From` address for
|
||||
the whole platform. For a customer that is wrong in two ways at once: their
|
||||
people receive account mail from a company they have never heard of, and it
|
||||
arrives with no SPF or DKIM alignment to their own domain, so a strict receiver
|
||||
treats it as spoofing and files it accordingly.
|
||||
|
||||
The most visible symptom is the one that matters: **invitations land in spam.**
|
||||
|
||||
## Why the host is checked like a webhook URL
|
||||
|
||||
An SMTP host is a customer-supplied name that the *server* then connects to. It
|
||||
is the same shape of hazard as a webhook destination — `127.0.0.1:25` is this
|
||||
machine, and a name that resolves inside the network is a way to make the
|
||||
platform talk to something it should not. It goes through the same
|
||||
resolve-then-refuse check, on every send rather than only at configuration time.
|
||||
|
||||
## Why the password is encrypted rather than hashed
|
||||
|
||||
Unlike an API key or a recovery code, the platform has to *present* this
|
||||
credential to somebody else's server. There is no version of this where a hash
|
||||
would do.
|
||||
|
||||
Revision ID: b6e3a1d9f42c
|
||||
Revises: a4d7f2c9e63b
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "b6e3a1d9f42c"
|
||||
down_revision: Union[str, Sequence[str], None] = "a4d7f2c9e63b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tenant_email_settings",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("smtp_host", sa.String(255), nullable=False),
|
||||
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default="587"),
|
||||
sa.Column("smtp_user", sa.String(255), nullable=True),
|
||||
sa.Column("smtp_password_enc", sa.Text(), nullable=True),
|
||||
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("from_address", sa.String(255), nullable=False),
|
||||
sa.Column("from_name", sa.String(150), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error", sa.String(500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", name="uq_tenant_email_settings_tenant"),
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE tenant_email_settings ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE tenant_email_settings FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON tenant_email_settings
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON tenant_email_settings")
|
||||
op.drop_table("tenant_email_settings")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Remember which alerts are already open.
|
||||
|
||||
Without this an alerting loop re-sends the same condition every time it runs, and
|
||||
a channel that cries about the same stuck event every five minutes is a channel
|
||||
people mute — after which it may as well not exist.
|
||||
|
||||
One row per condition. Opened when it starts, re-notified no more often than the
|
||||
cooldown, and closed with a recovery notice when it clears, so nobody goes
|
||||
chasing something that fixed itself.
|
||||
|
||||
Revision ID: b8d1f4a6c306
|
||||
Revises: a7c9e3f5b205
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "b8d1f4a6c306"
|
||||
down_revision: Union[str, Sequence[str], None] = "a7c9e3f5b205"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"alert_state",
|
||||
sa.Column(
|
||||
"id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("alert_key", sa.String(60), nullable=False, unique=True),
|
||||
sa.Column("severity", sa.String(20), nullable=False),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
sa.Column("observed", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"opened_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("last_notified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("notify_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_alert_state_open", "alert_state", ["resolved_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_alert_state_open", table_name="alert_state")
|
||||
op.drop_table("alert_state")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Telling a customer's own systems when something happens.
|
||||
|
||||
There is already an outbox, and it delivers to **modules** — components this
|
||||
platform runs, registered by a superadmin, with credentials the platform issued.
|
||||
A customer cannot use it. If they want to know when a user is added, their only
|
||||
option is to poll, which means either stale data or a script hammering the API on
|
||||
a timer.
|
||||
|
||||
This is the same idea pointed the other way: a workspace registers its own URL,
|
||||
picks the events it cares about, and gets a signed POST when one happens.
|
||||
|
||||
Two things it does that the module outbox does not have to:
|
||||
|
||||
**The URL is attacker-supplied.** A workspace administrator can type anything,
|
||||
and the *server* is the one that fetches it — so `169.254.169.254` is the cloud
|
||||
metadata service and `127.0.0.1:5432` is this database. Every delivery goes
|
||||
through the same resolve-then-pin check the identity-provider discovery uses.
|
||||
|
||||
**Failure has to be visible and self-limiting.** A module that stops answering is
|
||||
an incident somebody is paged for. A customer endpoint that stops answering is
|
||||
Tuesday — a certificate expired, a firewall rule changed, someone deleted the
|
||||
Lambda. So deliveries are recorded per attempt, and an endpoint that fails
|
||||
consistently is disabled rather than retried for ever behind live traffic.
|
||||
|
||||
Revision ID: b8e1c4a6f90c
|
||||
Revises: a7d9f1c3e80b
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision: str = "b8e1c4a6f90c"
|
||||
down_revision: Union[str, Sequence[str], None] = "a7d9f1c3e80b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCOPED = ("webhook_endpoints", "webhook_deliveries")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"webhook_endpoints",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("url", sa.String(2048), nullable=False),
|
||||
sa.Column("description", sa.String(255), nullable=True),
|
||||
sa.Column("event_types", JSONB(), nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("secret_enc", sa.Text(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("disabled_reason", sa.String(255), nullable=True),
|
||||
sa.Column("consecutive_failures", sa.Integer(), nullable=False,
|
||||
server_default="0"),
|
||||
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_webhook_endpoints_tenant", "webhook_endpoints", ["tenant_id"])
|
||||
|
||||
op.create_table(
|
||||
"webhook_deliveries",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("endpoint_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("webhook_endpoints.id", ondelete="CASCADE"),
|
||||
nullable=False),
|
||||
sa.Column("event_id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("event_type", sa.String(120), nullable=False),
|
||||
sa.Column("payload", JSONB(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("response_status", sa.Integer(), nullable=True),
|
||||
sa.Column("error", sa.String(1000), nullable=True),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_webhook_deliveries_endpoint", "webhook_deliveries",
|
||||
["endpoint_id"])
|
||||
op.create_index(
|
||||
"ix_webhook_deliveries_due", "webhook_deliveries",
|
||||
["next_attempt_at"],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
|
||||
for table in SCOPED:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in SCOPED:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.drop_index("ix_webhook_deliveries_due", table_name="webhook_deliveries")
|
||||
op.drop_index("ix_webhook_deliveries_endpoint", table_name="webhook_deliveries")
|
||||
op.drop_table("webhook_deliveries")
|
||||
op.drop_index("ix_webhook_endpoints_tenant", table_name="webhook_endpoints")
|
||||
op.drop_table("webhook_endpoints")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add_provisioning_endpoint_to_module_environment
|
||||
|
||||
Revision ID: c37ba6143f83
|
||||
Revises: 91cc93992a91
|
||||
Create Date: 2026-01-24 10:04:06.239899
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'c37ba6143f83'
|
||||
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('module_environments', 'provisioning_endpoint')
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Enforce workspace isolation in the database rather than in every query.
|
||||
|
||||
Until now isolation was a convention: each query had to remember to filter by
|
||||
`tenant_id`. Finding S-5 was one that forgot, and forgetting was possible because
|
||||
nothing outside the developer's memory was checking.
|
||||
|
||||
**This migration is inert until the application connects as a non-owner role.**
|
||||
A PostgreSQL superuser bypasses row-level security unconditionally, and the table
|
||||
owner does too — which is why every table here is FORCEd, and why
|
||||
`scripts/create_app_role.py` exists. Applying this while still connecting as the
|
||||
owner turns the policies on for that connection as well, so the code that sets
|
||||
`app.tenant_id` has to be in place first. It ships in the same change.
|
||||
|
||||
Two settings drive the policies:
|
||||
|
||||
app.tenant_id the workspace, or '' when none is set
|
||||
app.bypass_rls 'on' for deliberate cross-workspace work
|
||||
|
||||
Unset means '' means no rows. A code path that forgets to establish context
|
||||
therefore fails loudly rather than quietly reading everyone's data.
|
||||
|
||||
Revision ID: c3d5e7f9a801
|
||||
Revises: b2e1d4f5a602
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c3d5e7f9a801"
|
||||
down_revision: Union[str, Sequence[str], None] = "b2e1d4f5a602"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
BYPASS = "current_setting('app.bypass_rls', true) = 'on'"
|
||||
CURRENT = "NULLIF(current_setting('app.tenant_id', true), '')::uuid"
|
||||
|
||||
STRICT = ("users", "tenant_modules")
|
||||
|
||||
SHARED_NULLS = ("roles", "sso_grants")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in STRICT:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING ({BYPASS} OR tenant_id = {CURRENT})
|
||||
WITH CHECK ({BYPASS} OR tenant_id = {CURRENT})
|
||||
"""
|
||||
)
|
||||
|
||||
for table in SHARED_NULLS:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING ({BYPASS} OR tenant_id IS NULL OR tenant_id = {CURRENT})
|
||||
WITH CHECK ({BYPASS} OR tenant_id = {CURRENT})
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in (*STRICT, *SHARED_NULLS):
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Files attached to things.
|
||||
|
||||
## What is assumed here, because it is a product decision and not a technical one
|
||||
|
||||
**A document attaches to an entity by type and id**, loosely — `("user",
|
||||
<uuid>)`, `("invoice", <uuid>)` — rather than by a foreign key per attachable
|
||||
table. A key per table is stricter and needs a migration every time something new
|
||||
becomes attachable, which in a platform meant to host modules is every time a
|
||||
module ships. The looseness is paid for by the API refusing an unknown
|
||||
`entity_type` rather than storing whatever it is handed.
|
||||
|
||||
If the answer turns out to be "documents belong to one specific thing", this
|
||||
becomes a narrower table with a real foreign key, and the migration is small.
|
||||
|
||||
**Storage is a local directory behind an interface.** There is no object-store
|
||||
credential in this deployment and inventing one would be inventing an
|
||||
infrastructure decision. The interface is narrow enough that S3 is one class
|
||||
rather than a rewrite.
|
||||
|
||||
## The columns that are load-bearing
|
||||
|
||||
**`storage_key` is random and unrelated to the filename.** Deriving a path from
|
||||
what somebody typed is how `../../etc/passwd` gets written, how two people
|
||||
uploading `report.pdf` overwrite each other, and how a URL becomes guessable. The
|
||||
original name is kept in a column, for display only.
|
||||
|
||||
**`content_type` is what *we* determined, not what the client claimed.** A
|
||||
browser will happily execute an uploaded `.html` served back as `text/html` from
|
||||
the console's own origin, which is a stored cross-site scripting hole with a file
|
||||
picker attached.
|
||||
|
||||
**`checksum`** so a re-upload of the identical file is recognisable, and so
|
||||
corruption is detectable rather than silent.
|
||||
|
||||
**`deleted_at`, with the blob removed.** Storage costs money and a deleted file
|
||||
should stop costing it; the row stays because "who deleted that attachment, and
|
||||
when" is exactly what gets asked afterwards.
|
||||
|
||||
Revision ID: c8f1b4e7a03d
|
||||
Revises: b6e3a1d9f42c
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "c8f1b4e7a03d"
|
||||
down_revision: Union[str, Sequence[str], None] = "b6e3a1d9f42c"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"documents",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("uploaded_by_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("entity_type", sa.String(60), nullable=True),
|
||||
sa.Column("entity_id", sa.String(64), nullable=True),
|
||||
sa.Column("filename", sa.String(255), nullable=False),
|
||||
sa.Column("content_type", sa.String(120), nullable=False),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("checksum", sa.String(64), nullable=False),
|
||||
sa.Column("storage_key", sa.String(120), nullable=False),
|
||||
sa.Column("description", sa.String(500), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_by_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("storage_key", name="uq_documents_storage_key"),
|
||||
)
|
||||
op.create_index("ix_documents_tenant", "documents", ["tenant_id"])
|
||||
op.create_index(
|
||||
"ix_documents_entity", "documents", ["tenant_id", "entity_type", "entity_id"],
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE documents ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE documents FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON documents
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON documents")
|
||||
op.drop_index("ix_documents_entity", table_name="documents")
|
||||
op.drop_index("ix_documents_tenant", table_name="documents")
|
||||
op.drop_table("documents")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""One account per address, however it was typed.
|
||||
|
||||
`users.email` had a plain unique index, so `Alice@example.com` and
|
||||
`alice@example.com` were two accounts. Nobody types their address the same way
|
||||
twice — a signup gets one form, a password reset gets another, and the second
|
||||
finds nothing.
|
||||
|
||||
This normalises what is stored and enforces uniqueness on the normalised form.
|
||||
|
||||
**It refuses rather than failing.** If two accounts already differ only by case,
|
||||
creating the index would abort with a constraint violation naming an index and
|
||||
nothing else. Instead the collisions are found first and reported by address, so
|
||||
the person running it knows exactly what to merge. Nothing is changed in that
|
||||
case — the migration is a no-op until the data is fixed.
|
||||
|
||||
Uniqueness stays **global**, not per workspace. That is the existing behaviour
|
||||
and a deliberate constraint on multi-workspace customers: one address, one
|
||||
account. (The base application scopes it per tenant; this does not, and changing
|
||||
that is a product decision rather than a migration.)
|
||||
|
||||
Revision ID: c9e2a4b6d407
|
||||
Revises: b8d1f4a6c306
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c9e2a4b6d407"
|
||||
down_revision: Union[str, Sequence[str], None] = "b8d1f4a6c306"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
INDEX_NAME = "uq_users_email_lower"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
|
||||
collisions = connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT lower(btrim(email)) AS address,
|
||||
count(*) AS n,
|
||||
string_agg(email, ', ' ORDER BY email) AS variants
|
||||
FROM users
|
||||
GROUP BY lower(btrim(email))
|
||||
HAVING count(*) > 1
|
||||
ORDER BY 1
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
if collisions:
|
||||
detail = "\n".join(
|
||||
f" {row.address}: {row.variants} ({row.n} accounts)" for row in collisions
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Cannot enforce case-insensitive email uniqueness — these addresses "
|
||||
"already exist more than once, differing only by case:\n"
|
||||
f"{detail}\n"
|
||||
"Merge or remove the duplicates, then run this migration again. "
|
||||
"Nothing has been changed."
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET email = lower(btrim(email)) "
|
||||
"WHERE email <> lower(btrim(email))"
|
||||
)
|
||||
)
|
||||
|
||||
op.create_index(INDEX_NAME, "users", [sa.text("lower(email)")], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(INDEX_NAME, table_name="users")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Making a retried request safe to retry.
|
||||
|
||||
A client posts, the connection drops before the response arrives, and now nobody
|
||||
knows whether it worked. Every integration hits this eventually, and there are
|
||||
only two behaviours available: retry and risk doing it twice, or do not retry and
|
||||
risk not doing it at all. Both are wrong.
|
||||
|
||||
An `Idempotency-Key` header makes the choice unnecessary. The first request runs
|
||||
and its response is kept; a repeat with the same key gets that same response back
|
||||
without the work happening again.
|
||||
|
||||
Three columns carry the weight:
|
||||
|
||||
**`request_hash`.** Reusing a key for a *different* request is a client bug, and
|
||||
the dangerous kind: without this, "charge £10" retried as "charge £1000" would
|
||||
quietly return the £10 response and the caller would believe the second one
|
||||
happened. Mismatched hashes are refused rather than answered.
|
||||
|
||||
**`state`.** A key inserted before the work starts is what makes two simultaneous
|
||||
requests safe — the second loses the unique index and is told the first is still
|
||||
running, rather than both proceeding.
|
||||
|
||||
**`expires_at`.** A retry happens within seconds or minutes. Keeping keys for ever
|
||||
would mean a client that generates them per hour eventually collides with its own
|
||||
history, and the table grows without bound for no benefit.
|
||||
|
||||
Revision ID: c9f2b5d7e10d
|
||||
Revises: b8e1c4a6f90c
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision: str = "c9f2b5d7e10d"
|
||||
down_revision: Union[str, Sequence[str], None] = "b8e1c4a6f90c"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"idempotency_records",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
||||
sa.Column("endpoint", sa.String(255), nullable=False),
|
||||
sa.Column("request_hash", sa.String(64), nullable=False),
|
||||
sa.Column("state", sa.String(20), nullable=False, server_default="in_progress"),
|
||||
sa.Column("response_status", sa.Integer(), nullable=True),
|
||||
sa.Column("response_body", JSONB(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", "idempotency_key", "endpoint",
|
||||
name="uq_idempotency_scope"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idempotency_expiry", "idempotency_records", ["expires_at"],
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE idempotency_records ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE idempotency_records FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON idempotency_records
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON idempotency_records")
|
||||
op.drop_index("ix_idempotency_expiry", table_name="idempotency_records")
|
||||
op.drop_table("idempotency_records")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""make_sso_grants_stateless
|
||||
|
||||
Revision ID: cd8ba77ffd9e
|
||||
Revises: 74b6ccfaee8e
|
||||
Create Date: 2026-01-20 16:58:53.362779
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'cd8ba77ffd9e'
|
||||
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.drop_column('sso_grants', 'redirect_url')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.add_column('sso_grants', sa.Column('redirect_url', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Signing in with somebody else's identity provider.
|
||||
|
||||
Until now "SSO" here meant the platform signing users *into modules* — outbound.
|
||||
This is the direction enterprise customers mean: a workspace points at its own
|
||||
Azure AD, Okta or Google, and its people sign in there rather than holding a
|
||||
password on this platform.
|
||||
|
||||
Three tables, all workspace-scoped and all under row-level security. A provider's
|
||||
configuration is a workspace's own business, and `user_identities` says which of
|
||||
their staff exist at which external directory.
|
||||
|
||||
Revision ID: d1a3c5e7f508
|
||||
Revises: c9e2a4b6d407
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "d1a3c5e7f508"
|
||||
down_revision: Union[str, Sequence[str], None] = "c9e2a4b6d407"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCOPED = ("identity_providers", "user_identities", "sso_login_states")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"identity_providers",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("kind", sa.String(10), nullable=False, server_default="OIDC"),
|
||||
sa.Column("name", sa.String(150), nullable=False),
|
||||
sa.Column("slug", sa.String(100), nullable=False),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("issuer", sa.String(500), nullable=True),
|
||||
sa.Column("client_id", sa.String(255), nullable=True),
|
||||
sa.Column("client_secret_enc", sa.Text(), nullable=True),
|
||||
sa.Column("scopes", sa.String(500), nullable=False,
|
||||
server_default="openid email profile"),
|
||||
sa.Column("authorization_endpoint", sa.String(500), nullable=True),
|
||||
sa.Column("token_endpoint", sa.String(500), nullable=True),
|
||||
sa.Column("jwks_uri", sa.String(500), nullable=True),
|
||||
sa.Column("discovered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("allowed_domains", sa.Text(), nullable=True),
|
||||
sa.Column("jit_provisioning", sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.Column("default_role_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("link_existing_by_email", sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", "slug", name="uq_identity_provider_slug"),
|
||||
)
|
||||
op.create_index("ix_identity_providers_tenant", "identity_providers", ["tenant_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_identities",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("provider_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("identity_providers.id", ondelete="CASCADE"),
|
||||
nullable=False),
|
||||
sa.Column("subject", sa.String(255), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("provider_id", "subject", name="uq_user_identity_subject"),
|
||||
)
|
||||
op.create_index("ix_user_identities_tenant", "user_identities", ["tenant_id"])
|
||||
op.create_index("ix_user_identities_user", "user_identities", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"sso_login_states",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("provider_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("identity_providers.id", ondelete="CASCADE"),
|
||||
nullable=False),
|
||||
sa.Column("state", sa.String(128), nullable=False, unique=True),
|
||||
sa.Column("nonce", sa.String(128), nullable=False),
|
||||
sa.Column("code_verifier", sa.String(256), nullable=False),
|
||||
sa.Column("redirect_to", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sso_login_states_state", "sso_login_states", ["state"])
|
||||
op.create_index("ix_sso_login_states_expiry", "sso_login_states", ["expires_at"])
|
||||
|
||||
for table in SCOPED:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in SCOPED:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.drop_index("ix_sso_login_states_expiry", table_name="sso_login_states")
|
||||
op.drop_index("ix_sso_login_states_state", table_name="sso_login_states")
|
||||
op.drop_table("sso_login_states")
|
||||
op.drop_index("ix_user_identities_user", table_name="user_identities")
|
||||
op.drop_index("ix_user_identities_tenant", table_name="user_identities")
|
||||
op.drop_table("user_identities")
|
||||
op.drop_index("ix_identity_providers_tenant", table_name="identity_providers")
|
||||
op.drop_table("identity_providers")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Telling somebody something inside the product.
|
||||
|
||||
The platform already has three ways to say something happened, and all three
|
||||
reach somebody who is not the person who needs to know:
|
||||
|
||||
- **The log** reaches whoever greps it.
|
||||
- **The audit trail** reaches an administrator who goes looking.
|
||||
- **Email** reaches an inbox, eventually, if the address is right and the
|
||||
message is not filtered.
|
||||
|
||||
None of them reach the person sitting in the product right now. That gap is why
|
||||
a webhook endpoint disabled after twenty failures is currently a line in a log
|
||||
file: the customer whose integration just stopped has no way to find out until
|
||||
somebody notices the data is stale.
|
||||
|
||||
**Addressed to a person, not broadcast.** `user_id` is required. "Everyone in the
|
||||
workspace" sounds convenient and produces a product where nobody reads anything,
|
||||
because most notices are not for most people.
|
||||
|
||||
**Read state is per notification, not a pointer.** A "last read at" timestamp
|
||||
cannot express reading one thing and leaving another for later, which is what
|
||||
people actually do with a list.
|
||||
|
||||
Revision ID: d1a4c8f2b30e
|
||||
Revises: c9f2b5d7e10d
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision: str = "d1a4c8f2b30e"
|
||||
down_revision: Union[str, Sequence[str], None] = "c9f2b5d7e10d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("kind", sa.String(60), nullable=False),
|
||||
sa.Column("severity", sa.String(20), nullable=False, server_default="info"),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("body", sa.String(1000), nullable=True),
|
||||
sa.Column("link", sa.String(500), nullable=True),
|
||||
sa.Column("data", JSONB(), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_notifications_unread", "notifications", ["user_id", "created_at"],
|
||||
postgresql_where=sa.text("read_at IS NULL"),
|
||||
)
|
||||
op.create_index("ix_notifications_user", "notifications",
|
||||
["user_id", "created_at"])
|
||||
|
||||
op.execute("ALTER TABLE notifications ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE notifications FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON notifications
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON notifications")
|
||||
op.drop_index("ix_notifications_user", table_name="notifications")
|
||||
op.drop_index("ix_notifications_unread", table_name="notifications")
|
||||
op.drop_table("notifications")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Reference lists — the things dropdowns are made of.
|
||||
|
||||
## The assumption, stated because it is a product decision
|
||||
|
||||
**One generic pair of tables rather than a table per list.** Countries,
|
||||
currencies, document types, leave types, cost centres — each could be its own
|
||||
table with its own columns, and each would then need its own migration, its own
|
||||
CRUD, its own screen and its own permission. A platform meant to host modules
|
||||
would grow one per module.
|
||||
|
||||
The cost of the generic shape is that a list cannot carry list-specific columns.
|
||||
Where one genuinely needs them — a currency's decimal places, a country's dialling
|
||||
code — that list graduates to its own table, and this stops being where it lives.
|
||||
`metadata` exists so the common cases do not have to.
|
||||
|
||||
## The split that makes it multi-tenant
|
||||
|
||||
A list is either **the platform's** or **a workspace's**, and the difference
|
||||
matters in both directions:
|
||||
|
||||
- Platform lists (`tenant_id IS NULL`) are visible to every workspace and
|
||||
editable by none of them. ISO currency codes are not a customer's to change,
|
||||
and a workspace that renamed one would break every other workspace if these
|
||||
were shared and writable.
|
||||
- A workspace's own lists are invisible to everyone else. "Cost centre" means
|
||||
something different at every customer.
|
||||
|
||||
A workspace **can** add items to a platform list without being able to edit the
|
||||
platform's own — an item carries its own `tenant_id`, so "the standard list plus
|
||||
ours" is the ordinary case rather than a special one.
|
||||
|
||||
Revision ID: d4a7c2f8b51e
|
||||
Revises: c8f1b4e7a03d
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision: str = "d4a7c2f8b51e"
|
||||
down_revision: Union[str, Sequence[str], None] = "c8f1b4e7a03d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lookup_lists",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("code", sa.String(60), nullable=False),
|
||||
sa.Column("name", sa.String(150), nullable=False),
|
||||
sa.Column("description", sa.String(500), nullable=True),
|
||||
sa.Column("allows_custom_items", sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX uq_lookup_lists_code "
|
||||
"ON lookup_lists (tenant_id, code) NULLS NOT DISTINCT"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"lookup_items",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("list_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("lookup_lists.id", ondelete="CASCADE"),
|
||||
nullable=False),
|
||||
sa.Column("code", sa.String(60), nullable=False),
|
||||
sa.Column("label", sa.String(200), nullable=False),
|
||||
sa.Column("metadata_json", JSONB(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX uq_lookup_items_code "
|
||||
"ON lookup_items (list_id, tenant_id, code) NULLS NOT DISTINCT"
|
||||
)
|
||||
op.create_index("ix_lookup_items_list", "lookup_items", ["list_id", "sort_order"])
|
||||
|
||||
for table in ("lookup_lists", "lookup_items"):
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("lookup_items", "lookup_lists"):
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.drop_index("ix_lookup_items_list", table_name="lookup_items")
|
||||
op.execute("DROP INDEX IF EXISTS uq_lookup_items_code")
|
||||
op.drop_table("lookup_items")
|
||||
op.execute("DROP INDEX IF EXISTS uq_lookup_lists_code")
|
||||
op.drop_table("lookup_lists")
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Grace periods, cancellation, and a record of what changed.
|
||||
|
||||
Before this, a subscription had two states: working and locked out. A customer
|
||||
whose card failed went straight from one to the other, with nothing in between
|
||||
and no record of when or why it happened.
|
||||
|
||||
Adds:
|
||||
|
||||
- `subscription_plans.grace_period_days` — how long after expiry a workspace stays
|
||||
read-only rather than locked out. Per plan, because it is a commercial decision
|
||||
rather than a constant. Defaults to 0, which is exactly the old behaviour.
|
||||
- `tenants.cancelled_at` — cancellation is a decision with a date, not the absence
|
||||
of an active flag.
|
||||
- `tenant_subscription_history` — what changed, when, and who did it. Answering
|
||||
"why does this workspace have that plan" currently requires guessing.
|
||||
|
||||
Revision ID: d4f6a8b0c902
|
||||
Revises: c3d5e7f9a801
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "d4f6a8b0c902"
|
||||
down_revision: Union[str, Sequence[str], None] = "c3d5e7f9a801"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"subscription_plans",
|
||||
sa.Column("grace_period_days", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"tenants", sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tenant_subscription_history",
|
||||
sa.Column(
|
||||
"id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"from_plan_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("subscription_plans.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"to_plan_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("subscription_plans.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("change_type", sa.String(30), nullable=False),
|
||||
sa.Column("from_end_date", sa.Date(), nullable=True),
|
||||
sa.Column("to_end_date", sa.Date(), nullable=True),
|
||||
sa.Column("from_status", sa.String(30), nullable=True),
|
||||
sa.Column("to_status", sa.String(30), nullable=True),
|
||||
sa.Column(
|
||||
"changed_by_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("changed_by_email", sa.String(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_tenant_subscription_history_tenant",
|
||||
"tenant_subscription_history",
|
||||
["tenant_id", sa.text("created_at DESC")],
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE tenant_subscription_history ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE tenant_subscription_history FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON tenant_subscription_history
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON tenant_subscription_history")
|
||||
op.drop_index(
|
||||
"ix_tenant_subscription_history_tenant", table_name="tenant_subscription_history"
|
||||
)
|
||||
op.drop_table("tenant_subscription_history")
|
||||
op.drop_column("tenants", "cancelled_at")
|
||||
op.drop_column("subscription_plans", "grace_period_days")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Deleting a person without destroying the record of them.
|
||||
|
||||
`db.delete(user)` removes the row and, with it, everything that hung off it: the
|
||||
second factor, the API keys, the sessions, the notifications. The audit trail
|
||||
survives — `audit_logs.performed_by_id` has no foreign key, and the address is
|
||||
stored beside it as text — but "who was that account" becomes unanswerable, and
|
||||
an accidental deletion is unrecoverable.
|
||||
|
||||
**The row stays, marked.** Sign-in, listing and seat counting all skip it, so
|
||||
from every direction that matters the account is gone.
|
||||
|
||||
**The address is released.** This is the part that is easy to get wrong in the
|
||||
other direction: `users.email` is globally unique, so a row kept for ever would
|
||||
hold that address for ever — and the same person could never be added to a
|
||||
different workspace, for reasons nobody could see. On deletion the address moves
|
||||
to `deleted_email` and `email` becomes a tombstone. The record of who they were
|
||||
survives; the claim on the address does not.
|
||||
|
||||
**Restoring is possible, and can fail.** Somebody else may have taken the address
|
||||
in the meantime, which is a legitimate outcome and is refused explicitly rather
|
||||
than colliding with the index.
|
||||
|
||||
Revision ID: e2b5d9a3c41f
|
||||
Revises: d1a4c8f2b30e
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "e2b5d9a3c41f"
|
||||
down_revision: Union[str, Sequence[str], None] = "d1a4c8f2b30e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users",
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("users",
|
||||
sa.Column("deleted_by_id", UUID(as_uuid=True), nullable=True))
|
||||
op.add_column("users", sa.Column("deleted_email", sa.String(255), nullable=True))
|
||||
|
||||
op.create_index(
|
||||
"ix_users_deleted_at", "users", ["deleted_at"],
|
||||
postgresql_where=sa.text("deleted_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_deleted_at", table_name="users")
|
||||
op.drop_column("users", "deleted_email")
|
||||
op.drop_column("users", "deleted_by_id")
|
||||
op.drop_column("users", "deleted_at")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""A second factor, and a limit on guessing.
|
||||
|
||||
Two additions to signing in, kept in one migration because they are the same
|
||||
concern: whether the person at the keyboard is who the password says.
|
||||
|
||||
**MFA (TOTP).** A password is a secret that gets reused, phished and breached
|
||||
elsewhere. A time-based code is not, because it is worth six seconds.
|
||||
|
||||
**Lockout.** Rate limiting (finding 1.8) throttles a *source*; it does nothing
|
||||
about an attacker spreading attempts across addresses, and it keeps no record
|
||||
against the account being attacked. These columns are per account, so the
|
||||
hundredth guess against one person is refused whoever is making it.
|
||||
|
||||
Revision ID: e3b5d7a9c609
|
||||
Revises: d1a3c5e7f508
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "e3b5d7a9c609"
|
||||
down_revision: Union[str, Sequence[str], None] = "d1a3c5e7f508"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCOPED = ("user_mfa", "mfa_recovery_codes")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("failed_login_attempts", sa.Integer(), nullable=False,
|
||||
server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("last_failed_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_users_locked_until", "users", ["locked_until"],
|
||||
postgresql_where=sa.text("locked_until IS NOT NULL"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_mfa",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("secret_enc", sa.Text(), nullable=False),
|
||||
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_counter", sa.BigInteger(), nullable=True),
|
||||
sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", name="uq_user_mfa_user"),
|
||||
)
|
||||
op.create_index("ix_user_mfa_user", "user_mfa", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"mfa_recovery_codes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("code_hash", sa.String(255), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_mfa_recovery_user", "mfa_recovery_codes", ["user_id"])
|
||||
|
||||
for table in SCOPED:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE POLICY tenant_isolation ON {table}
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in SCOPED:
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.drop_index("ix_mfa_recovery_user", table_name="mfa_recovery_codes")
|
||||
op.drop_table("mfa_recovery_codes")
|
||||
op.drop_index("ix_user_mfa_user", table_name="user_mfa")
|
||||
op.drop_table("user_mfa")
|
||||
op.drop_index("ix_users_locked_until", table_name="users")
|
||||
op.drop_column("users", "last_failed_login_at")
|
||||
op.drop_column("users", "locked_until")
|
||||
op.drop_column("users", "failed_login_attempts")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Sessions people can see and end.
|
||||
|
||||
Revocation lived only in a Redis blacklist. Three problems with that: it failed
|
||||
open, because a Redis outage made the check log a warning and accept the token;
|
||||
it did not survive a flush, so revoked tokens came back; and nothing recorded a
|
||||
session at all, so nobody could be shown where they were signed in.
|
||||
|
||||
Revision ID: e5a7b9c1d003
|
||||
Revises: d4f6a8b0c902
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import INET, UUID
|
||||
|
||||
revision: str = "e5a7b9c1d003"
|
||||
down_revision: Union[str, Sequence[str], None] = "d4f6a8b0c902"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_sessions",
|
||||
sa.Column(
|
||||
"id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("current_jti", UUID(as_uuid=True), nullable=False, unique=True),
|
||||
sa.Column("previous_jti", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||
sa.Column("ip_address", INET(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"last_used_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_reason", sa.String(40), nullable=True),
|
||||
)
|
||||
op.create_index("ix_user_sessions_user", "user_sessions", ["user_id"])
|
||||
op.create_index("ix_user_sessions_previous_jti", "user_sessions", ["previous_jti"])
|
||||
op.create_index(
|
||||
"ix_user_sessions_live", "user_sessions", ["revoked_at", "expires_at"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_sessions_live", table_name="user_sessions")
|
||||
op.drop_index("ix_user_sessions_previous_jti", table_name="user_sessions")
|
||||
op.drop_index("ix_user_sessions_user", table_name="user_sessions")
|
||||
op.drop_table("user_sessions")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""An index the retention sweep can actually use.
|
||||
|
||||
`ix_audit_logs_...` on `(tenant_id, created_at)` serves the operations views,
|
||||
which always ask about one workspace. Retention asks the opposite question — the
|
||||
oldest rows *across every workspace*, split by whether the module is one of the
|
||||
security ones — and that index cannot help with it.
|
||||
|
||||
At a few thousand rows nothing notices. The sweep exists precisely for the table
|
||||
that has grown for two years, which is where a sequential scan per batch, twenty
|
||||
batches a night, becomes the reason the nightly job never finishes.
|
||||
|
||||
Revision ID: e7b2d5c9a14f
|
||||
Revises: d4a7c2f8b51e
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e7b2d5c9a14f"
|
||||
down_revision: Union[str, Sequence[str], None] = "d4a7c2f8b51e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_audit_logs_module_created", "audit_logs", ["module_name", "created_at"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_logs_module_created", table_name="audit_logs")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Making the user search use an index.
|
||||
|
||||
The search on the user list is `ILIKE '%term%'` across four columns. A leading
|
||||
wildcard means no B-tree index can be used, so every keystroke in the search box
|
||||
is a sequential scan of `users` — four times over, with an `OR` between them.
|
||||
|
||||
At the sizes this runs at today that is invisible. It is also exactly the shape
|
||||
of query that is invisible until the afternoon it is not, because the cost grows
|
||||
with the table while the perceived behaviour does not change at all until it
|
||||
suddenly does.
|
||||
|
||||
`pg_trgm` indexes substrings, which is what a contains-search actually needs. The
|
||||
query does not change; PostgreSQL simply stops scanning.
|
||||
|
||||
**A GIN index rather than GiST.** GIN is larger and slower to update, and this
|
||||
table is read far more than it is written — a user row changes when somebody
|
||||
edits a profile, and is searched every time an administrator types a character.
|
||||
|
||||
**The extension is created if it is absent**, and the migration says so plainly
|
||||
rather than failing on a database where nobody thought to enable it. It ships
|
||||
with PostgreSQL; it is not an external dependency.
|
||||
|
||||
Revision ID: f3c6e1b8d52a
|
||||
Revises: e2b5d9a3c41f
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f3c6e1b8d52a"
|
||||
down_revision: Union[str, Sequence[str], None] = "e2b5d9a3c41f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_email_trgm "
|
||||
"ON users USING gin (email gin_trgm_ops)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_first_name_trgm "
|
||||
"ON users USING gin (first_name gin_trgm_ops)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_last_name_trgm "
|
||||
"ON users USING gin (last_name gin_trgm_ops)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_audit_entity_name_trgm "
|
||||
"ON audit_logs USING gin (entity_name gin_trgm_ops)"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Skipping pg_trgm extension and indexes due to permissions: {e}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_audit_entity_name_trgm"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_last_name_trgm"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_first_name_trgm"))
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_email_trgm"))
|
||||
except Exception as e:
|
||||
print(f"Skipping pg_trgm index drop: {e}")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Inviting somebody instead of choosing their password for them.
|
||||
|
||||
Today an administrator creates an account by typing a password into a form. That
|
||||
password is then known to two people, and the one it does not belong to is the
|
||||
one with administrative access. Everything downstream inherits the problem: "the
|
||||
account holder approved this" is not a claim the audit trail can support, and
|
||||
neither can a second factor enrolled on an account somebody else could sign into.
|
||||
|
||||
An invitation is a one-time secret sent to the address being invited. The person
|
||||
who accepts it sets their own password, and nobody else has ever known it.
|
||||
|
||||
**The token is hashed, not stored.** It arrives in an email — in transit, in a
|
||||
mailbox, in a mail server's logs — and the platform only ever needs to *check*
|
||||
one. A readable column would turn a database dump into a set of working accounts
|
||||
on workspaces the reader was never part of.
|
||||
|
||||
**A seat is checked twice**: when the invitation is sent and again when it is
|
||||
accepted. Ten invitations against nine free seats is a normal thing to do, and
|
||||
the tenth acceptance is where it has to be refused.
|
||||
|
||||
Revision ID: f5c7e9b1d70a
|
||||
Revises: e3b5d7a9c609
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "f5c7e9b1d70a"
|
||||
down_revision: Union[str, Sequence[str], None] = "e3b5d7a9c609"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_invitations",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("first_name", sa.String(100), nullable=True),
|
||||
sa.Column("last_name", sa.String(100), nullable=True),
|
||||
sa.Column("role_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("invited_by_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("accepted_user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("token_hash", name="uq_user_invitations_token"),
|
||||
)
|
||||
op.create_index("ix_user_invitations_tenant", "user_invitations", ["tenant_id"])
|
||||
|
||||
op.create_index(
|
||||
"uq_user_invitations_pending",
|
||||
"user_invitations",
|
||||
["tenant_id", "email"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("accepted_at IS NULL AND revoked_at IS NULL"),
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE user_invitations ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE user_invitations FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON user_invitations
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON user_invitations")
|
||||
op.drop_index("uq_user_invitations_pending", table_name="user_invitations")
|
||||
op.drop_index("ix_user_invitations_tenant", table_name="user_invitations")
|
||||
op.drop_table("user_invitations")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Give the audit log a workspace, and hide it behind one.
|
||||
|
||||
The audit log endpoint required only an authenticated session. It had no
|
||||
workspace filter, and the table had no workspace column to filter on — so any
|
||||
user of any customer could read every audit entry on the platform: who did what,
|
||||
to which named entity, from which IP, with the full `old_values` / `new_values`
|
||||
of workspace and plan changes, and every administrator's email address across
|
||||
every customer.
|
||||
|
||||
`tenant_id` is nullable because rows written before this migration cannot be
|
||||
attributed. A NULL is treated as a platform row: visible to superadmins, hidden
|
||||
from workspaces — the safe reading of "we do not know whose this was".
|
||||
|
||||
Revision ID: f6b8c2d4e104
|
||||
Revises: e5a7b9c1d003
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "f6b8c2d4e104"
|
||||
down_revision: Union[str, Sequence[str], None] = "e5a7b9c1d003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"audit_logs",
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_audit_logs_tenant_created",
|
||||
"audit_logs",
|
||||
["tenant_id", sa.text("created_at DESC")],
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON audit_logs
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON audit_logs")
|
||||
op.drop_index("ix_audit_logs_tenant_created", table_name="audit_logs")
|
||||
op.drop_column("audit_logs", "tenant_id")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Letting somebody turn a notification off.
|
||||
|
||||
Notifications are addressed to a person, deduplicated, and raised only for things
|
||||
worth raising — and none of that helps if the one you get every day is the one
|
||||
you do not want. A bell somebody has learned to ignore is worse than no bell,
|
||||
because it also hides the notice that mattered.
|
||||
|
||||
## Why a row means "changed from the default"
|
||||
|
||||
The absence of a row means **enabled**. Nothing is written when somebody accepts
|
||||
the defaults, which is almost everybody, so the table stays roughly the size of
|
||||
the number of people who have actually expressed an opinion.
|
||||
|
||||
The alternative — a row per person per kind per channel, written on account
|
||||
creation — is thousands of rows saying "yes, the default" and a migration every
|
||||
time a new kind is added. It also has a worse failure mode: a kind added without
|
||||
backfilling the table is a notification nobody receives, and nothing reports it.
|
||||
|
||||
## Why the channel is part of the key
|
||||
|
||||
"Tell me in the product but do not email me" is the setting people actually want,
|
||||
and it is not expressible without it. Today only the in-app channel exists; email
|
||||
and webhook are named so the column does not have to change when they do.
|
||||
|
||||
Revision ID: f8c3a6e1b72d
|
||||
Revises: e7b2d5c9a14f
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "f8c3a6e1b72d"
|
||||
down_revision: Union[str, Sequence[str], None] = "e7b2d5c9a14f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notification_preferences",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("notification_type", sa.String(100), nullable=False),
|
||||
sa.Column("channel", sa.String(50), nullable=False, server_default="in_app"),
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "notification_type", "channel",
|
||||
name="uq_notification_preference"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_notification_preferences_user", "notification_preferences",
|
||||
["user_id", "notification_type"],
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE notification_preferences FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY tenant_isolation ON notification_preferences
|
||||
USING (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
current_setting('app.bypass_rls', true) = 'on'
|
||||
OR tenant_id IS NULL
|
||||
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON notification_preferences")
|
||||
op.drop_index("ix_notification_preferences_user",
|
||||
table_name="notification_preferences")
|
||||
op.drop_table("notification_preferences")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add_audit_log_extra_columns
|
||||
|
||||
Revision ID: f9cf173f48f9
|
||||
Revises: 73b754d5b2c5
|
||||
Create Date: 2026-03-31 13:57:11.328101
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = 'f9cf173f48f9'
|
||||
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column('audit_logs', sa.Column('entity_id', sa.String(length=255), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('entity_name', sa.String(length=255), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('performed_by_id', sa.UUID(), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('performed_by_email', sa.String(length=255), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('ip_address', sa.String(length=45), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.create_index(op.f('ix_audit_logs_action_type'), 'audit_logs', ['action_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_performed_by_email'), 'audit_logs', ['performed_by_email'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f('ix_audit_logs_performed_by_email'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs')
|
||||
op.drop_column('audit_logs', 'new_values')
|
||||
op.drop_column('audit_logs', 'old_values')
|
||||
op.drop_column('audit_logs', 'ip_address')
|
||||
op.drop_column('audit_logs', 'performed_by_email')
|
||||
op.drop_column('audit_logs', 'performed_by_id')
|
||||
op.drop_column('audit_logs', 'entity_name')
|
||||
op.drop_column('audit_logs', 'entity_id')
|
||||
+207
-26
@@ -1,3 +1,4 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer
|
||||
@@ -5,14 +6,25 @@ import logging
|
||||
from sqlalchemy import text
|
||||
from app.config.settings import settings
|
||||
from app.config.database import engine
|
||||
|
||||
# Import models for Alembic
|
||||
from app.routes.admin import audit_logs, operations
|
||||
import app.models.auth.user_model
|
||||
import app.models.auth.role_model
|
||||
import app.models.auth.tenant_model
|
||||
import app.models.theme.color_palette_model
|
||||
import app.models.auth.module_model
|
||||
import app.models.auth.module_environment_model
|
||||
import app.models.auth.tenant_module_model
|
||||
import app.models.auth.sso_grant_model
|
||||
import app.models.auth.access_model
|
||||
import app.models.auth.subscription_plan_model
|
||||
import app.models.system.event_log_model
|
||||
import app.models.system.audit_log
|
||||
import asyncio
|
||||
from app.services.auth.event_service import EventService
|
||||
from app.config.database import SessionLocal
|
||||
from app.core.redis import redis_client, sync_redis_client
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=settings.LOG_LEVEL.upper(),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
@@ -21,8 +33,102 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def wait_for_db(retries: int = 10, base_delay: float = 3.0, max_delay: float = 30.0):
|
||||
"""Wait for the database to become available with exponential backoff."""
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
logger.info("Database connection successful!")
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == retries:
|
||||
logger.error(f"Database unreachable after {retries} attempts: {e}")
|
||||
raise
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
logger.warning(
|
||||
f"Database not ready (attempt {attempt}/{retries}): {e}. "
|
||||
f"Retrying in {delay:.0f}s..."
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting SaaS application...")
|
||||
|
||||
await wait_for_db()
|
||||
|
||||
app.state.redis_available = False
|
||||
|
||||
try:
|
||||
await redis_client.connect()
|
||||
await run_in_threadpool(sync_redis_client.connect)
|
||||
app.state.redis_available = True
|
||||
logger.info("Redis connected successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis unavailable at startup: {e}")
|
||||
logger.warning("Continuing without Redis — event queue disabled.")
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
|
||||
async def redis_event_consumer():
|
||||
logger.info("Redis Event Consumer STARTED")
|
||||
while True:
|
||||
try:
|
||||
if not app.state.redis_available or not redis_client.client:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
||||
|
||||
if result:
|
||||
_, event_id_bytes = result
|
||||
event_id = event_id_bytes.decode("utf-8") if isinstance(event_id_bytes, bytes) else event_id_bytes
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event {event_id}: {e}")
|
||||
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def fallback_poller():
|
||||
logger.info("Fallback Event Poller STARTED")
|
||||
while True:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_outbox, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback poller error: {e}")
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
background_tasks = [
|
||||
asyncio.create_task(redis_event_consumer()),
|
||||
asyncio.create_task(fallback_poller()),
|
||||
]
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down...")
|
||||
for task in background_tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||||
if app.state.redis_available:
|
||||
try:
|
||||
await redis_client.close()
|
||||
await run_in_threadpool(sync_redis_client.close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
from app.core.rls import register_rls_listener
|
||||
|
||||
register_rls_listener()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
@@ -30,9 +136,9 @@ def create_app() -> FastAPI:
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === OpenAPI Security Scheme ===
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
def custom_openapi():
|
||||
@@ -56,7 +162,6 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
# === CORS ===
|
||||
origins = []
|
||||
if settings.CORS_ALLOWED_ORIGINS:
|
||||
origins = [
|
||||
@@ -70,46 +175,117 @@ def create_app() -> FastAPI:
|
||||
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
|
||||
)
|
||||
|
||||
from app.middleware.tenant_scope_middleware import TenantScopeMiddleware
|
||||
|
||||
app.add_middleware(TenantScopeMiddleware)
|
||||
|
||||
from app.middleware.idempotency_middleware import IdempotencyMiddleware
|
||||
|
||||
app.add_middleware(IdempotencyMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-MFA-Required", "Idempotent-Replay"],
|
||||
)
|
||||
|
||||
# === Include Routers ===
|
||||
from app.routes.auth.auth import router as auth_router
|
||||
from app.routes.auth.tenant import router as tenant_router
|
||||
from app.routes.auth.role import router as role_router
|
||||
from app.routes.auth.access import router as access_router
|
||||
from app.routes.auth.user import router as user_router
|
||||
from app.routes.auth.sso import public_router as sso_public_router, internal_router as sso_internal_router
|
||||
from app.routes.api.module import router as module_router
|
||||
from app.routes.auth.subscription_plan import router as subscription_plan_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"])
|
||||
app.include_router(role_router, prefix="/api/role", tags=["Role Management"])
|
||||
app.include_router(access_router, prefix="/api/access", tags=["Access Management"])
|
||||
app.include_router(user_router, prefix="/api/user", tags=["User Management"])
|
||||
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
|
||||
from app.routes.auth.identity_provider import (
|
||||
public_router as idp_public_router,
|
||||
router as idp_admin_router,
|
||||
)
|
||||
from app.routes.internal.identity import router as identity_router
|
||||
from app.routes.internal.module import router as internal_module_router
|
||||
|
||||
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
|
||||
app.include_router(module_router, prefix="/api/modules", tags=["Modules"])
|
||||
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal Modules"])
|
||||
app.include_router(identity_router, prefix="/.well-known", tags=["Module Identity"])
|
||||
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
|
||||
|
||||
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
logger.info("Testing database connection...")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.commit()
|
||||
logger.info("Database connection successful!")
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection failed: {e}")
|
||||
raise
|
||||
app.include_router(idp_admin_router, prefix="/api/admin/sso", tags=["Admin - SSO"])
|
||||
app.include_router(idp_public_router, prefix="/api/sso/idp", tags=["SSO - Identity Providers"])
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
from app.routes.auth.invitation import (
|
||||
public_router as invitation_public_router,
|
||||
router as invitation_router,
|
||||
)
|
||||
app.include_router(invitation_router, prefix="/api/user/invitations",
|
||||
tags=["User Management - Invitations"])
|
||||
app.include_router(invitation_public_router, prefix="/api/invitations",
|
||||
tags=["Invitations"])
|
||||
|
||||
from app.routes.auth.api_key import router as api_key_router
|
||||
app.include_router(api_key_router, prefix="/api/api-keys", tags=["API Keys"])
|
||||
|
||||
from app.routes.system.webhook import router as webhook_router
|
||||
app.include_router(webhook_router, prefix="/api/webhooks", tags=["Webhooks"])
|
||||
|
||||
from app.routes.auth.scim import router as scim_router
|
||||
app.include_router(scim_router, prefix="/scim/v2", tags=["SCIM"])
|
||||
|
||||
from fastapi.responses import JSONResponse as _JSONResponse
|
||||
|
||||
from app.services.auth.scim_service import ScimError
|
||||
|
||||
@app.exception_handler(ScimError)
|
||||
async def _scim_error(request, exc: ScimError):
|
||||
return _JSONResponse(
|
||||
exc.detail, status_code=exc.status_code,
|
||||
media_type="application/scim+json",
|
||||
)
|
||||
|
||||
# === Basic Routes ===
|
||||
from app.routes.system.notification import router as notification_router
|
||||
app.include_router(notification_router, prefix="/api/notifications",
|
||||
tags=["Notifications"])
|
||||
|
||||
from app.routes.auth.org_unit import router as org_unit_router
|
||||
app.include_router(org_unit_router, prefix="/api/org-units",
|
||||
tags=["Organisation"])
|
||||
|
||||
from app.routes.system.tenant_email import router as tenant_email_router
|
||||
app.include_router(tenant_email_router, prefix="/api/settings/email",
|
||||
tags=["Settings - Email"])
|
||||
|
||||
from app.routes.system.document import router as document_router
|
||||
app.include_router(document_router, prefix="/api/documents", tags=["Documents"])
|
||||
|
||||
from app.routes.system.lookup import router as lookup_router
|
||||
app.include_router(lookup_router, prefix="/api/reference", tags=["Reference data"])
|
||||
|
||||
from app.routes.auth.mfa import router as mfa_router
|
||||
app.include_router(mfa_router, prefix="/api/auth/mfa", tags=["Authentication - MFA"])
|
||||
|
||||
from app.routes.theme.color_palette import router as palette_router
|
||||
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
||||
|
||||
from app.routes.admin.modules import router as admin_modules_router
|
||||
from app.routes.admin.module_environments import router as admin_module_env_router
|
||||
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
|
||||
|
||||
app.include_router(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"])
|
||||
app.include_router(operations.router, prefix="/api/admin/operations", tags=["Admin - Operations"])
|
||||
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
|
||||
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
|
||||
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
def root():
|
||||
"""
|
||||
@@ -138,16 +314,21 @@ def create_app() -> FastAPI:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"unhealthy: {str(e)}"
|
||||
logger.error(f"Health check DB error: {e}")
|
||||
db_status = "unhealthy"
|
||||
|
||||
from app.services.auth import module_identity
|
||||
|
||||
module_identity_status = (
|
||||
"configured" if module_identity.is_configured() else "not configured"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
"environment": settings.APP_ENV,
|
||||
"database": db_status,
|
||||
"module_identity": module_identity_status,
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
+8
-14
@@ -9,23 +9,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
|
||||
# Build connection arguments based on SSL setting
|
||||
connect_args = {"connect_timeout": 10}
|
||||
if settings.DB_SSL:
|
||||
connect_args["sslmode"] = "require"
|
||||
|
||||
# Create the SQLAlchemy engine with optimized connection pool
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300, # Recycle connections every 5 minutes
|
||||
pool_size=20, # Increased from 5 to handle higher concurrency
|
||||
max_overflow=30, # Increased from 10 for peak load handling
|
||||
pool_timeout=30, # Connection acquisition timeout
|
||||
pool_reset_on_return='commit', # Reset connections on return
|
||||
pool_recycle=300,
|
||||
pool_size=20,
|
||||
max_overflow=30,
|
||||
pool_timeout=30,
|
||||
pool_reset_on_return='commit',
|
||||
connect_args=connect_args,
|
||||
echo=False, # Disable SQL logging in production
|
||||
future=True # Use SQLAlchemy 2.0 style
|
||||
echo=False,
|
||||
future=True
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
@@ -36,18 +34,14 @@ def get_db():
|
||||
try:
|
||||
yield db
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions without logging as database errors
|
||||
# These are application-level errors, not database errors
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
# Log actual database errors
|
||||
logger.error(f"Database error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log other unexpected errors
|
||||
logger.error(f"Unexpected database session error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
db.close()
|
||||
|
||||
+169
-30
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Security utilities for authentication and authorization.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import bcrypt
|
||||
import jwt
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import HTTPException, status
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SecurityUtils:
|
||||
"""Security utility class for authentication and authorization."""
|
||||
@@ -25,34 +24,81 @@ class SecurityUtils:
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
"""Verify a password against its hash (supports bcrypt and PBKDF2)."""
|
||||
if not plain_password or not hashed_password:
|
||||
return False
|
||||
|
||||
# Support migrated PBKDF2 hashes from DocQube
|
||||
if hashed_password.startswith("$pbkdf2-sha256$") or hashed_password.startswith("$pbkdf2$"):
|
||||
try:
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
return pbkdf2_sha256.verify(plain_password, hashed_password)
|
||||
except Exception as e:
|
||||
logger.warning(f"PBKDF2 verification failed: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'), hashed_password.encode('utf-8')
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_legacy_hash(hashed_password: str) -> bool:
|
||||
"""Check if hash is in legacy format (e.g. PBKDF2) needing upgrade to bcrypt."""
|
||||
if not hashed_password:
|
||||
return False
|
||||
return hashed_password.startswith("$pbkdf2-sha256$") or hashed_password.startswith("$pbkdf2$")
|
||||
|
||||
@staticmethod
|
||||
def generate_access_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
||||
"""Generate JWT access token."""
|
||||
def generate_access_token(
|
||||
data: Dict[str, Any],
|
||||
tenant_id: Optional[Any] = None,
|
||||
is_superadmin: bool = False,
|
||||
) -> str:
|
||||
"""Generate JWT access token.
|
||||
|
||||
Carries `tenant_id` and `is_superadmin` so the request scope can be
|
||||
established before the first query — including the one that resolves the
|
||||
caller. Both are routing hints, not authorisation: every request is still
|
||||
verified and re-checked against the database.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
if is_superadmin:
|
||||
to_encode["is_superadmin"] = True
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
|
||||
# Include tenant_id if provided
|
||||
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
|
||||
|
||||
if tenant_id:
|
||||
to_encode["tenant_id"] = str(tenant_id)
|
||||
|
||||
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.ACCESS_TOKEN_SECRET,
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
||||
"""Generate JWT refresh token."""
|
||||
def generate_refresh_token(
|
||||
data: Dict[str, Any],
|
||||
tenant_id: Optional[Any] = None,
|
||||
jti: Optional[Any] = None,
|
||||
) -> str:
|
||||
"""Generate JWT refresh token.
|
||||
|
||||
`jti` is supplied by the session store so the token and the session row
|
||||
name each other. Left to itself the token would carry an identifier
|
||||
nothing else knows, and revoking it would mean finding it first.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
"jti": str(jti) if jti else str(uuid.uuid4()),
|
||||
})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
to_encode["tenant_id"] = str(tenant_id)
|
||||
|
||||
@@ -77,7 +123,20 @@ class SecurityUtils:
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and sync_redis_client.client:
|
||||
try:
|
||||
if sync_redis_client.client.get(f"blacklist:{jti}"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis blacklist check failed: {e}")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
@@ -105,7 +164,20 @@ class SecurityUtils:
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and sync_redis_client.client:
|
||||
try:
|
||||
if sync_redis_client.client.get(f"blacklist:{jti}"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis blacklist check failed: {e}")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
@@ -118,6 +190,57 @@ class SecurityUtils:
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def decode_unverified(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read a token's claims without checking its signature or expiry.
|
||||
|
||||
For identifying *which* session a token belongs to when it is being
|
||||
thrown away — logging out, listing sessions. Never for deciding whether
|
||||
to trust it: nothing here has been verified, and an expired or forged
|
||||
token decodes just as happily as a good one.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
options={"verify_signature": False, "verify_exp": False},
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def revoke_token(token: str, secret: str) -> bool:
|
||||
"""Blacklist a token's jti for whatever remains of its lifetime.
|
||||
|
||||
Decodes without verifying expiry: an already-expired token needs no
|
||||
blacklist entry, and refusing to revoke one because it failed validation
|
||||
would leave the caller unable to clean up.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if not jti or not exp or not sync_redis_client.client:
|
||||
return False
|
||||
|
||||
remaining = int(exp - datetime.now(timezone.utc).timestamp())
|
||||
if remaining <= 0:
|
||||
return True
|
||||
try:
|
||||
sync_redis_client.client.setex(f"blacklist:{jti}", remaining, "1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke token: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_otp(length: int = 6) -> str:
|
||||
"""Generate a random OTP."""
|
||||
@@ -129,24 +252,42 @@ class SecurityUtils:
|
||||
if len(password) < 8:
|
||||
return False
|
||||
|
||||
# Check for at least one uppercase letter
|
||||
if not re.search(r'[A-Z]', password):
|
||||
return False
|
||||
|
||||
# Check for at least one lowercase letter
|
||||
if not re.search(r'[a-z]', password):
|
||||
return False
|
||||
|
||||
# Check for at least one digit
|
||||
if not re.search(r'\d', password):
|
||||
return False
|
||||
|
||||
# Check for at least one special character
|
||||
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@staticmethod
|
||||
def generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
|
||||
"""Generate short-lived module-scoped JWT (15 min default)."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"type": "module_access",
|
||||
"aud": str(module_id),
|
||||
"iat": datetime.now(timezone.utc).timestamp()
|
||||
})
|
||||
|
||||
if not settings.SAAS_PRIVATE_KEY:
|
||||
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
|
||||
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.SAAS_PRIVATE_KEY,
|
||||
algorithm="RS256",
|
||||
headers={"kid": settings.SAAS_KEY_ID}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Validate email format."""
|
||||
@@ -161,6 +302,4 @@ class SecurityUtils:
|
||||
|
||||
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
|
||||
|
||||
|
||||
# Create instance for easy importing
|
||||
security = SecurityUtils()
|
||||
security = SecurityUtils()
|
||||
|
||||
+51
-64
@@ -2,125 +2,112 @@ from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import os
|
||||
|
||||
# Load environment variables from .env files
|
||||
app_env = os.getenv("APP_ENV", "local")
|
||||
env_filename = f".env.{app_env}"
|
||||
|
||||
# Define paths
|
||||
base_path = Path(__file__).resolve().parent.parent.parent
|
||||
backend_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load specific environment file (e.g., .env.development)
|
||||
# Priority: Backend folder specific env -> Root specific env -> Backend .env -> Root .env
|
||||
load_dotenv(dotenv_path=base_path / '.env') # Load base .env first as fallback
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
_explicit = dict(os.environ)
|
||||
|
||||
load_dotenv(dotenv_path=base_path / '.env')
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
|
||||
# Override with specific environment config
|
||||
if (base_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||
if (backend_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||
|
||||
os.environ.update(_explicit)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Project
|
||||
PROJECT_NAME: str = "SaaS Architecture"
|
||||
VERSION: str = "1.0.0"
|
||||
PROJECT_NAME: str
|
||||
VERSION: str
|
||||
|
||||
# FastAPI
|
||||
PORT: int
|
||||
HOST: str
|
||||
APP_ENV: str
|
||||
SECRET_KEY: str
|
||||
ALLOWED_HOSTS: str = "*"
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL: str
|
||||
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
|
||||
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
||||
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
|
||||
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY: Optional[str] = None
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# Database settings
|
||||
ALLOW_PUBLIC_SIGNUP: bool = False
|
||||
|
||||
MODULE_TRUST_REQUIRE_REPLAY_CONTROLS: bool = False
|
||||
|
||||
ALERT_WEBHOOK_URL: Optional[str] = None
|
||||
ALERT_EMAIL: Optional[str] = None
|
||||
ALERT_RENOTIFY_MINUTES: int = 60
|
||||
ALERT_STUCK_EVENTS_THRESHOLD: int = 5
|
||||
ALERT_STUCK_EVENTS_CRITICAL: int = 50
|
||||
ALERT_TOKEN_REUSE_WINDOW_HOURS: int = 24
|
||||
MODULE_TRUST_MAX_SKEW_SECONDS: int = 120
|
||||
|
||||
DATABASE_URL: str
|
||||
DB_SSL: bool = False
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development
|
||||
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds
|
||||
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool
|
||||
REDIS_HOST: str
|
||||
REDIS_PORT: int
|
||||
REDIS_PASSWORD: Optional[str]
|
||||
REDIS_DB: int = 0
|
||||
|
||||
REDIS_ENABLED: bool = True
|
||||
REDIS_TIMEOUT: int = 10
|
||||
REDIS_MAX_CONNECTIONS: int = 10
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
if self.REDIS_PASSWORD:
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# Email
|
||||
SMTP_HOST: str
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_PORT: int
|
||||
SMTP_SECURE: bool = True
|
||||
SMTP_USER: str
|
||||
SMTP_PASSWORD: str
|
||||
EMAIL_FROM: str
|
||||
|
||||
# JWT settings
|
||||
ACCESS_TOKEN_SECRET: str
|
||||
ACCESS_TOKEN_EXPIRES: int = 86400
|
||||
ACCESS_TOKEN_EXPIRES: int = 900
|
||||
REFRESH_TOKEN_SECRET: str
|
||||
REFRESH_TOKEN_EXPIRES: int = 864000
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
|
||||
# Super Admin Setup
|
||||
|
||||
COOKIE_SECURE: bool = False
|
||||
COOKIE_DOMAIN: Optional[str] = None
|
||||
|
||||
AUDIT_RETENTION_DATABASE_URL: Optional[str] = None
|
||||
|
||||
DOCUMENT_STORAGE_PATH: str = "./storage/documents"
|
||||
DOCUMENT_MAX_BYTES: int = 25 * 1024 * 1024
|
||||
DOCUMENT_QUOTA_BYTES: int = 2 * 1024 * 1024 * 1024
|
||||
|
||||
SUPER_ADMIN_EMAIL: str
|
||||
SUPER_ADMIN_PASSWORD: str
|
||||
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
||||
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
||||
|
||||
# External SaaS Integration
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key"
|
||||
SAAS_PRIVATE_KEY: Optional[str] = None
|
||||
SAAS_KEY_ID: str = "saas-key-v1"
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID: str
|
||||
PAYPAL_CLIENT_SECRET: str
|
||||
PAYPAL_MODE: str = "sandbox"
|
||||
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
|
||||
|
||||
# AWS S3 settings
|
||||
AWS_SECRET_ACCESS_KEY: Optional[str] = None
|
||||
AWS_ACCESS_KEY_ID: Optional[str] = None
|
||||
S3_BUCKET_NAME: Optional[str] = None
|
||||
AWS_REGION: Optional[str] = "us-east-1"
|
||||
|
||||
# Property to use existing S3_BUCKET_NAME for AWS_S3_BUCKET
|
||||
@property
|
||||
def AWS_S3_BUCKET(self) -> Optional[str]:
|
||||
return self.S3_BUCKET_NAME
|
||||
|
||||
# S3 Dataset Processing Settings
|
||||
S3_PROCESSING_WORKERS: int = 4
|
||||
DOCUMENT_CHUNK_SIZE: int = 1000
|
||||
DOCUMENT_CHUNK_OVERLAP: int = 200
|
||||
PINECONE_BATCH_SIZE: int = 100
|
||||
|
||||
# Redis Chat Settings
|
||||
REDIS_CHAT_TTL: int = 86400 # 24 hours
|
||||
|
||||
REDIS_CHAT_TTL: int = 86400
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "info"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_WINDOW: int = 60
|
||||
|
||||
# Integration Settings (Optional for development)
|
||||
# Test_BASE_URL: Optional[str] = "http://localhost:8001"
|
||||
# Test2_BASE_URL: Optional[str] = "http://localhost:8002"
|
||||
# Test3_BASE_URL: Optional[str] = "http://localhost:8003"
|
||||
# INTEGRATION_TIMEOUT: int = 30
|
||||
|
||||
# Properties for FastAPI Mail compatibility
|
||||
@property
|
||||
def MAIL_USERNAME(self) -> str:
|
||||
return self.SMTP_USER
|
||||
@@ -146,4 +133,4 @@ class Settings(BaseSettings):
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from typing import List, Any
|
||||
from app.services.auth.access_service import AccessService
|
||||
from app.schemas.auth.access_schema import AccessResponse
|
||||
|
||||
class AccessController:
|
||||
@staticmethod
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[AccessResponse]:
|
||||
return AccessService.get_all_accesses(db, category)
|
||||
def get_all_accesses(db: Session, category: str = None, tenant_id: Any = None) -> List[AccessResponse]:
|
||||
return AccessService.get_all_accesses(db, category, tenant_id)
|
||||
@@ -20,13 +20,19 @@ class AuthController:
|
||||
return AuthService.create_user(db, user_data, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def signin(db: Session, signin_data: UserSignin):
|
||||
return AuthService.signin(db, signin_data)
|
||||
def signin(db: Session, signin_data: UserSignin, user_agent=None, ip_address=None):
|
||||
return AuthService.signin(
|
||||
db, signin_data, user_agent=user_agent, ip_address=ip_address
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token(db: Session, token_data: RefreshTokenRequest):
|
||||
return AuthService.refresh_access_token(db, token_data.refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token_raw(db: Session, refresh_token: str):
|
||||
return AuthService.refresh_access_token(db, refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def update_user(
|
||||
db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User
|
||||
@@ -40,8 +46,20 @@ class AuthController:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
return AuthService.logout(current_user)
|
||||
def logout(current_user: User, token: str, refresh_token: str = None, db: Session = None):
|
||||
return AuthService.logout(current_user, token, refresh_token, db=db)
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(db: Session, current_user: User, current_refresh_token: str = None):
|
||||
return AuthService.list_sessions(db, current_user, current_refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def end_session(db: Session, current_user: User, session_id):
|
||||
return AuthService.end_session(db, current_user, session_id)
|
||||
|
||||
@staticmethod
|
||||
def end_other_sessions(db: Session, current_user: User, current_refresh_token: str = None):
|
||||
return AuthService.end_other_sessions(db, current_user, current_refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.module_service import ModuleService
|
||||
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
|
||||
from app.models.auth.module_model import Module
|
||||
|
||||
class ModuleController:
|
||||
@staticmethod
|
||||
def list_modules(db: Session) -> List[ModuleResponse]:
|
||||
modules = ModuleService.list_modules(db)
|
||||
return [ModuleResponse.model_validate(m) for m in modules]
|
||||
|
||||
@staticmethod
|
||||
def create_module(db: Session, module_data: ModuleCreate) -> ModuleResponse:
|
||||
module = ModuleService.create_module(db, module_data)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def get_available_modules(db: Session, current_user) -> List[ModuleResponse]:
|
||||
from app.schemas.auth.module_schema import ModuleAvailableResponse
|
||||
results = ModuleService.get_available_modules(db, current_user.tenant_id)
|
||||
|
||||
response = []
|
||||
for item in results:
|
||||
mod = item["module"]
|
||||
response.append(ModuleAvailableResponse(
|
||||
module_id=mod.module_id,
|
||||
module_name=mod.module_name,
|
||||
description=mod.description,
|
||||
icon_url=mod.icon_url,
|
||||
display_order=mod.display_order or 0,
|
||||
is_active=item["is_active"]
|
||||
))
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get_module(db: Session, module_id: str) -> ModuleResponse:
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> ModuleResponse:
|
||||
module = ModuleService.update_module(db, module_id, module_data)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def delete_module(db: Session, module_id: str):
|
||||
ModuleService.delete_module(db, module_id)
|
||||
return {"message": "Module deleted successfully"}
|
||||
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.module_environment_service import ModuleEnvironmentService
|
||||
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
|
||||
|
||||
class ModuleEnvironmentController:
|
||||
@staticmethod
|
||||
def list_environments(db: Session, module_id: str) -> List[EnvironmentResponse]:
|
||||
environments = ModuleEnvironmentService.list_environments(db, module_id)
|
||||
return [EnvironmentResponse.model_validate(env) for env in environments]
|
||||
|
||||
@staticmethod
|
||||
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> EnvironmentResponse:
|
||||
environment = ModuleEnvironmentService.create_environment(db, module_id, env_data)
|
||||
return EnvironmentResponse.model_validate(environment)
|
||||
|
||||
@staticmethod
|
||||
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> EnvironmentResponse:
|
||||
environment = ModuleEnvironmentService.update_environment(db, module_id, env_id, env_data)
|
||||
return EnvironmentResponse.model_validate(environment)
|
||||
|
||||
@staticmethod
|
||||
def set_default_environment(db: Session, module_id: str, env_id: str):
|
||||
ModuleEnvironmentService.set_default_environment(db, module_id, env_id)
|
||||
return {"message": "Environment set as default"}
|
||||
|
||||
@staticmethod
|
||||
def delete_environment(db: Session, module_id: str, env_id: str):
|
||||
ModuleEnvironmentService.delete_environment(db, module_id, env_id)
|
||||
return {"message": "Environment deleted successfully"}
|
||||
@@ -26,8 +26,19 @@ class RoleController:
|
||||
return RoleService.get_all_roles(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(db: Session, role_id: uuid.UUID) -> Role:
|
||||
return RoleService.get_role_by_id(db, role_id)
|
||||
def get_role_by_id(
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID],
|
||||
actor_is_superadmin: bool,
|
||||
) -> Role:
|
||||
return RoleService.get_role_by_id(
|
||||
db,
|
||||
role_id,
|
||||
actor_tenant_id=actor_tenant_id,
|
||||
actor_is_superadmin=actor_is_superadmin,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_role(
|
||||
@@ -35,20 +46,43 @@ class RoleController:
|
||||
role_id: uuid.UUID,
|
||||
role_data: RoleUpdate,
|
||||
is_superadmin: bool = False,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID] = None,
|
||||
) -> Role:
|
||||
return RoleService.update_role(
|
||||
db, role_id, role_data, is_superadmin=is_superadmin
|
||||
db,
|
||||
role_id,
|
||||
role_data,
|
||||
is_superadmin=is_superadmin,
|
||||
actor_tenant_id=actor_tenant_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_role(db: Session, role_id: uuid.UUID, is_superadmin: bool = False):
|
||||
return RoleService.delete_role(db, role_id, is_superadmin=is_superadmin)
|
||||
def delete_role(
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
is_superadmin: bool = False,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID] = None,
|
||||
):
|
||||
return RoleService.delete_role(
|
||||
db, role_id, is_superadmin=is_superadmin, actor_tenant_id=actor_tenant_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_role_with_accesses(
|
||||
db: Session, role_id: uuid.UUID
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID],
|
||||
actor_is_superadmin: bool,
|
||||
) -> RoleWithAccessesResponse:
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
role = RoleService.get_role_by_id(
|
||||
db,
|
||||
role_id,
|
||||
actor_tenant_id=actor_tenant_id,
|
||||
actor_is_superadmin=actor_is_superadmin,
|
||||
)
|
||||
|
||||
accesses = [
|
||||
{
|
||||
@@ -57,9 +91,25 @@ class RoleController:
|
||||
"category": ra.access.category,
|
||||
"name": ra.access.name,
|
||||
"parent_id": str(ra.access.parent_id) if ra.access.parent_id else None,
|
||||
"module_name": "SaaS (Internal)",
|
||||
}
|
||||
for ra in role.role_accesses
|
||||
if ra.access
|
||||
]
|
||||
|
||||
accesses.extend([
|
||||
{
|
||||
"id": str(rma.module_access.id),
|
||||
"access_code": rma.module_access.access_code,
|
||||
"category": rma.module_access.category,
|
||||
"name": rma.module_access.name,
|
||||
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
|
||||
"module_id": str(rma.module_access.module_id) if rma.module_access.module_id else None,
|
||||
"module_name": rma.module_access.module.module_name if (rma.module_access and rma.module_access.module) else "DocQube",
|
||||
}
|
||||
for rma in role.role_module_accesses
|
||||
if rma.module_access
|
||||
])
|
||||
|
||||
return RoleWithAccessesResponse(
|
||||
id=role.id,
|
||||
@@ -77,6 +127,10 @@ class RoleController:
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
filter_role_names: Optional[List[str]] = None,
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> RolePaginatedResponse:
|
||||
tenant_id = None if current_user_tenant_id is None else current_user_tenant_id
|
||||
return RoleService.get_roles_paginated(
|
||||
@@ -85,4 +139,8 @@ class RoleController:
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
)
|
||||
filter_role_names=filter_role_names,
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Request, HTTPException
|
||||
from typing import Optional, Dict, Any
|
||||
import uuid
|
||||
import logging
|
||||
from app.services.auth.sso_service import SSOService
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SSOController:
|
||||
@staticmethod
|
||||
def initiate_sso(db: Session, request: SSOInitiateRequest, current_user: User):
|
||||
return SSOService.generate_signed_payload(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
module_id=request.module_id,
|
||||
tenant_id=current_user.tenant_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def exchange_grant(
|
||||
db: Session,
|
||||
payload: SSOExchangeRequest,
|
||||
x_module_signature: Optional[str] = None,
|
||||
x_module_key: Optional[str] = None,
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.slug == payload.environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
headers = {}
|
||||
if x_module_signature:
|
||||
headers["X-Module-Signature"] = x_module_signature
|
||||
if x_module_key:
|
||||
headers["X-Module-Key"] = x_module_key
|
||||
for header in (
|
||||
"X-Module-Signature-Version",
|
||||
"X-Module-Timestamp",
|
||||
"X-Module-Nonce",
|
||||
):
|
||||
value = (raw_headers or {}).get(header.lower())
|
||||
if value:
|
||||
headers[header] = value
|
||||
|
||||
actual_body = payload.model_dump_json()
|
||||
|
||||
TrustService.validate_module_trust(
|
||||
environment=env,
|
||||
request_headers=headers,
|
||||
request_body=actual_body,
|
||||
)
|
||||
|
||||
return SSOService.exchange_grant(
|
||||
db=db,
|
||||
grant_code=payload.grant_code,
|
||||
module_id=payload.module_id,
|
||||
environment_slug=payload.environment_slug
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.auth.subscription_plan_schema import SubscriptionPlanCreate, SubscriptionPlanUpdate
|
||||
from app.services.auth.subscription_plan_service import SubscriptionPlanService
|
||||
|
||||
class SubscriptionPlanController:
|
||||
|
||||
@staticmethod
|
||||
def create_plan(db: Session, plan_data: SubscriptionPlanCreate):
|
||||
return SubscriptionPlanService.create_plan(db, plan_data)
|
||||
|
||||
@staticmethod
|
||||
def update_plan(db: Session, plan_id: uuid.UUID, plan_data: SubscriptionPlanUpdate):
|
||||
return SubscriptionPlanService.update_plan(db, plan_id, plan_data)
|
||||
|
||||
@staticmethod
|
||||
def get_plan(db: Session, plan_id: uuid.UUID):
|
||||
return SubscriptionPlanService.get_plan(db, plan_id)
|
||||
|
||||
@staticmethod
|
||||
def get_all_plans(db: Session, is_public: Optional[bool] = None, status: Optional[str] = None):
|
||||
return SubscriptionPlanService.get_all_plans(db, is_public, status)
|
||||
|
||||
@staticmethod
|
||||
def get_paginated_plans(
|
||||
db: Session,
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: Optional[str],
|
||||
filter_names: Optional[List[str]] = None,
|
||||
statuses: Optional[List[str]] = None,
|
||||
visibility: Optional[List[bool]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
return SubscriptionPlanService.get_paginated_plans(
|
||||
db,
|
||||
page,
|
||||
page_size,
|
||||
search,
|
||||
filter_names=filter_names,
|
||||
statuses=statuses,
|
||||
visibility=visibility,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_plan(db: Session, plan_id: uuid.UUID):
|
||||
return SubscriptionPlanService.delete_plan(db, plan_id)
|
||||
@@ -2,13 +2,13 @@ from sqlalchemy.orm import Session
|
||||
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate
|
||||
from app.services.auth.tenant_service import TenantService
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
class TenantController:
|
||||
|
||||
@staticmethod
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate):
|
||||
return TenantService.create_tenant(db, tenant_data)
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate, actor=None):
|
||||
return TenantService.create_tenant(db, tenant_data, actor=actor)
|
||||
|
||||
@staticmethod
|
||||
def get_all_tenants(db: Session):
|
||||
@@ -19,9 +19,15 @@ class TenantController:
|
||||
return TenantService.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate):
|
||||
return TenantService.update_tenant(db, tenant_id, tenant_data)
|
||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate, actor=None):
|
||||
return TenantService.update_tenant(db, tenant_id, tenant_data, actor=actor)
|
||||
|
||||
@staticmethod
|
||||
def subscription_history(db: Session, tenant_id: uuid.UUID, limit: int = 50):
|
||||
from app.services.auth import subscription_history as history
|
||||
|
||||
return history.history_for(db, tenant_id, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||
return TenantService.delete_tenant(db, tenant_id)
|
||||
@@ -33,6 +39,12 @@ class TenantController:
|
||||
page_size: int,
|
||||
search: Optional[str],
|
||||
is_active: Optional[bool],
|
||||
filter_tenant_names: Optional[List[str]] = None,
|
||||
filter_tenant_domains: Optional[List[str]] = None,
|
||||
filter_plan_ids: Optional[List[uuid.UUID]] = None,
|
||||
statuses: Optional[List[bool]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
return TenantService.get_tenants_paginated(
|
||||
db=db,
|
||||
@@ -40,4 +52,10 @@ class TenantController:
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
filter_tenant_names=filter_tenant_names,
|
||||
filter_tenant_domains=filter_tenant_domains,
|
||||
filter_plan_ids=filter_plan_ids,
|
||||
statuses=statuses,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.models.auth.module_model import Module
|
||||
import uuid
|
||||
from app.services.auth.tenant_module_service import TenantModuleService
|
||||
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
|
||||
|
||||
class TenantModuleController:
|
||||
@staticmethod
|
||||
def list_tenant_modules(db: Session, tenant_id: str) -> List[TenantModuleResponse]:
|
||||
results = TenantModuleService.list_tenant_modules(db, tenant_id)
|
||||
|
||||
response_list = []
|
||||
for tm, mod in results:
|
||||
response_list.append(TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=mod.module_name,
|
||||
module_icon_url=mod.icon_url,
|
||||
assigned_environment_slug=tm.assigned_environment_slug or "prod",
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
))
|
||||
return response_list
|
||||
|
||||
@staticmethod
|
||||
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModuleResponse:
|
||||
tm = TenantModuleService.assign_module(db, tenant_id, assignment_data)
|
||||
|
||||
module = db.query(Module).filter(Module.id == tm.module_id).first()
|
||||
|
||||
return TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=module.module_name if module else "Unknown",
|
||||
module_icon_url=module.icon_url if module else None,
|
||||
assigned_environment_slug=tm.assigned_environment_slug,
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModuleResponse:
|
||||
tm = TenantModuleService.update_assignment(db, tenant_id, tenant_module_id, update_data)
|
||||
|
||||
module = db.query(Module).filter(Module.id == tm.module_id).first()
|
||||
|
||||
return TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=module.module_name if module else "Unknown",
|
||||
module_icon_url=module.icon_url if module else None,
|
||||
assigned_environment_slug=tm.assigned_environment_slug,
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
|
||||
TenantModuleService.remove_assignment(db, tenant_id, tenant_module_id)
|
||||
return {"message": "Module removed from tenant successfully"}
|
||||
@@ -1,15 +1,16 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import HTTPException, status, BackgroundTasks
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate
|
||||
from app.services.auth.user_service import UserService
|
||||
from app.middleware.tenant_middleware import is_superadmin
|
||||
|
||||
class UserController:
|
||||
@staticmethod
|
||||
def _resolve_tenant_id(current_user: User, requested_tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
|
||||
if current_user.tenant_id is None:
|
||||
if is_superadmin(current_user):
|
||||
return requested_tenant_id
|
||||
|
||||
if requested_tenant_id and requested_tenant_id != current_user.tenant_id:
|
||||
@@ -21,32 +22,90 @@ class UserController:
|
||||
return current_user.tenant_id
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session, user_data: UserCreate, current_user: User) -> User:
|
||||
def create_user(db: Session, user_data: UserCreate, current_user: User, background_tasks: BackgroundTasks) -> User:
|
||||
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||
return UserService.create_user(db, user_data, tenant_id)
|
||||
return UserService.create_user(db, user_data, tenant_id, background_tasks)
|
||||
|
||||
@staticmethod
|
||||
def _scoped_tenant_id(current_user: User) -> Optional[uuid.UUID]:
|
||||
"""The tenant filter to apply for this actor.
|
||||
|
||||
None means "no filter", which is only ever correct for a superadmin.
|
||||
A tenant-less non-superadmin is an artefact of the signup defect and
|
||||
is refused rather than being handed an unfiltered query.
|
||||
"""
|
||||
if is_superadmin(current_user):
|
||||
return None
|
||||
if current_user.tenant_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is not associated with a workspace",
|
||||
)
|
||||
return current_user.tenant_id
|
||||
|
||||
@staticmethod
|
||||
def _visible_ids(db: Session, current_user: User):
|
||||
"""Whose accounts this person may administer, or None for everyone.
|
||||
|
||||
Computed here rather than inside `UserService` so the rule lives in one
|
||||
place: every path an administrator reaches a user through goes past this
|
||||
method, and a query that forgot it would be a scoped administrator
|
||||
quietly seeing the whole workspace.
|
||||
"""
|
||||
from app.services.auth import org_unit_service
|
||||
|
||||
if is_superadmin(current_user):
|
||||
return None
|
||||
return org_unit_service.visible_user_ids(db, current_user)
|
||||
|
||||
@staticmethod
|
||||
def get_all_users(db: Session, current_user: User) -> List[User]:
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_all_users(db, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
return UserService.get_all_users(
|
||||
db, tenant_id, visible_ids=UserController._visible_ids(db, current_user)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: uuid.UUID, current_user: User) -> User:
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
return UserService.get_user_by_id(
|
||||
db, user_id, tenant_id,
|
||||
visible_ids=UserController._visible_ids(db, current_user),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User) -> User:
|
||||
if current_user.tenant_id is not None and user_data.tenant_id is not None:
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, background_tasks: BackgroundTasks) -> User:
|
||||
if not is_superadmin(current_user) and user_data.tenant_id is not None:
|
||||
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.update_user(db, user_id, user_data, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
UserService.get_user_by_id(
|
||||
db, user_id, tenant_id,
|
||||
visible_ids=UserController._visible_ids(db, current_user),
|
||||
)
|
||||
return UserService.update_user(db, user_id, user_data, tenant_id, background_tasks)
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.delete_user(db, user_id, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
UserService.get_user_by_id(
|
||||
db, user_id, tenant_id,
|
||||
visible_ids=UserController._visible_ids(db, current_user),
|
||||
)
|
||||
return UserService.delete_user(db, user_id, tenant_id,
|
||||
actor_id=current_user.id)
|
||||
|
||||
@staticmethod
|
||||
def deleted_users(db: Session, current_user: User):
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
return UserService.deleted_users(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def restore_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id,
|
||||
include_deleted=True)
|
||||
return UserService.restore(db, user)
|
||||
|
||||
@staticmethod
|
||||
def get_users_paginated(
|
||||
@@ -55,14 +114,28 @@ class UserController:
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
filter_names: Optional[List[str]] = None,
|
||||
filter_emails: Optional[List[str]] = None,
|
||||
statuses: Optional[List[str]] = None,
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
||||
filter_role_ids: Optional[List[uuid.UUID]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
tenant_id = current_user.tenant_id
|
||||
visible = UserController._visible_ids(db, current_user)
|
||||
return UserService.get_users_paginated(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
status=status,
|
||||
)
|
||||
filter_names=filter_names,
|
||||
filter_emails=filter_emails,
|
||||
statuses=statuses,
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
filter_role_ids=filter_role_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
visible_ids=visible,
|
||||
)
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from app.models.auth.user_models import User
|
||||
from app.models.theme.color_palette_models import ColorPalette
|
||||
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
|
||||
from app.services.theme.palette_service import PaletteService
|
||||
from typing import List
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.theme.color_palette_model import ColorPalette
|
||||
from app.schemas.theme.color_palette_schema import (
|
||||
ColorPaletteCreate,
|
||||
ColorPaletteUpdate,
|
||||
)
|
||||
from app.services.theme.color_palette_service import PaletteService
|
||||
|
||||
class PaletteController:
|
||||
@staticmethod
|
||||
def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]:
|
||||
|
||||
tenant_id = current_user.tenant_id
|
||||
return PaletteService.get_all_palettes(db, tenant_id)
|
||||
return PaletteService.get_all_palettes(db)
|
||||
|
||||
@staticmethod
|
||||
def get_palette(db: Session, palette_id: UUID) -> ColorPalette:
|
||||
return PaletteService.get_palette_by_id(db, palette_id)
|
||||
|
||||
@staticmethod
|
||||
def create_palette(db: Session, data: ColorPaletteCreate, current_user: User) -> ColorPalette:
|
||||
return PaletteService.create_palette(db, data, tenant_id=None)
|
||||
def create_palette(
|
||||
db: Session, data: ColorPaletteCreate, current_user: User
|
||||
) -> ColorPalette:
|
||||
return PaletteService.create_palette(db, data)
|
||||
|
||||
@staticmethod
|
||||
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
|
||||
def update_palette(
|
||||
db: Session, palette_id: UUID, data: ColorPaletteUpdate
|
||||
) -> ColorPalette:
|
||||
return PaletteService.update_palette(db, palette_id, data)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Symmetric encryption for secrets held at rest.
|
||||
|
||||
Module trust credentials — the HMAC secrets and static keys every module
|
||||
integration is authenticated with — were stored as a plain JSON column. A
|
||||
read-only database leak, a backup, or a support export handed over the signing
|
||||
keys for every integration at once.
|
||||
|
||||
The key is derived from `ENCRYPTION_KEY` rather than used directly, so any
|
||||
sufficiently long secret works as configuration without callers having to
|
||||
produce a correctly-formatted Fernet key.
|
||||
|
||||
**This protects against database exposure, not repository exposure.** The
|
||||
`ENCRYPTION_KEY` for this application currently lives in committed `.env.*`
|
||||
files. Rotating those secrets out of version control is a separate task in the
|
||||
same phase, and until it is done the honest description of this control is
|
||||
"raises the cost of a database leak".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PREFIX = "enc:v1:"
|
||||
|
||||
|
||||
class EncryptionUnavailable(RuntimeError):
|
||||
"""ENCRYPTION_KEY is not configured, so nothing can be encrypted or read."""
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = settings.ENCRYPTION_KEY
|
||||
if not key:
|
||||
raise EncryptionUnavailable(
|
||||
"ENCRYPTION_KEY is not set. Module trust credentials cannot be "
|
||||
"encrypted or decrypted without it."
|
||||
)
|
||||
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
return _PREFIX + _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
if not is_encrypted(ciphertext):
|
||||
raise ValueError("Value is not an encrypted payload")
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext[len(_PREFIX) :].encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as e:
|
||||
raise ValueError(
|
||||
"Could not decrypt value — the ENCRYPTION_KEY does not match the one "
|
||||
"it was encrypted with."
|
||||
) from e
|
||||
|
||||
|
||||
def is_encrypted(value: Optional[str]) -> bool:
|
||||
return isinstance(value, str) and value.startswith(_PREFIX)
|
||||
|
||||
|
||||
def encrypt_json(payload: dict[str, Any]) -> str:
|
||||
return encrypt(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
def decrypt_json(ciphertext: str) -> dict[str, Any]:
|
||||
return json.loads(decrypt(ciphertext))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Where the bytes live.
|
||||
|
||||
A narrow interface over a local directory. There is no object-store credential in
|
||||
this deployment, and inventing one would be inventing an infrastructure decision
|
||||
that is not mine to make — but the surface here is four functions, so putting S3
|
||||
behind it later is one class rather than a rewrite.
|
||||
|
||||
## The two rules that matter
|
||||
|
||||
**A storage key is generated, never derived.** Not from the filename, not from
|
||||
the description, not from anything a person typed. Deriving a path from user
|
||||
input is how `../../etc/passwd` gets written, how two people uploading
|
||||
`report.pdf` overwrite each other's work, and how a URL becomes something worth
|
||||
guessing at.
|
||||
|
||||
**Every path is re-checked against the root before it is opened.** The key is
|
||||
generated here so it cannot escape, and it is checked anyway — because "this
|
||||
value is safe because of where it came from" is an argument that survives exactly
|
||||
until somebody adds a second caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Iterator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
from app.config.settings import settings
|
||||
|
||||
return Path(settings.DOCUMENT_STORAGE_PATH).resolve()
|
||||
|
||||
|
||||
def new_key(tenant_id) -> str:
|
||||
"""A fresh key for one document.
|
||||
|
||||
The workspace id is the first segment so a filesystem listing is navigable
|
||||
and so a whole workspace's files can be removed with it; the rest is random,
|
||||
which is what makes the key unguessable and collision-free.
|
||||
"""
|
||||
return f"{tenant_id}/{secrets.token_hex(16)}"
|
||||
|
||||
|
||||
def _path_for(key: str) -> Path:
|
||||
"""The absolute path for a key, or a refusal.
|
||||
|
||||
The key is generated by `new_key` and cannot contain a traversal — and this
|
||||
checks anyway. "Safe because of where it came from" holds until the day
|
||||
somebody adds a second caller, and this is a filesystem write.
|
||||
"""
|
||||
root = _root()
|
||||
candidate = (root / key).resolve()
|
||||
if not candidate.is_relative_to(root):
|
||||
raise ValueError("storage key escapes the storage root")
|
||||
return candidate
|
||||
|
||||
|
||||
def write(key: str, source: BinaryIO, *, max_bytes: int) -> tuple[int, str]:
|
||||
"""Stream a file to disk. Returns its size and SHA-256.
|
||||
|
||||
Refuses past `max_bytes` **while writing** rather than after: a limit checked
|
||||
on `Content-Length` alone trusts a header, and one checked after the write
|
||||
has already spent the disk.
|
||||
|
||||
A partial file is removed on the way out. Leaving one behind would leave a
|
||||
row pointing at truncated bytes, or worse, no row and bytes nobody can find.
|
||||
"""
|
||||
path = _path_for(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
digest = hashlib.sha256()
|
||||
written = 0
|
||||
|
||||
try:
|
||||
with path.open("wb") as sink:
|
||||
while True:
|
||||
chunk = source.read(CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > max_bytes:
|
||||
raise ValueError("file is larger than the limit")
|
||||
digest.update(chunk)
|
||||
sink.write(chunk)
|
||||
except Exception:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return written, digest.hexdigest()
|
||||
|
||||
|
||||
def read(key: str) -> Iterator[bytes]:
|
||||
"""Stream a file back. Raises `FileNotFoundError` if the bytes have gone."""
|
||||
path = _path_for(key)
|
||||
with path.open("rb") as source:
|
||||
while True:
|
||||
chunk = source.read(CHUNK_BYTES)
|
||||
if not chunk:
|
||||
return
|
||||
yield chunk
|
||||
|
||||
|
||||
def delete(key: str) -> None:
|
||||
"""Remove the bytes. Missing is success — the point is that they are gone.
|
||||
|
||||
Failure is logged rather than raised: the caller has already decided the
|
||||
document is deleted, and a file left on disk is a cost, not a correctness
|
||||
problem. Raising here would leave the row undeleted *and* the file present.
|
||||
"""
|
||||
try:
|
||||
_path_for(key).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
logger.warning("could not remove stored file %s", key, exc_info=True)
|
||||
|
||||
|
||||
def exists(key: str) -> bool:
|
||||
try:
|
||||
return _path_for(key).is_file()
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -0,0 +1,43 @@
|
||||
"""One way to write an email address down.
|
||||
|
||||
`users.email` had a plain unique index, so `Alice@example.com` and
|
||||
`alice@example.com` were two accounts. Nobody types their address the same way
|
||||
twice: a signup form gets `Alice@Example.com`, the password reset gets
|
||||
`alice@example.com`, and the second finds nothing. Worse, an invitation or an
|
||||
identity provider matching a user by address can create a duplicate of a person
|
||||
who already exists.
|
||||
|
||||
Addresses are stored normalised — trimmed and lower-cased — and every lookup
|
||||
normalises its input, so a row written before this still matches.
|
||||
|
||||
**Only the case is changed.** The local part of an address is technically
|
||||
case-sensitive, and a handful of mail servers honour that; in practice none that
|
||||
anyone signs up with does, and treating `Alice@` and `alice@` as different people
|
||||
causes far more trouble than the theoretical correctness buys. This is the same
|
||||
call the base application made.
|
||||
|
||||
Nothing else is touched — no dot-stripping, no plus-tag removal. `a.b+x@gmail.com`
|
||||
routes to the same mailbox as `ab@gmail.com` at one provider and to a different
|
||||
one elsewhere, so deciding they are the same person is a guess about somebody
|
||||
else's mail server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def normalise(email: Optional[str]) -> Optional[str]:
|
||||
"""The canonical form: trimmed and lower-cased. `None` stays `None`."""
|
||||
if email is None:
|
||||
return None
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def matches(left: Optional[str], right: Optional[str]) -> bool:
|
||||
"""Whether two addresses are the same person, for comparison in code.
|
||||
|
||||
Database comparisons should filter on the normalised column instead; this is
|
||||
for the places holding two strings already.
|
||||
"""
|
||||
return normalise(left) == normalise(right)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Deciding what an uploaded file actually is, and how to hand it back.
|
||||
|
||||
## Why the client is not asked
|
||||
|
||||
`Content-Type` on a multipart part is whatever the uploader wrote there. Trusting
|
||||
it means a file declared `image/png` and served back as `image/png` can be an
|
||||
HTML document, and a browser asked to render it from the console's own origin
|
||||
will run the script inside — a stored cross-site scripting hole with a file
|
||||
picker attached to it.
|
||||
|
||||
So the type is determined from the **first bytes of the content**, and the answer
|
||||
is used both to decide whether the upload is allowed and to set the header on the
|
||||
way back out.
|
||||
|
||||
## Why the list is short
|
||||
|
||||
An allow-list, not a deny-list. A deny-list is a promise to have thought of
|
||||
everything, and the list of things a browser will execute grows: SVG carries
|
||||
script, HTML obviously does, and a PDF can too. The set here is what an
|
||||
attachment on a business record actually is, and anything else is refused with a
|
||||
reason rather than stored and hoped about.
|
||||
|
||||
SVG is deliberately **not** on it. It is an image to a person and a script host
|
||||
to a browser, and there is no way to serve one that is safe in every context
|
||||
without rewriting it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
_SIGNATURES: tuple[tuple[bytes, int, str, str], ...] = (
|
||||
(b"%PDF-", 0, "application/pdf", "pdf"),
|
||||
(b"\x89PNG\r\n\x1a\n", 0, "image/png", "png"),
|
||||
(b"\xff\xd8\xff", 0, "image/jpeg", "jpg"),
|
||||
(b"GIF87a", 0, "image/gif", "gif"),
|
||||
(b"GIF89a", 0, "image/gif", "gif"),
|
||||
(b"RIFF", 0, "image/webp", "webp"),
|
||||
(b"%!PS", 0, "application/postscript", "ps"),
|
||||
)
|
||||
|
||||
_ZIP_MAGIC = b"PK\x03\x04"
|
||||
_ZIP_TYPES = {
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"zip": "application/zip",
|
||||
}
|
||||
|
||||
TEXT_EXTENSIONS = {"txt", "csv", "log", "md"}
|
||||
|
||||
INLINE_SAFE = {"application/pdf", "image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||
|
||||
ALLOWED = set(INLINE_SAFE) | set(_ZIP_TYPES.values()) | {"text/plain", "text/csv"}
|
||||
|
||||
|
||||
def _extension(filename: str) -> str:
|
||||
_, _, tail = (filename or "").rpartition(".")
|
||||
return tail.lower() if tail else ""
|
||||
|
||||
|
||||
def _looks_like_text(head: bytes) -> bool:
|
||||
"""Plain text, decided by what is *not* in it.
|
||||
|
||||
A NUL byte is the reliable tell for binary; beyond that, a run of control
|
||||
characters means something that is not meant to be read.
|
||||
"""
|
||||
if b"\x00" in head:
|
||||
return False
|
||||
try:
|
||||
head.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
printable = sum(1 for byte in head if byte >= 32 or byte in (9, 10, 13))
|
||||
return not head or printable / len(head) > 0.95
|
||||
|
||||
|
||||
def sniff(head: bytes, filename: str) -> Optional[str]:
|
||||
"""What this file is, or None if it is not something we accept.
|
||||
|
||||
`head` should be the first kilobyte or so — enough for every signature here
|
||||
and small enough to hold in memory for any upload.
|
||||
"""
|
||||
for magic, offset, content_type, _ in _SIGNATURES:
|
||||
if head[offset:offset + len(magic)] == magic:
|
||||
if content_type == "image/webp":
|
||||
if head[8:12] != b"WEBP":
|
||||
continue
|
||||
if content_type == "application/postscript":
|
||||
return None
|
||||
return content_type
|
||||
|
||||
if head[:len(_ZIP_MAGIC)] == _ZIP_MAGIC:
|
||||
return _ZIP_TYPES.get(_extension(filename), "application/zip")
|
||||
|
||||
extension = _extension(filename)
|
||||
if extension in TEXT_EXTENSIONS and _looks_like_text(head):
|
||||
return "text/csv" if extension == "csv" else "text/plain"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def disposition_for(content_type: str) -> str:
|
||||
"""`inline` only for what a browser cannot be talked into executing.
|
||||
|
||||
A PDF or a PNG shown in the page is the difference between looking at an
|
||||
invoice and downloading it to look at it. Everything else is `attachment`,
|
||||
which is what stops a browser rendering it at all.
|
||||
"""
|
||||
return "inline" if content_type in INLINE_SAFE else "attachment"
|
||||
|
||||
|
||||
def safe_filename(filename: str) -> str:
|
||||
"""A name fit to put in a header.
|
||||
|
||||
Never used to build a path — the storage key is random for that reason — so
|
||||
this is about the `Content-Disposition` header alone: a quote or a newline in
|
||||
it is header injection, and a leading dot or a path separator is a name that
|
||||
reads as somewhere else.
|
||||
"""
|
||||
cleaned = (filename or "").replace("\\", "/").rpartition("/")[2]
|
||||
cleaned = "".join(
|
||||
character for character in cleaned
|
||||
if character.isprintable() and character not in '"\\\r\n'
|
||||
).strip().lstrip(".")
|
||||
return cleaned[:200] or "download"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Small utilities shared by migrations.
|
||||
|
||||
Kept here rather than copied into each one because a migration that has already
|
||||
run is awkward to change later — the fewer places the same fix has to be applied,
|
||||
the better.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def drop_foreign_key_on(table: str, column: str) -> None:
|
||||
"""Drop the foreign key on a column, whatever the database called it.
|
||||
|
||||
Alembic's autogenerate writes `op.drop_constraint(None, ...)` for a
|
||||
constraint it created without a name, and that cannot be emitted: a
|
||||
constraint with no name is one Alembic cannot address. Every downgrade
|
||||
containing it fails, which means the migration chain is not reversible —
|
||||
discovered by trying it rather than by anything failing in normal use.
|
||||
|
||||
Hard-coding the name Postgres happens to generate would work on one database
|
||||
and not on another created at a different time, so the name is looked up.
|
||||
Absent, nothing happens: a downgrade should not fail because the thing it
|
||||
wants to remove is already gone.
|
||||
"""
|
||||
name = op.get_bind().execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid
|
||||
AND att.attnum = ANY(con.conkey)
|
||||
WHERE con.conrelid = CAST(:table AS regclass)
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = :column
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
).scalar()
|
||||
|
||||
if name:
|
||||
op.drop_constraint(name, table, type_="foreignkey")
|
||||
@@ -0,0 +1,133 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from redis.asyncio import Redis, from_url as async_from_url
|
||||
from app.config.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisClient:
|
||||
def __init__(self):
|
||||
self._redis: Optional[Redis] = None
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
Initializes the Redis connection pool (Async).
|
||||
"""
|
||||
if settings.REDIS_ENABLED:
|
||||
try:
|
||||
self._redis = async_from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
||||
socket_timeout=settings.REDIS_TIMEOUT
|
||||
)
|
||||
ping = await self._redis.ping()
|
||||
if ping:
|
||||
logger.info("Connected to Redis (Async)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
self._redis = None
|
||||
else:
|
||||
logger.info("Redis is disabled in settings")
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Closes the Redis connection.
|
||||
"""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get a value by key.
|
||||
"""
|
||||
if not self._redis:
|
||||
return None
|
||||
try:
|
||||
return await self._redis.get(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis GET error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = None) -> bool:
|
||||
"""
|
||||
Set a value by key with optional expiration time in seconds.
|
||||
"""
|
||||
if not self._redis:
|
||||
return False
|
||||
try:
|
||||
return await self._redis.set(key, value, ex=expire)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis SET error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a value by key.
|
||||
"""
|
||||
if not self._redis:
|
||||
return False
|
||||
try:
|
||||
return await self._redis.delete(key) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Redis DELETE error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def client(self) -> Optional[Redis]:
|
||||
"""
|
||||
Expose the raw Redis client if needed for advanced operations.
|
||||
"""
|
||||
return self._redis
|
||||
|
||||
class SyncRedisClient:
|
||||
def __init__(self):
|
||||
self._redis = None
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
Initializes the Redis connection pool (Sync).
|
||||
"""
|
||||
if settings.REDIS_ENABLED:
|
||||
try:
|
||||
import redis
|
||||
self._redis = redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
||||
socket_timeout=settings.REDIS_TIMEOUT
|
||||
)
|
||||
if self._redis.ping():
|
||||
logger.info("Connected to Redis (Sync)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis (Sync): {e}")
|
||||
self._redis = None
|
||||
|
||||
def close(self):
|
||||
if self._redis:
|
||||
self._redis.close()
|
||||
logger.info("Redis connection closed (Sync)")
|
||||
|
||||
def rpush(self, key: str, *values) -> int:
|
||||
if not self._redis:
|
||||
try:
|
||||
self.connect()
|
||||
except Exception:
|
||||
pass
|
||||
if not self._redis:
|
||||
return 0
|
||||
try:
|
||||
return self._redis.rpush(key, *values)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis RPUSH error: {e}")
|
||||
return 0
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
return self._redis
|
||||
|
||||
redis_client = RedisClient()
|
||||
sync_redis_client = SyncRedisClient()
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""Pushing the tenant context into PostgreSQL, so the database enforces isolation.
|
||||
|
||||
Until now isolation has been a convention: every query had to remember
|
||||
`.filter(tenant_id == ...)`. Finding S-5 was one that forgot, and the reason it
|
||||
was possible to forget is that nothing outside the developer's memory was
|
||||
checking.
|
||||
|
||||
This makes it a property instead. Before any query runs, the session's tenant is
|
||||
written into two PostgreSQL settings that the row-level security policies read:
|
||||
|
||||
app.tenant_id the workspace, or '' when none is set
|
||||
app.bypass_rls 'on' for deliberate cross-workspace work
|
||||
|
||||
A policy that sees neither returns no rows. So a code path that forgets to
|
||||
establish context fails loudly and safely, rather than quietly reading everyone's
|
||||
data.
|
||||
|
||||
**This only bites if the application connects as a non-owner role.** A superuser
|
||||
bypasses row-level security unconditionally, and the table owner does too unless
|
||||
the table is FORCEd — which migration `c3d5e7f9a801` does. `scripts/create_app_role.py`
|
||||
creates the role to connect as; `check_rls_enforced()` reports whether the
|
||||
connection in use is actually subject to the policies, because "policies exist"
|
||||
and "policies apply" look identical in the schema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.tenant_context import current_tenant_id, is_bypassed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TENANT_SETTING = "app.tenant_id"
|
||||
BYPASS_SETTING = "app.bypass_rls"
|
||||
|
||||
_STATE_KEY = "_rls_state"
|
||||
_APPLYING_KEY = "_rls_applying"
|
||||
|
||||
|
||||
def _desired_state() -> tuple[str, str]:
|
||||
if is_bypassed():
|
||||
return "", "on"
|
||||
tenant_id = current_tenant_id()
|
||||
return (str(tenant_id) if tenant_id else ""), "off"
|
||||
|
||||
|
||||
def apply_rls_context(session: Session) -> None:
|
||||
"""Write the current context into the session, if it has changed.
|
||||
|
||||
Guarded against re-entry because setting the context is itself a query, and
|
||||
the listener that calls this fires on every query.
|
||||
|
||||
`set_config(..., true)` makes the setting local to the transaction, so it is
|
||||
discarded on commit or rollback and cannot leak into whatever uses this
|
||||
connection next out of the pool.
|
||||
"""
|
||||
if session.info.get(_APPLYING_KEY):
|
||||
return
|
||||
|
||||
desired = _desired_state()
|
||||
if session.info.get(_STATE_KEY) == desired:
|
||||
return
|
||||
|
||||
tenant_id, bypass = desired
|
||||
session.info[_APPLYING_KEY] = True
|
||||
try:
|
||||
session.execute(
|
||||
text("SELECT set_config(:k1, :v1, true), set_config(:k2, :v2, true)"),
|
||||
{"k1": TENANT_SETTING, "v1": tenant_id, "k2": BYPASS_SETTING, "v2": bypass},
|
||||
)
|
||||
session.info[_STATE_KEY] = desired
|
||||
finally:
|
||||
session.info[_APPLYING_KEY] = False
|
||||
|
||||
|
||||
def register_rls_listener() -> None:
|
||||
@event.listens_for(Session, "do_orm_execute")
|
||||
def _sync_on_query(execute_state: Any) -> None:
|
||||
apply_rls_context(execute_state.session)
|
||||
|
||||
@event.listens_for(Session, "before_flush")
|
||||
def _sync_on_flush(session: Session, flush_context: Any, instances: Any) -> None:
|
||||
"""Writes need the context too, and do not come through `do_orm_execute`.
|
||||
|
||||
That event fires for ORM *queries*. An INSERT produced by `session.add()`
|
||||
plus a flush does not pass through it, so without this hook every write
|
||||
ran with no `app.tenant_id` set — and the policy's WITH CHECK refused it.
|
||||
The symptom is a permission error on a perfectly ordinary insert, which
|
||||
is a confusing way to discover a missing listener.
|
||||
"""
|
||||
apply_rls_context(session)
|
||||
|
||||
@event.listens_for(Session, "after_transaction_end")
|
||||
def _forget(session: Session, transaction: Any) -> None:
|
||||
session.info.pop(_STATE_KEY, None)
|
||||
|
||||
logger.debug("row-level security context listener registered")
|
||||
|
||||
|
||||
def check_rls_enforced(session: Session) -> list[str]:
|
||||
"""Whether isolation is actually in force on *this* connection.
|
||||
|
||||
Returns the problems found, empty when all is well. Worth calling from a
|
||||
health check: a schema full of policies and a superuser connection look
|
||||
exactly like a schema full of policies and a correct connection.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
|
||||
rows = session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relkind = 'r'
|
||||
AND EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
problems.append("no table carries a row-level security policy")
|
||||
|
||||
for name, enabled, forced in rows:
|
||||
if not enabled:
|
||||
problems.append(f"{name}: has policies but row security is disabled")
|
||||
elif not forced:
|
||||
problems.append(
|
||||
f"{name}: row security is enabled but not FORCEd, so the owning role "
|
||||
"bypasses it — and the owning role is what this application connects as"
|
||||
)
|
||||
|
||||
is_super = session.execute(
|
||||
text("SELECT usesuper FROM pg_user WHERE usename = current_user")
|
||||
).scalar()
|
||||
if is_super:
|
||||
problems.append(
|
||||
"connected as a PostgreSQL superuser, which bypasses row-level security "
|
||||
"unconditionally — no policy applies to this connection. Point DATABASE_URL "
|
||||
"at the role created by scripts/create_app_role.py"
|
||||
)
|
||||
|
||||
return problems
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Refuse to fetch a URL that points back inside the network.
|
||||
|
||||
An identity provider is configured by a workspace administrator, who supplies an
|
||||
issuer URL that the **server** then fetches — discovery documents, JWKS, token
|
||||
exchange. That is a request the platform makes on a customer's instruction, to
|
||||
wherever the customer says, from inside the network. Unchecked, it is server-side
|
||||
request forgery: `http://169.254.169.254/` reaches the cloud metadata service,
|
||||
`http://localhost:5432` reaches the database, and an internal admin panel is one
|
||||
hostname away.
|
||||
|
||||
Two checks, and the second is the one usually missed:
|
||||
|
||||
1. **The host must resolve to a public address.** A literal is checked directly;
|
||||
a name is resolved and every address it returns must be global, because a
|
||||
name with one public and one private answer is a name that will eventually
|
||||
return the private one.
|
||||
|
||||
2. **The connection is pinned to the address that was checked.** Resolving and
|
||||
then handing the *hostname* to the HTTP client leaves a gap: the name can
|
||||
resolve again, to something else, between the check and the request. That is
|
||||
DNS rebinding, and it defeats a check that only looks at the name.
|
||||
|
||||
Ported from the platform, which needed it for the same reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from typing import Optional
|
||||
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
||||
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
|
||||
_PRIVATE_ADDRESS_MESSAGE = (
|
||||
"URL host resolves to a private, loopback or otherwise reserved address, "
|
||||
"which is not allowed"
|
||||
)
|
||||
|
||||
|
||||
class PrivateAddressError(Exception):
|
||||
"""The target is inside the network, or cannot be shown not to be."""
|
||||
|
||||
def __init__(self, message: str = _PRIVATE_ADDRESS_MESSAGE) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _as_ip_literal(host: str) -> Optional[IPv4Address | IPv6Address]:
|
||||
try:
|
||||
return ipaddress.ip_address(host.strip("[]"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_public_address(host: str, port: Optional[int] = None) -> str:
|
||||
"""The address to connect to, or a refusal.
|
||||
|
||||
Every address the name resolves to has to be global. Accepting a name
|
||||
because *one* of its answers is public would accept a name that alternates,
|
||||
which is the whole trick.
|
||||
"""
|
||||
literal = _as_ip_literal(host)
|
||||
if literal is not None:
|
||||
if not literal.is_global:
|
||||
raise PrivateAddressError()
|
||||
return str(literal)
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as e:
|
||||
raise PrivateAddressError(f"URL host could not be resolved: {e}")
|
||||
|
||||
if not infos:
|
||||
raise PrivateAddressError("URL host could not be resolved")
|
||||
|
||||
addresses = [ipaddress.ip_address(info[4][0]) for info in infos]
|
||||
if any(not address.is_global for address in addresses):
|
||||
raise PrivateAddressError()
|
||||
return str(addresses[0])
|
||||
|
||||
|
||||
def pin_url_to_address(url: str, address: str) -> str:
|
||||
"""Rewrite the URL to connect to a specific address.
|
||||
|
||||
Closes the window between checking a name and using it. The `Host` header
|
||||
still has to carry the original name for TLS and virtual hosting, which is
|
||||
the caller's job.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
literal = f"[{address}]" if ":" in address else address
|
||||
netloc = f"{literal}:{parts.port}" if parts.port else literal
|
||||
return urlunsplit(
|
||||
(parts.scheme, netloc, parts.path or "/", parts.query, parts.fragment)
|
||||
)
|
||||
|
||||
|
||||
def _url_shape_error(parts: SplitResult, require_https: bool) -> Optional[str]:
|
||||
if parts.scheme not in ALLOWED_SCHEMES:
|
||||
return f"URL scheme must be one of {', '.join(ALLOWED_SCHEMES)}"
|
||||
if require_https and parts.scheme != "https":
|
||||
return "URL must use https"
|
||||
if not parts.hostname:
|
||||
return "URL has no host"
|
||||
if parts.username or parts.password:
|
||||
return "URL must not contain credentials"
|
||||
return None
|
||||
|
||||
|
||||
def url_destination_error(url: str, *, require_https: bool = True) -> Optional[str]:
|
||||
"""Why this URL cannot be fetched, or None if it can.
|
||||
|
||||
Returns a message rather than raising so a configuration form can show it
|
||||
next to the field, which is where somebody can act on it.
|
||||
"""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return "URL could not be parsed"
|
||||
|
||||
shape = _url_shape_error(parts, require_https)
|
||||
if shape:
|
||||
return shape
|
||||
|
||||
try:
|
||||
resolve_public_address(parts.hostname, parts.port)
|
||||
except PrivateAddressError as e:
|
||||
return str(e)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Which workspace the current unit of work belongs to.
|
||||
|
||||
Held in a `ContextVar` rather than passed around, because the thing that needs it
|
||||
is the database session listener in `app.core.rls`, and threading a tenant id
|
||||
through every call site to reach it is exactly the discipline that failed —
|
||||
finding S-5 was one query that forgot.
|
||||
|
||||
Three states, and the difference between them matters:
|
||||
|
||||
- **scoped** — a workspace is set. Queries see only that workspace's rows.
|
||||
- **unscoped** — deliberately crossing workspaces. Used by sign-in (which looks a
|
||||
user up by email before any workspace is known), by the event worker, and by
|
||||
platform superadmin operations. Always explicit, never a default.
|
||||
- **unset** — no context established. Queries see **nothing**.
|
||||
|
||||
That last one is the important choice. Unset could have meant "see everything",
|
||||
which would be forgiving of a code path that forgot to set context — and would
|
||||
also mean any such path silently leaks across workspaces. Seeing nothing makes a
|
||||
missed path fail loudly and safely instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any, TypeVar
|
||||
|
||||
_tenant_id: ContextVar[uuid.UUID | None] = ContextVar("saas_tenant_id", default=None)
|
||||
_bypass: ContextVar[bool] = ContextVar("saas_tenant_bypass", default=False)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def current_tenant_id() -> uuid.UUID | None:
|
||||
return _tenant_id.get()
|
||||
|
||||
|
||||
def is_bypassed() -> bool:
|
||||
return _bypass.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def scoped_to(tenant_id: uuid.UUID | None) -> Iterator[None]:
|
||||
"""Run inside one workspace.
|
||||
|
||||
A `None` tenant here means a platform superadmin, who belongs to no workspace
|
||||
— so it bypasses rather than scoping to nothing. Scoping a superadmin to
|
||||
"no workspace" would make every administrative screen empty.
|
||||
"""
|
||||
if tenant_id is None:
|
||||
with unscoped():
|
||||
yield
|
||||
return
|
||||
|
||||
token_tenant = _tenant_id.set(tenant_id)
|
||||
token_bypass = _bypass.set(False)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tenant_id.reset(token_tenant)
|
||||
_bypass.reset(token_bypass)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def unscoped() -> Iterator[None]:
|
||||
"""Deliberately cross workspace boundaries.
|
||||
|
||||
Every use is a decision worth reading twice. The legitimate ones are narrow:
|
||||
authenticating someone before their workspace is known, background work that
|
||||
spans workspaces, and platform superadmin operations.
|
||||
"""
|
||||
token_tenant = _tenant_id.set(None)
|
||||
token_bypass = _bypass.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tenant_id.reset(token_tenant)
|
||||
_bypass.reset(token_bypass)
|
||||
|
||||
|
||||
def system_operation(fn: F) -> F:
|
||||
"""Mark a function as crossing workspaces by nature.
|
||||
|
||||
Reads better than an `unscoped()` block at the top of a body, and makes the
|
||||
property visible at the call site rather than buried in the implementation.
|
||||
"""
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
with unscoped():
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
@@ -0,0 +1 @@
|
||||
from .helpers import get_client_ip
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import Request
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -2,9 +2,39 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timezone
|
||||
from app.config.database import get_db
|
||||
from app.config.security import security
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.core.tenant_context import unscoped
|
||||
from app.services.auth import api_key_service
|
||||
from app.services.auth.subscription_lifecycle import (
|
||||
SubscriptionState,
|
||||
resolve as resolve_lifecycle,
|
||||
)
|
||||
|
||||
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _lifecycle_message(lifecycle) -> str:
|
||||
"""Say which of the several reasons applies, rather than "inactive".
|
||||
|
||||
"Tenant is inactive" covered suspension, cancellation and expiry alike, so
|
||||
nobody reading it could tell whether to renew, contact support, or check
|
||||
their own admin settings.
|
||||
"""
|
||||
if lifecycle.state is SubscriptionState.CANCELLED:
|
||||
return "This workspace has been cancelled. Contact support to reinstate it."
|
||||
if lifecycle.state is SubscriptionState.SUSPENDED:
|
||||
return "This workspace has been suspended. Contact support."
|
||||
if lifecycle.state is SubscriptionState.EXPIRED:
|
||||
return "Your subscription has expired. Renew it to regain access."
|
||||
return "This workspace is not active."
|
||||
from app.services.auth.subscription_entitlement_service import (
|
||||
SubscriptionEntitlementService,
|
||||
)
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -13,12 +43,19 @@ def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
||||
token = (
|
||||
credentials.credentials if credentials
|
||||
else request.headers.get("X-API-Key") or request.cookies.get("access_token")
|
||||
)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated"
|
||||
)
|
||||
|
||||
if api_key_service.looks_like_a_key(token):
|
||||
return _user_for_api_key(request, token, db)
|
||||
|
||||
try:
|
||||
payload = security.verify_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
@@ -35,21 +72,106 @@ def get_current_user(
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
with unscoped():
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
|
||||
setattr(user, "_saas_db_session", db)
|
||||
_forget_api_key(user)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is inactive"
|
||||
)
|
||||
|
||||
_assert_workspace_usable(request, user, db)
|
||||
|
||||
return user
|
||||
|
||||
def _forget_api_key(user: User) -> None:
|
||||
for marker in ("_saas_api_key_scopes", "_saas_api_key_id", "_saas_api_key_name"):
|
||||
if hasattr(user, marker):
|
||||
delattr(user, marker)
|
||||
|
||||
|
||||
def _user_for_api_key(request: Request, raw: str, db: Session) -> User:
|
||||
"""Turn a key into the principal it acts as.
|
||||
|
||||
The principal is the **user who issued it**, not a separate kind of account.
|
||||
That is the decision that makes everything else fall out: row-level security,
|
||||
the entitlement chain, subscription lifecycle and audit attribution all work
|
||||
unchanged, and deactivating somebody disables their integrations in the same
|
||||
moment rather than leaving them running after that person has gone.
|
||||
|
||||
What the key adds is a ceiling. `_saas_api_key_scopes` is set on the returned
|
||||
user, and `has_access` intersects with it — so a key can only ever be
|
||||
narrower than its owner, never wider.
|
||||
"""
|
||||
key = api_key_service.resolve(db, raw)
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
with unscoped():
|
||||
owner = db.query(User).filter(User.id == key.user_id).first()
|
||||
if owner is None or owner.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
setattr(owner, "_saas_db_session", db)
|
||||
setattr(owner, "_saas_api_key_scopes", api_key_service.effective_scopes(db, key, owner))
|
||||
setattr(owner, "_saas_api_key_id", key.id)
|
||||
setattr(owner, "_saas_api_key_name", key.name)
|
||||
request.state.api_key_id = str(key.id)
|
||||
|
||||
_assert_workspace_usable(request, owner, db)
|
||||
|
||||
api_key_service.touch(key.id, key.last_used_at)
|
||||
return owner
|
||||
|
||||
|
||||
def _assert_workspace_usable(request: Request, user: User, db: Session) -> None:
|
||||
"""The subscription checks, applied to any way of authenticating.
|
||||
|
||||
Extracted rather than repeated: a key that kept working through a
|
||||
cancellation, or kept writing during the read-only grace period, would be a
|
||||
hole that exists only because a second code path forgot about it.
|
||||
"""
|
||||
if user.tenant_id is None:
|
||||
return
|
||||
|
||||
with unscoped():
|
||||
tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found")
|
||||
|
||||
lifecycle = resolve_lifecycle(tenant)
|
||||
if not lifecycle.can_sign_in:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=_lifecycle_message(lifecycle))
|
||||
|
||||
if not lifecycle.can_write and request.method in _WRITE_METHODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Your subscription has expired. You can still view and export your "
|
||||
"data until "
|
||||
f"{lifecycle.grace_until.isoformat() if lifecycle.grace_until else 'renewal'}"
|
||||
", but changes are paused until it is renewed."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.status != "active":
|
||||
raise HTTPException(
|
||||
@@ -59,24 +181,25 @@ def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
|
||||
def has_access(user: User, access_code: str) -> bool:
|
||||
if not user.role:
|
||||
db = getattr(user, "_saas_db_session", None)
|
||||
if db is None:
|
||||
return False
|
||||
|
||||
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
|
||||
|
||||
return access_code in user_access_codes
|
||||
|
||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||
db, user
|
||||
)
|
||||
if access_code not in user_access_codes:
|
||||
return False
|
||||
|
||||
scopes = getattr(user, "_saas_api_key_scopes", None)
|
||||
return access_code in scopes if scopes is not None else True
|
||||
|
||||
def can_access(user: User, access_code: str, db: Session) -> bool:
|
||||
if not user.role:
|
||||
return False
|
||||
|
||||
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
|
||||
|
||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||
db, user
|
||||
)
|
||||
if access_code in user_access_codes:
|
||||
return True
|
||||
|
||||
from app.models.auth.access_models import Access
|
||||
|
||||
return True
|
||||
requested_access = db.query(Access).filter(
|
||||
Access.access_code == access_code
|
||||
).first()
|
||||
@@ -93,10 +216,11 @@ def can_access(user: User, access_code: str, db: Session) -> bool:
|
||||
return False
|
||||
|
||||
def get_user_accesses(user: User) -> List[str]:
|
||||
if not user.role:
|
||||
db = getattr(user, "_saas_db_session", None)
|
||||
if db is None:
|
||||
return []
|
||||
|
||||
return [ra.access.access_code for ra in user.role.role_accesses]
|
||||
|
||||
return sorted(SubscriptionEntitlementService.get_effective_access_codes(db, user))
|
||||
|
||||
def require_access(access_code: str):
|
||||
def check_permission(current_user: User = Depends(get_current_user)) -> bool:
|
||||
@@ -121,4 +245,4 @@ def require_access_hierarchical(access_code: str):
|
||||
)
|
||||
return True
|
||||
|
||||
return check_permission
|
||||
return check_permission
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Honour an `Idempotency-Key` on requests that change something.
|
||||
|
||||
Middleware rather than a dependency, for the same reason the tenant scope is:
|
||||
the work spans the endpoint. A dependency can claim the key before the handler
|
||||
runs but has no way to see the response afterwards, and the response is the half
|
||||
that matters — remembering it is the entire point.
|
||||
|
||||
Only `POST`, `PUT` and `PATCH`. `GET` and `DELETE` are already idempotent by
|
||||
definition; adding a key to them would be a cache, which is a different feature
|
||||
with different rules about staleness.
|
||||
|
||||
The key is scoped to the caller — workspace and user — read from the same token
|
||||
the scope middleware reads, or from an API key. An unauthenticated request with a
|
||||
key is passed straight through: it will be refused a moment later anyway, and
|
||||
claiming a key on the caller's behalf before knowing who they are would let
|
||||
anybody occupy anybody's key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from app.services.system import idempotency_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEADER = "Idempotency-Key"
|
||||
METHODS = {"POST", "PUT", "PATCH"}
|
||||
|
||||
|
||||
def _caller(request: Request) -> Optional[tuple[Optional[uuid.UUID], Optional[uuid.UUID]]]:
|
||||
"""(tenant_id, user_id) for the caller, or None if they are not identified.
|
||||
|
||||
Read from the token rather than from the database, exactly as the scope
|
||||
middleware does, so that this costs nothing on the overwhelming majority of
|
||||
requests that carry no key at all.
|
||||
"""
|
||||
from app.middleware.tenant_scope_middleware import _claims, _presented
|
||||
from app.services.auth import api_key_service
|
||||
|
||||
claims = _claims(request)
|
||||
if claims is not None:
|
||||
subject = claims.get("sub")
|
||||
raw_tenant = claims.get("tenant_id")
|
||||
try:
|
||||
user_id = uuid.UUID(str(subject)) if subject else None
|
||||
tenant_id = uuid.UUID(str(raw_tenant)) if raw_tenant else None
|
||||
except ValueError:
|
||||
return None
|
||||
return (tenant_id, user_id)
|
||||
|
||||
presented = _presented(request)
|
||||
if api_key_service.looks_like_a_key(presented):
|
||||
from app.config.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
key = api_key_service.resolve(db, presented)
|
||||
return (key.tenant_id, key.user_id) if key else None
|
||||
except Exception:
|
||||
logger.exception("could not resolve an API key for idempotency")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
key = request.headers.get(HEADER)
|
||||
if not key or request.method not in METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
if len(key) > idempotency_service.MAX_KEY_LENGTH:
|
||||
return JSONResponse(
|
||||
{"detail": f"{HEADER} must be at most "
|
||||
f"{idempotency_service.MAX_KEY_LENGTH} characters"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
caller = _caller(request)
|
||||
if caller is None:
|
||||
return await call_next(request)
|
||||
|
||||
tenant_id, user_id = caller
|
||||
endpoint = f"{request.method} {request.url.path}"
|
||||
|
||||
body = await request.body()
|
||||
|
||||
async def _receive():
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
request = Request(request.scope, _receive)
|
||||
|
||||
from app.config.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
replay = idempotency_service.claim(
|
||||
db,
|
||||
key=key,
|
||||
endpoint=endpoint,
|
||||
request_hash=idempotency_service.hash_request(body),
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except idempotency_service.Conflict as conflict:
|
||||
db.close()
|
||||
return JSONResponse({"detail": conflict.detail},
|
||||
status_code=conflict.status_code)
|
||||
except Exception:
|
||||
logger.exception("idempotency claim failed; proceeding without it")
|
||||
db.close()
|
||||
return await call_next(request)
|
||||
|
||||
if replay is not None:
|
||||
db.close()
|
||||
return JSONResponse(
|
||||
replay.response_body,
|
||||
status_code=replay.response_status or 200,
|
||||
headers={"Idempotent-Replay": "true"},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
idempotency_service.release(
|
||||
db, key=key, endpoint=endpoint,
|
||||
tenant_id=tenant_id, user_id=user_id,
|
||||
)
|
||||
db.close()
|
||||
raise
|
||||
|
||||
payload = b""
|
||||
async for chunk in response.body_iterator:
|
||||
payload += chunk
|
||||
|
||||
try:
|
||||
idempotency_service.complete(
|
||||
db, key=key, endpoint=endpoint,
|
||||
tenant_id=tenant_id, user_id=user_id,
|
||||
status_code=response.status_code, body=payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("could not record idempotent response")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return Response(
|
||||
content=payload,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Fixed-window rate limiting for the endpoints that had none.
|
||||
|
||||
`/signin`, `/signup`, `/forgot-password`, `/verify-otp` and `/reset-password-otp`
|
||||
were completely unprotected: unlimited password guesses, unlimited OTP guesses
|
||||
against any address, and unlimited outbound mail triggered by anyone.
|
||||
|
||||
A fixed window is deliberately simple. It permits a burst of up to 2x the limit
|
||||
across a window boundary, which is the well-known trade-off; for login throttling
|
||||
that is entirely acceptable, and a sliding window is not worth the extra Redis
|
||||
round-trips on a system scheduled for replacement.
|
||||
|
||||
Fails **open** when Redis is unavailable — the alternative is that a Redis outage
|
||||
locks every user out of the product. That is a deliberate choice and it is why
|
||||
this is a mitigation rather than a control: it raises the cost of an online
|
||||
guessing attack, it does not make one impossible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Best-effort client address.
|
||||
|
||||
X-Forwarded-For is only trustworthy behind a proxy that overwrites it. Take
|
||||
the left-most entry, which is what a single trusted proxy sets, and fall back
|
||||
to the socket address.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
first = forwarded.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
|
||||
def _consume(bucket: str, limit: int, window_seconds: int) -> Optional[int]:
|
||||
"""Increment the bucket. Returns seconds-to-wait if over limit, else None."""
|
||||
from app.config.settings import settings
|
||||
if settings.APP_ENV in ["local", "localdev"]:
|
||||
return None
|
||||
|
||||
client = getattr(sync_redis_client, "client", None)
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
key = f"ratelimit:{bucket}"
|
||||
try:
|
||||
pipe = client.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.ttl(key)
|
||||
count, ttl = pipe.execute()
|
||||
|
||||
if ttl is None or ttl < 0:
|
||||
client.expire(key, window_seconds)
|
||||
ttl = window_seconds
|
||||
|
||||
if int(count) > limit:
|
||||
return int(ttl) if int(ttl) > 0 else window_seconds
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Rate limit check failed for %s: %s", bucket, e)
|
||||
return None
|
||||
|
||||
|
||||
def rate_limit(
|
||||
name: str,
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
by_body_field: Optional[str] = None,
|
||||
) -> Callable:
|
||||
"""Dependency factory.
|
||||
|
||||
Limits by client address, and additionally by a body field (typically
|
||||
`email`) when one is named — so an attacker spreading guesses across many
|
||||
addresses is still capped per address, and one spreading across many source
|
||||
addresses is still capped per target account.
|
||||
"""
|
||||
|
||||
async def dependency(request: Request) -> None:
|
||||
buckets: list[str] = [f"{name}:ip:{get_client_ip(request)}"]
|
||||
|
||||
if by_body_field:
|
||||
try:
|
||||
body = await request.json()
|
||||
value = body.get(by_body_field) if isinstance(body, dict) else None
|
||||
if value:
|
||||
buckets.append(f"{name}:{by_body_field}:{str(value).lower()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for bucket in buckets:
|
||||
retry_after = _consume(bucket, limit, window_seconds)
|
||||
if retry_after is not None:
|
||||
logger.warning("Rate limit exceeded: %s", bucket)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests. Please try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
SIGNIN_LIMIT = rate_limit("signin", limit=10, window_seconds=900, by_body_field="email")
|
||||
SIGNUP_LIMIT = rate_limit("signup", limit=5, window_seconds=3600)
|
||||
FORGOT_PASSWORD_LIMIT = rate_limit(
|
||||
"forgot_password", limit=5, window_seconds=900, by_body_field="email"
|
||||
)
|
||||
VERIFY_OTP_LIMIT = rate_limit(
|
||||
"verify_otp", limit=10, window_seconds=900, by_body_field="email"
|
||||
)
|
||||
RESET_PASSWORD_OTP_LIMIT = rate_limit(
|
||||
"reset_password_otp", limit=10, window_seconds=900, by_body_field="email"
|
||||
)
|
||||
@@ -19,7 +19,13 @@ def get_tenant_id_from_user(user) -> Optional[uuid.UUID]:
|
||||
return user.tenant_id
|
||||
|
||||
def is_superadmin(user) -> bool:
|
||||
return user.tenant_id is None
|
||||
"""Platform superadmin — an explicit property of the account.
|
||||
|
||||
This deliberately does NOT infer privilege from a missing tenant_id. Doing so
|
||||
made every tenant-less account a superadmin, and the public signup endpoint
|
||||
creates tenant-less accounts.
|
||||
"""
|
||||
return bool(getattr(user, "is_superadmin", False))
|
||||
|
||||
def require_superadmin(user=Depends(get_current_user)):
|
||||
if not is_superadmin(user):
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Establish the caller's workspace for the whole request.
|
||||
|
||||
The workspace comes from the access token rather than from a database lookup, so
|
||||
the context is in place before the first query runs — including the one that
|
||||
resolves the caller. `generate_access_token` already puts `tenant_id` in the
|
||||
claims; this reads it back.
|
||||
|
||||
Middleware rather than a dependency, because entering and exiting the context has
|
||||
to be one function. A `with` block cannot span a dependency and the endpoint that
|
||||
follows it, and a `ContextVar` set without a matching reset outlives the request.
|
||||
|
||||
The token is decoded here **for routing only** — to decide which workspace the
|
||||
queries belong to. It is deliberately not treated as authentication: a forged or
|
||||
expired token yields no context, and `get_current_user` still verifies properly,
|
||||
still checks the blacklist, and still refuses. The worst a bad token achieves
|
||||
here is scoping a request that is about to be rejected anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.tenant_context import scoped_to, unscoped
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _presented(request: Request) -> str | None:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if header.startswith("Bearer "):
|
||||
return header[7:]
|
||||
return request.headers.get("X-API-Key") or request.cookies.get("access_token")
|
||||
|
||||
|
||||
def _claims(request: Request) -> dict | None:
|
||||
token = _presented(request)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def _tenant_for_api_key(raw: str) -> tuple[uuid.UUID, str] | None:
|
||||
"""The workspace an API key belongs to, and its name, for scoping only.
|
||||
|
||||
A key is not a JWT, so the claims route above yields nothing and the request
|
||||
would run with no workspace — which row-level security would answer by
|
||||
showing the caller an empty platform. That is a confusing way to fail: every
|
||||
endpoint returns 200 and nothing.
|
||||
|
||||
This costs one indexed lookup on a short-lived session of its own, because
|
||||
middleware runs before the request's session exists. As with the token
|
||||
above, it is **scoping, not authentication** — `get_current_user` still
|
||||
resolves the key properly and still refuses a bad one.
|
||||
"""
|
||||
from app.config.database import SessionLocal
|
||||
from app.services.auth import api_key_service
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
key = api_key_service.resolve(db, raw)
|
||||
return (key.tenant_id, key.name) if key else None
|
||||
except Exception:
|
||||
logger.exception("could not resolve an API key for request scoping")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class TenantScopeMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
claims = _claims(request)
|
||||
|
||||
if claims is None:
|
||||
from app.services.auth import api_key_service
|
||||
|
||||
presented = _presented(request)
|
||||
if api_key_service.looks_like_a_key(presented):
|
||||
resolved = _tenant_for_api_key(presented)
|
||||
if resolved is not None:
|
||||
tenant_id, key_name = resolved
|
||||
request.state.tenant_id = str(tenant_id)
|
||||
with scoped_to(tenant_id), api_key_service.acting_as(key_name):
|
||||
return await call_next(request)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
if claims.get("is_superadmin"):
|
||||
with unscoped():
|
||||
return await call_next(request)
|
||||
|
||||
raw_tenant = claims.get("tenant_id")
|
||||
if not raw_tenant:
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(raw_tenant))
|
||||
except ValueError:
|
||||
logger.warning("access token carried an unparseable tenant_id")
|
||||
return await call_next(request)
|
||||
|
||||
request.state.tenant_id = str(tenant_id)
|
||||
with scoped_to(tenant_id):
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Every model, in one import.
|
||||
|
||||
`Base.metadata` only knows about a model once its module has been imported.
|
||||
Nothing had imported them all in one place, so anything wanting the whole schema
|
||||
— `create_all`, a scratch database, a tool reading the metadata — had to list
|
||||
twenty modules and stay in step with them.
|
||||
|
||||
Importing here is a side effect on purpose. The names are re-exported so the
|
||||
import is not mistaken for dead code and removed.
|
||||
"""
|
||||
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.identity_model import (
|
||||
IdentityProvider,
|
||||
SsoLoginState,
|
||||
UserIdentity,
|
||||
)
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
from app.models.auth.role_access_model import RoleAccess
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.sso_grant_model import SSOGrant
|
||||
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.system.alert_state_model import AlertState
|
||||
from app.models.system.audit_log import AuditLog
|
||||
from app.models.system.event_log_model import EventLog
|
||||
from app.models.auth.api_key_model import ApiKey # noqa: F401
|
||||
from app.models.auth.seat_allocation_model import OrgUnitSeatAllocation # noqa: F401
|
||||
from app.models.auth.org_unit_model import OrgUnit, UserAdminScope, UserOrgUnit # noqa: F401
|
||||
from app.models.auth.invitation_model import UserInvitation # noqa: F401
|
||||
from app.models.system.idempotency_model import IdempotencyRecord # noqa: F401
|
||||
from app.models.system.notification_preference_model import NotificationPreference # noqa: F401
|
||||
from app.models.system.notification_model import Notification # noqa: F401
|
||||
from app.models.system.tenant_email_model import TenantEmailSettings # noqa: F401
|
||||
from app.models.system.document_model import Document # noqa: F401
|
||||
from app.models.system.lookup_model import LookupItem, LookupList # noqa: F401
|
||||
from app.models.system.webhook_model import WebhookDelivery, WebhookEndpoint # noqa: F401
|
||||
from app.models.system.mfa_model import MfaRecoveryCode, UserMfa
|
||||
from app.models.system.subscription_history_model import TenantSubscriptionHistory
|
||||
from app.models.system.subscription_notice_model import SubscriptionNotice
|
||||
from app.models.system.user_session_model import UserSession
|
||||
from app.models.theme.color_palette_model import ColorPalette
|
||||
|
||||
__all__ = [
|
||||
"Access",
|
||||
"AlertState",
|
||||
"AuditLog",
|
||||
"ColorPalette",
|
||||
"EventLog",
|
||||
"IdentityProvider",
|
||||
"MfaRecoveryCode",
|
||||
"Module",
|
||||
"ModuleAccess",
|
||||
"ModuleEnvironment",
|
||||
"PlanAccess",
|
||||
"PlanModuleAccess",
|
||||
"Role",
|
||||
"RoleAccess",
|
||||
"RoleModuleAccess",
|
||||
"SSOGrant",
|
||||
"SubscriptionNotice",
|
||||
"SsoLoginState",
|
||||
"SubscriptionPlan",
|
||||
"Tenant",
|
||||
"TenantModule",
|
||||
"TenantSubscriptionHistory",
|
||||
"User",
|
||||
"UserIdentity",
|
||||
"UserMfa",
|
||||
"UserSession",
|
||||
]
|
||||
@@ -2,4 +2,9 @@ from app.models.auth.access_model import Access
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_access_model import RoleAccess
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
@@ -12,6 +12,10 @@ class Access(Base):
|
||||
category = Column(String, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
|
||||
|
||||
|
||||
sync_checksum = Column(String, nullable=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""A key an integration authenticates with.
|
||||
|
||||
The model holds the hash and the prefix; the raw key exists once, in the response
|
||||
to creating it, and nowhere afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
def hash_secret(raw: str) -> str:
|
||||
"""One place, so the write and the lookup cannot disagree.
|
||||
|
||||
SHA-256 rather than bcrypt for the same reason invitations use it: this is
|
||||
checked on every API request, and the secret is 256 bits of randomness
|
||||
rather than something a person chose, so there is nothing to slow down.
|
||||
"""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
name = Column(String(120), nullable=False)
|
||||
prefix = Column(String(16), nullable=False)
|
||||
key_hash = Column(String(64), nullable=False)
|
||||
scopes = Column(JSONB, nullable=False, default=list)
|
||||
last_used_at = Column(DateTime(timezone=True))
|
||||
expires_at = Column(DateTime(timezone=True))
|
||||
revoked_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
owner = relationship("User", lazy="raise")
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return bool(self.expires_at and self.expires_at <= datetime.now(timezone.utc))
|
||||
|
||||
@property
|
||||
def is_live(self) -> bool:
|
||||
return not (self.revoked_at or self.is_expired)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
if self.revoked_at:
|
||||
return "revoked"
|
||||
if self.is_expired:
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
def scope_list(self) -> list[str]:
|
||||
"""Empty means "whatever the owner can do" rather than "nothing".
|
||||
|
||||
The alternative — empty meaning no permissions — would make a key with a
|
||||
forgotten scope list silently useless, and the failure would look like a
|
||||
platform bug rather than a configuration one.
|
||||
"""
|
||||
return list(self.scopes or [])
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Signing in with somebody else's identity provider.
|
||||
|
||||
Until now "SSO" in this codebase meant the platform signing users *into modules*
|
||||
— an outbound handoff. This is the other direction, and the one enterprise
|
||||
customers mean: a workspace points at its own Azure AD, Okta or Google, and its
|
||||
people sign in there rather than holding a password here.
|
||||
|
||||
Three tables:
|
||||
|
||||
- `identity_providers` — one per workspace per provider. Holds the OIDC issuer
|
||||
and client credentials, and the endpoints discovered from it.
|
||||
- `user_identities` — which account at the provider is which account here. Keyed
|
||||
on the provider's `sub`, never on the email address, because an address can be
|
||||
reassigned to a different person and `sub` cannot.
|
||||
- `sso_login_states` — the in-flight half of a login: the PKCE verifier, the
|
||||
nonce, and where to go afterwards. Short-lived and single-use.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class IdentityProviderKind(str, enum.Enum):
|
||||
OIDC = "OIDC"
|
||||
SAML = "SAML"
|
||||
|
||||
|
||||
class IdentityProvider(Base):
|
||||
"""A workspace's connection to its own identity provider."""
|
||||
|
||||
__tablename__ = "identity_providers"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
|
||||
kind = Column(String(10), nullable=False, default=IdentityProviderKind.OIDC.value)
|
||||
name = Column(String(150), nullable=False)
|
||||
slug = Column(String(100), nullable=False)
|
||||
enabled = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
issuer = Column(String(500), nullable=True)
|
||||
client_id = Column(String(255), nullable=True)
|
||||
client_secret_enc = Column(Text, nullable=True)
|
||||
scopes = Column(String(500), nullable=False, default="openid email profile")
|
||||
|
||||
authorization_endpoint = Column(String(500), nullable=True)
|
||||
token_endpoint = Column(String(500), nullable=True)
|
||||
jwks_uri = Column(String(500), nullable=True)
|
||||
discovered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
allowed_domains = Column(Text, nullable=True)
|
||||
|
||||
jit_provisioning = Column(Boolean, nullable=False, default=True)
|
||||
default_role_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("roles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
link_existing_by_email = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "slug", name="uq_identity_provider_slug"),
|
||||
)
|
||||
|
||||
def domain_list(self) -> list[str]:
|
||||
if not self.allowed_domains:
|
||||
return []
|
||||
return [d.strip().lower() for d in self.allowed_domains.split(",") if d.strip()]
|
||||
|
||||
@property
|
||||
def client_secret(self) -> str | None:
|
||||
from app.core.crypto import decrypt_json, is_encrypted
|
||||
|
||||
if not self.client_secret_enc:
|
||||
return None
|
||||
if is_encrypted(self.client_secret_enc):
|
||||
return (decrypt_json(self.client_secret_enc) or {}).get("client_secret")
|
||||
return None
|
||||
|
||||
@client_secret.setter
|
||||
def client_secret(self, value: str | None) -> None:
|
||||
from app.core.crypto import encrypt_json
|
||||
|
||||
self.client_secret_enc = (
|
||||
encrypt_json({"client_secret": value}) if value else None
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<IdentityProvider {self.slug} ({'on' if self.enabled else 'off'})>"
|
||||
|
||||
|
||||
class UserIdentity(Base):
|
||||
"""Which account at the provider is which account here.
|
||||
|
||||
Keyed on the provider's `sub`, never on the email address. An address can be
|
||||
reassigned — somebody leaves, the address is given to their replacement —
|
||||
and matching on it would hand the new person the old person's account.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_identities"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
provider_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("identity_providers.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
subject = Column(String(255), nullable=False)
|
||||
last_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider_id", "subject", name="uq_user_identity_subject"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserIdentity {self.subject} -> {self.user_id}>"
|
||||
|
||||
|
||||
class SsoLoginState(Base):
|
||||
"""A login that has started and not finished.
|
||||
|
||||
Holds what the callback needs and the browser must not carry: the PKCE
|
||||
verifier, and the nonce the id_token has to echo. Single-use and short-lived
|
||||
— a state that can be replayed is a login that can be replayed.
|
||||
"""
|
||||
|
||||
__tablename__ = "sso_login_states"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
provider_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("identity_providers.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
|
||||
state = Column(String(128), nullable=False, unique=True, index=True)
|
||||
nonce = Column(String(128), nullable=False)
|
||||
code_verifier = Column(String(256), nullable=False)
|
||||
|
||||
redirect_to = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SsoLoginState {self.state[:8]}…>"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""An invitation to join a workspace.
|
||||
|
||||
The token itself is never stored — only its SHA-256 — so this model can check an
|
||||
invitation but cannot reproduce one. That is deliberate: a "resend" makes a new
|
||||
token rather than repeating the old one, because nothing on the platform is able
|
||||
to repeat it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
"""One place, so the write and the lookup cannot disagree."""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class UserInvitation(Base):
|
||||
__tablename__ = "user_invitations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
email = Column(String(255), nullable=False)
|
||||
first_name = Column(String(100))
|
||||
last_name = Column(String(100))
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="SET NULL"))
|
||||
invited_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||
token_hash = Column(String(64), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
accepted_at = Column(DateTime(timezone=True))
|
||||
revoked_at = Column(DateTime(timezone=True))
|
||||
accepted_user_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
tenant = relationship("Tenant", lazy="raise")
|
||||
role = relationship("Role", lazy="raise")
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expires_at <= datetime.now(timezone.utc)
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
"""Still usable. Everything an accept route needs to know, in one place,
|
||||
because three separate checks is how one of them gets forgotten."""
|
||||
return not (self.accepted_at or self.revoked_at or self.is_expired)
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""For the administrator's list. An expired invitation and a revoked one
|
||||
look identical to the invitee — both refuse — but they mean different
|
||||
things to whoever sent it."""
|
||||
if self.accepted_at:
|
||||
return "accepted"
|
||||
if self.revoked_at:
|
||||
return "revoked"
|
||||
if self.is_expired:
|
||||
return "expired"
|
||||
return "pending"
|
||||
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class ModuleAccess(Base):
|
||||
__tablename__ = "module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
access_code = Column(String, nullable=False, index=True)
|
||||
|
||||
category = Column(String, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('module_accesses.id'), nullable=True, index=True)
|
||||
|
||||
sync_checksum = Column(String, nullable=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
module = relationship("Module", back_populates="module_accesses")
|
||||
role_module_accesses = relationship("RoleModuleAccess", back_populates="module_access")
|
||||
parent = relationship("ModuleAccess", remote_side=[id], backref="children")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('module_id', 'access_code', name='uq_module_access_code'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleAccess module={self.module_id} code={self.access_code}>"
|
||||
@@ -0,0 +1,61 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text, func, ForeignKey, UniqueConstraint, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class ModuleEnvironment(Base):
|
||||
__tablename__ = "module_environments"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
slug = Column(String, nullable=False, index=True)
|
||||
|
||||
frontend_base_url = Column(String, nullable=False)
|
||||
sso_entry_path = Column(String, default="/sso/start")
|
||||
|
||||
backend_base_url = Column(String, nullable=False)
|
||||
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
|
||||
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
|
||||
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
|
||||
|
||||
trust_type = Column(String, nullable=False)
|
||||
|
||||
trust_credentials = Column(JSON, nullable=False)
|
||||
trust_credentials_enc = Column(Text, nullable=True)
|
||||
|
||||
is_default = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
module = relationship("Module", back_populates="environments")
|
||||
|
||||
@property
|
||||
def credentials(self) -> dict:
|
||||
"""The trust credentials, decrypted.
|
||||
|
||||
Falls back to the legacy plaintext column so a row that has not been
|
||||
migrated yet still works. Once migration b2e1d4f5a602 has run, that
|
||||
fallback returns an empty dict and the encrypted column is the only
|
||||
source.
|
||||
"""
|
||||
from app.core.crypto import decrypt_json, is_encrypted
|
||||
|
||||
if is_encrypted(self.trust_credentials_enc):
|
||||
return decrypt_json(self.trust_credentials_enc)
|
||||
return self.trust_credentials or {}
|
||||
|
||||
@credentials.setter
|
||||
def credentials(self, value: dict) -> None:
|
||||
from app.core.crypto import encrypt_json
|
||||
|
||||
self.trust_credentials_enc = encrypt_json(value or {})
|
||||
self.trust_credentials = {}
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Integer, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class Module(Base):
|
||||
__tablename__ = "modules"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
module_id = Column(String, unique=True, nullable=False, index=True)
|
||||
module_name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
status = Column(String, default="active")
|
||||
icon_url = Column(String, nullable=True)
|
||||
display_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
|
||||
tenant_modules = relationship("TenantModule", back_populates="module", cascade="all, delete-orphan")
|
||||
module_accesses = relationship("ModuleAccess", back_populates="module", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Module {self.module_id}>"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Departments, branches and teams, and who administers which."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class OrgUnit(Base):
|
||||
__tablename__ = "org_units"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
name = Column(String(160), nullable=False)
|
||||
code = Column(String(60))
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("org_units.id", ondelete="RESTRICT"))
|
||||
path = Column(Text, nullable=False, default="/")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True))
|
||||
deleted_by_id = Column(UUID(as_uuid=True))
|
||||
|
||||
parent = relationship("OrgUnit", remote_side=[id], lazy="raise")
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
"""How far down the tree. `/` is depth 0."""
|
||||
return max(0, len([part for part in (self.path or "/").split("/") if part]) - 1)
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
return self.deleted_at is not None
|
||||
|
||||
def descendant_prefix(self) -> str:
|
||||
"""What a descendant's path starts with.
|
||||
|
||||
Includes this unit itself, because "everything I administer" always
|
||||
means "this unit and everything under it" — a lead who could not manage
|
||||
their own unit would be a strange kind of lead.
|
||||
"""
|
||||
return self.path or "/"
|
||||
|
||||
|
||||
class UserOrgUnit(Base):
|
||||
__tablename__ = "user_org_units"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
org_unit_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False)
|
||||
is_primary = Column(Boolean, nullable=False, default=False)
|
||||
is_lead = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
org_unit = relationship("OrgUnit", lazy="raise")
|
||||
|
||||
|
||||
class UserAdminScope(Base):
|
||||
"""A unit whose people this person may administer.
|
||||
|
||||
Separate from membership and from being a lead. All three exist because they
|
||||
are genuinely different: somebody can be *in* a unit without administering
|
||||
it, can *lead* it without the platform granting them anything, and can
|
||||
administer a unit they do not belong to — a regional HR administrator, for
|
||||
instance.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_admin_scopes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
org_unit_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
org_unit = relationship("OrgUnit", lazy="raise")
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class PlanAccess(Base):
|
||||
__tablename__ = "plan_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
access_id = Column(UUID(as_uuid=True), ForeignKey("accesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
plan = relationship("SubscriptionPlan", back_populates="plan_accesses")
|
||||
access = relationship("Access")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('plan_id', 'access_id', name='uq_plan_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PlanAccess plan={self.plan_id} access={self.access_id}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class PlanModuleAccess(Base):
|
||||
__tablename__ = "plan_module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
plan = relationship("SubscriptionPlan", back_populates="plan_module_accesses")
|
||||
module_access = relationship("ModuleAccess")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PlanModuleAccess plan={self.plan_id} module_access={self.module_access_id}>"
|
||||
@@ -13,7 +13,6 @@ class RoleAccess(Base):
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
role = relationship("Role", back_populates="role_accesses")
|
||||
access = relationship("Access", back_populates="role_accesses")
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
@@ -20,12 +19,14 @@ class Role(Base):
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="roles")
|
||||
users = relationship("User", back_populates="role")
|
||||
role_accesses = relationship(
|
||||
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
role_module_accesses = relationship(
|
||||
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role {self.role_name}>"
|
||||
return f"<Role {self.role_name}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class RoleModuleAccess(Base):
|
||||
__tablename__ = "role_module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False, index=True)
|
||||
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_module_accesses")
|
||||
module_access = relationship("ModuleAccess", back_populates="role_module_accesses")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoleModuleAccess role={self.role_id} access={self.module_access_id}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
"""A seat cap on one organisational unit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class OrgUnitSeatAllocation(Base):
|
||||
__tablename__ = "org_unit_seat_allocations"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
org_unit_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("org_units.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
seat_limit = Column(Integer, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False,
|
||||
server_default=func.now(), onupdate=func.now())
|
||||
|
||||
org_unit = relationship("OrgUnit", lazy="raise")
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class SSOGrant(Base):
|
||||
__tablename__ = "sso_grants"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
grant_code = Column(String, unique=True, nullable=False, index=True)
|
||||
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
||||
|
||||
environment_slug = Column(String, nullable=False)
|
||||
|
||||
is_used = Column(Boolean, default=False)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User")
|
||||
module = relationship("Module")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SSOGrant {self.grant_code} for user {self.user_id}>"
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, func, Numeric
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class SubscriptionPlan(Base):
|
||||
__tablename__ = "subscription_plans"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
price = Column(Numeric(10, 2), nullable=True)
|
||||
duration_days = Column(Integer, nullable=True)
|
||||
max_users_allowed = Column(Integer, nullable=True)
|
||||
grace_period_days = Column(Integer, nullable=False, server_default="0", default=0)
|
||||
is_public = Column(Boolean, default=True)
|
||||
status = Column(String, default="active")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
tenants = relationship("Tenant", back_populates="plan")
|
||||
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SubscriptionPlan {self.name}>"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user