Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781df9d109 | ||
|
|
af4dae50e4 | ||
|
|
235d391d4f | ||
|
|
d353fabf11 | ||
|
|
08edde726b | ||
|
|
cce404b6a3 | ||
|
|
899cd275f9 | ||
|
|
cb7de68b3c | ||
|
|
28d3bd1525 | ||
|
|
6cd665e95a | ||
|
|
b923b3ed15 | ||
|
|
a36d91fe90 |
+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=
|
||||||
+5
-5
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
|
|||||||
ALLOWED_HOSTS=*
|
ALLOWED_HOSTS=*
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||||
@@ -28,12 +28,12 @@ REDIS_PORT=6381
|
|||||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
SMTP_HOST=smtp.hostinger.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_SECURE=true
|
SMTP_SECURE=true
|
||||||
SMTP_USER=info@maskantech.in
|
SMTP_USER=info.maskantech@gmail.com
|
||||||
SMTP_PASSWORD=Infomaskan@123
|
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||||
EMAIL_FROM=info@maskantech.in
|
EMAIL_FROM=info.maskantech@gmail.com
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||||
|
|||||||
+5
-5
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
|
|||||||
ALLOWED_HOSTS=*
|
ALLOWED_HOSTS=*
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
FRONTEND_URL=https://saas-test.maskantech.in
|
FRONTEND_URL=https://saas-test.maskantech.in
|
||||||
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in
|
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in,https://docqube.com,https://docqubeapp-test.maskantech.in
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||||
@@ -28,12 +28,12 @@ REDIS_PORT=6383
|
|||||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
SMTP_HOST=smtp.hostinger.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_SECURE=true
|
SMTP_SECURE=true
|
||||||
SMTP_USER=info@maskantech.in
|
SMTP_USER=info.maskantech@gmail.com
|
||||||
SMTP_PASSWORD=Infomaskan@123
|
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||||
EMAIL_FROM=info@maskantech.in
|
EMAIL_FROM=info.maskantech@gmail.com
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||||
|
|||||||
@@ -12,3 +12,10 @@ node_modules
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.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.
|
||||||
@@ -170,6 +170,31 @@ python run.py
|
|||||||
|
|
||||||
The API will be available at `http://localhost:8000`
|
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
|
## API Documentation
|
||||||
|
|
||||||
Once the application is running, visit:
|
Once the application is running, visit:
|
||||||
@@ -277,6 +302,23 @@ The RBAC system supports:
|
|||||||
- Flexible role-to-permission mapping
|
- Flexible role-to-permission mapping
|
||||||
- Tenant-scoped roles
|
- 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
|
## Troubleshooting
|
||||||
|
|
||||||
### Database Connection Issues
|
### Database Connection Issues
|
||||||
|
|||||||
+4
-19
@@ -8,34 +8,31 @@ from sqlalchemy import pool
|
|||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
# Add parent directory to path to import app modules
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
# Load environment variables before importing app
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
app_env = os.getenv("APP_ENV", "local")
|
app_env = os.getenv("APP_ENV", "local")
|
||||||
env_filename = f".env.{app_env}"
|
env_filename = f".env.{app_env}"
|
||||||
|
|
||||||
# Define paths
|
|
||||||
base_path = Path(__file__).resolve().parent.parent
|
base_path = Path(__file__).resolve().parent.parent
|
||||||
backend_path = base_path
|
backend_path = base_path
|
||||||
|
|
||||||
# Load environment variables
|
_explicit = dict(os.environ)
|
||||||
|
|
||||||
load_dotenv(dotenv_path=base_path / '.env')
|
load_dotenv(dotenv_path=base_path / '.env')
|
||||||
load_dotenv(dotenv_path=backend_path / '.env')
|
load_dotenv(dotenv_path=backend_path / '.env')
|
||||||
|
|
||||||
# Override with specific environment config
|
|
||||||
if (base_path / env_filename).exists():
|
if (base_path / env_filename).exists():
|
||||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||||
if (backend_path / env_filename).exists():
|
if (backend_path / env_filename).exists():
|
||||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
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.settings import settings
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
|
|
||||||
# Import all models for autogenerate support
|
|
||||||
import app.models.auth.user_model
|
import app.models.auth.user_model
|
||||||
import app.models.auth.role_model
|
import app.models.auth.role_model
|
||||||
import app.models.auth.tenant_model
|
import app.models.auth.tenant_model
|
||||||
@@ -44,27 +41,15 @@ import app.models.auth.role_access_model
|
|||||||
import app.models.theme.color_palette_model
|
import app.models.theme.color_palette_model
|
||||||
import app.models.auth.subscription_plan_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
|
config = context.config
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# Set the database URL from app settings
|
|
||||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
target_metadata = Base.metadata
|
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:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode.
|
"""Run migrations in 'offline' mode.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '03a1b1f05e99'
|
revision: str = '03a1b1f05e99'
|
||||||
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
|
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('event_logs',
|
op.create_table('event_logs',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('event_id', sa.UUID(), nullable=False),
|
sa.Column('event_id', sa.UUID(), nullable=False),
|
||||||
@@ -40,14 +38,11 @@ def upgrade() -> None:
|
|||||||
op.create_index(op.f('ix_event_logs_event_id'), 'event_logs', ['event_id'], unique=False)
|
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_next_retry_at'), 'event_logs', ['next_retry_at'], unique=False)
|
||||||
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
|
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_event_logs_status'), table_name='event_logs')
|
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_next_retry_at'), table_name='event_logs')
|
||||||
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
|
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
|
||||||
op.drop_table('event_logs')
|
op.drop_table('event_logs')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "5f2e9c1a7b44"
|
revision: str = "5f2e9c1a7b44"
|
||||||
down_revision: Union[str, Sequence[str], None] = "720027c97104"
|
down_revision: Union[str, Sequence[str], None] = "720027c97104"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '63b95ea5b967'
|
revision: str = '63b95ea5b967'
|
||||||
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
|
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('module_accesses',
|
op.create_table('module_accesses',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||||
@@ -47,12 +45,10 @@ def upgrade() -> None:
|
|||||||
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
|
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
|
||||||
op.drop_column('accesses', 'module_id')
|
op.drop_column('accesses', 'module_id')
|
||||||
op.drop_column('accesses', 'scope')
|
op.drop_column('accesses', 'scope')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('accesses', sa.Column('scope', sa.VARCHAR(), autoincrement=False, nullable=False))
|
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.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.create_foreign_key(op.f('accesses_module_id_fkey'), 'accesses', 'modules', ['module_id'], ['id'])
|
||||||
@@ -67,4 +63,3 @@ def downgrade() -> None:
|
|||||||
op.drop_index(op.f('ix_module_accesses_category'), 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_index(op.f('ix_module_accesses_access_code'), table_name='module_accesses')
|
||||||
op.drop_table('module_accesses')
|
op.drop_table('module_accesses')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "6a1b2c3d4e55"
|
revision: str = "6a1b2c3d4e55"
|
||||||
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
|
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '720027c97104'
|
revision: str = '720027c97104'
|
||||||
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
|
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('event_logs', sa.Column('follow_up_event', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
op.add_column('event_logs', sa.Column('follow_up_event', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_column('event_logs', 'follow_up_event')
|
op.drop_column('event_logs', 'follow_up_event')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '73b754d5b2c5'
|
revision: str = '73b754d5b2c5'
|
||||||
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
|
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('audit_logs',
|
op.create_table('audit_logs',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('module_name', sa.String(length=100), nullable=False),
|
sa.Column('module_name', sa.String(length=100), nullable=False),
|
||||||
@@ -31,13 +29,10 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
|
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)
|
op.create_index(op.f('ix_audit_logs_module_name'), 'audit_logs', ['module_name'], unique=False)
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_audit_logs_module_name'), table_name='audit_logs')
|
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_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
|
||||||
op.drop_table('audit_logs')
|
op.drop_table('audit_logs')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ Create Date: 2026-01-20 15:11:19.596874
|
|||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
from app.core.migration_helpers import drop_foreign_key_on
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '74b6ccfaee8e'
|
revision: str = '74b6ccfaee8e'
|
||||||
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
|
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('modules',
|
op.create_table('modules',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('module_id', sa.String(), nullable=False),
|
sa.Column('module_id', sa.String(), nullable=False),
|
||||||
@@ -98,43 +97,35 @@ def upgrade() -> None:
|
|||||||
op.create_index(op.f('ix_sso_grants_tenant_id'), 'sso_grants', ['tenant_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.create_index(op.f('ix_sso_grants_user_id'), 'sso_grants', ['user_id'], unique=False)
|
||||||
|
|
||||||
# Add scope column as nullable first
|
|
||||||
op.add_column('accesses', sa.Column('scope', sa.String(), nullable=True))
|
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('module_id', sa.UUID(), nullable=True))
|
||||||
op.add_column('accesses', sa.Column('sync_checksum', sa.String(), 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.add_column('accesses', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
# Update existing rows with default scope
|
|
||||||
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
|
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
|
||||||
|
|
||||||
# Now make it not null
|
|
||||||
op.alter_column('accesses', 'scope', nullable=False)
|
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_module_id'), 'accesses', ['module_id'], unique=False)
|
||||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||||
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
|
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
|
||||||
# Inspect to see if constraint/column exists to avoid transaction abortion on failure
|
|
||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
# Check and drop foreign key
|
|
||||||
fks = inspector.get_foreign_keys('users')
|
fks = inspector.get_foreign_keys('users')
|
||||||
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
|
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
|
||||||
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
|
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
|
||||||
|
|
||||||
# Check and drop column
|
|
||||||
columns = [c['name'] for c in inspector.get_columns('users')]
|
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||||
if 'palette_id' in columns:
|
if 'palette_id' in columns:
|
||||||
op.drop_column('users', 'palette_id')
|
op.drop_column('users', 'palette_id')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('users', sa.Column('palette_id', sa.UUID(), autoincrement=False, nullable=True))
|
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'])
|
op.create_foreign_key(op.f('users_palette_id_fkey'), 'users', 'color_palettes', ['palette_id'], ['id'])
|
||||||
op.drop_constraint(None, 'accesses', type_='foreignkey')
|
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_scope'), table_name='accesses')
|
||||||
op.drop_index(op.f('ix_accesses_module_id'), 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', 'last_synced_at')
|
||||||
@@ -154,4 +145,3 @@ def downgrade() -> None:
|
|||||||
op.drop_table('module_environments')
|
op.drop_table('module_environments')
|
||||||
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
|
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
|
||||||
op.drop_table('modules')
|
op.drop_table('modules')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "7b2c4d5e6f77"
|
revision: str = "7b2c4d5e6f77"
|
||||||
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
|
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '88cfc7dee19d'
|
revision: str = '88cfc7dee19d'
|
||||||
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
|
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,19 +19,15 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_accesses_access_code'), 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=False)
|
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_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'))
|
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index('ix_access_code_saas', table_name='accesses', postgresql_where=sa.text('module_id IS NULL'))
|
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('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.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.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '8acd83604252'
|
revision: str = '8acd83604252'
|
||||||
down_revision: Union[str, Sequence[str], None] = None
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('accesses',
|
op.create_table('accesses',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('access_code', sa.String(), nullable=False),
|
sa.Column('access_code', sa.String(), nullable=False),
|
||||||
@@ -102,12 +100,10 @@ def upgrade() -> None:
|
|||||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
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_role_id'), 'users', ['role_id'], unique=False)
|
||||||
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
|
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_role_id'), table_name='users')
|
||||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||||
@@ -129,4 +125,3 @@ def downgrade() -> None:
|
|||||||
op.drop_index(op.f('ix_accesses_category'), 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_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||||
op.drop_table('accesses')
|
op.drop_table('accesses')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ Create Date: 2026-01-23 11:16:02.998798
|
|||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
from app.core.migration_helpers import drop_foreign_key_on
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '91cc93992a91'
|
revision: str = '91cc93992a91'
|
||||||
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
|
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('role_module_accesses',
|
op.create_table('role_module_accesses',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||||
@@ -36,16 +35,13 @@ def upgrade() -> None:
|
|||||||
op.add_column('module_accesses', sa.Column('parent_id', sa.UUID(), nullable=True))
|
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_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'])
|
op.create_foreign_key(None, 'module_accesses', 'module_accesses', ['parent_id'], ['id'])
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
drop_foreign_key_on("module_accesses", "parent_id")
|
||||||
op.drop_constraint(None, 'module_accesses', type_='foreignkey')
|
|
||||||
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
|
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
|
||||||
op.drop_column('module_accesses', 'parent_id')
|
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_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_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
|
||||||
op.drop_table('role_module_accesses')
|
op.drop_table('role_module_accesses')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ Create Date: 2026-04-06 14:26:41.926020
|
|||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
from app.core.migration_helpers import drop_foreign_key_on
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '9283c3f52a76'
|
revision: str = '9283c3f52a76'
|
||||||
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
|
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('subscription_plans',
|
op.create_table('subscription_plans',
|
||||||
sa.Column('id', sa.UUID(), nullable=False),
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
sa.Column('name', sa.String(), nullable=False),
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
@@ -63,13 +62,11 @@ def upgrade() -> None:
|
|||||||
op.add_column('tenants', sa.Column('plan_id', sa.UUID(), nullable=True))
|
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_index(op.f('ix_tenants_plan_id'), 'tenants', ['plan_id'], unique=False)
|
||||||
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
|
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
drop_foreign_key_on("tenants", "plan_id")
|
||||||
op.drop_constraint(None, 'tenants', type_='foreignkey')
|
|
||||||
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
|
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
|
||||||
op.drop_column('tenants', 'plan_id')
|
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_plan_id'), table_name='plan_module_accesses')
|
||||||
@@ -83,4 +80,3 @@ def downgrade() -> None:
|
|||||||
op.drop_index(op.f('ix_subscription_plans_name'), table_name='subscription_plans')
|
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_index(op.f('ix_subscription_plans_id'), table_name='subscription_plans')
|
||||||
op.drop_table('subscription_plans')
|
op.drop_table('subscription_plans')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'c37ba6143f83'
|
revision: str = 'c37ba6143f83'
|
||||||
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
|
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
|
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_column('module_environments', 'provisioning_endpoint')
|
op.drop_column('module_environments', 'provisioning_endpoint')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'cd8ba77ffd9e'
|
revision: str = 'cd8ba77ffd9e'
|
||||||
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
|
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_column('sso_grants', 'redirect_url')
|
op.drop_column('sso_grants', 'redirect_url')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('sso_grants', sa.Column('redirect_url', sa.VARCHAR(), autoincrement=False, nullable=False))
|
op.add_column('sso_grants', sa.Column('redirect_url', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'f9cf173f48f9'
|
revision: str = 'f9cf173f48f9'
|
||||||
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
|
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""Upgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('audit_logs', sa.Column('entity_id', sa.String(length=255), nullable=True))
|
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('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_id', sa.UUID(), nullable=True))
|
||||||
@@ -31,12 +29,10 @@ def upgrade() -> None:
|
|||||||
op.create_index(op.f('ix_audit_logs_action_type'), 'audit_logs', ['action_type'], unique=False)
|
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_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)
|
op.create_index(op.f('ix_audit_logs_performed_by_email'), 'audit_logs', ['performed_by_email'], unique=False)
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
"""Downgrade schema."""
|
"""Downgrade schema."""
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_audit_logs_performed_by_email'), table_name='audit_logs')
|
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_created_at'), table_name='audit_logs')
|
||||||
op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs')
|
op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs')
|
||||||
@@ -47,4 +43,3 @@ def downgrade() -> None:
|
|||||||
op.drop_column('audit_logs', 'performed_by_id')
|
op.drop_column('audit_logs', 'performed_by_id')
|
||||||
op.drop_column('audit_logs', 'entity_name')
|
op.drop_column('audit_logs', 'entity_name')
|
||||||
op.drop_column('audit_logs', 'entity_id')
|
op.drop_column('audit_logs', 'entity_id')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
+84
-9
@@ -6,7 +6,7 @@ import logging
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.config.database import engine
|
from app.config.database import engine
|
||||||
from app.routes.admin import audit_logs
|
from app.routes.admin import audit_logs, operations
|
||||||
import app.models.auth.user_model
|
import app.models.auth.user_model
|
||||||
import app.models.auth.role_model
|
import app.models.auth.role_model
|
||||||
import app.models.auth.tenant_model
|
import app.models.auth.tenant_model
|
||||||
@@ -25,7 +25,6 @@ from app.config.database import SessionLocal
|
|||||||
from app.core.redis import redis_client, sync_redis_client
|
from app.core.redis import redis_client, sync_redis_client
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=settings.LOG_LEVEL.upper(),
|
level=settings.LOG_LEVEL.upper(),
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
@@ -85,7 +84,8 @@ async def lifespan(app: FastAPI):
|
|||||||
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
_, event_id = result
|
_, event_id_bytes = result
|
||||||
|
event_id = event_id_bytes.decode("utf-8") if isinstance(event_id_bytes, bytes) else event_id_bytes
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||||
@@ -125,6 +125,10 @@ async def lifespan(app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
from app.core.rls import register_rls_listener
|
||||||
|
|
||||||
|
register_rls_listener()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
version=settings.VERSION,
|
version=settings.VERSION,
|
||||||
@@ -135,7 +139,6 @@ def create_app() -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# === OpenAPI Security Scheme ===
|
|
||||||
from fastapi.openapi.utils import get_openapi
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
|
||||||
def custom_openapi():
|
def custom_openapi():
|
||||||
@@ -159,7 +162,6 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
app.openapi = custom_openapi
|
app.openapi = custom_openapi
|
||||||
|
|
||||||
# === CORS ===
|
|
||||||
origins = []
|
origins = []
|
||||||
if settings.CORS_ALLOWED_ORIGINS:
|
if settings.CORS_ALLOWED_ORIGINS:
|
||||||
origins = [
|
origins = [
|
||||||
@@ -173,15 +175,23 @@ def create_app() -> FastAPI:
|
|||||||
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
|
"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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins,
|
allow_origins=origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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.auth import router as auth_router
|
||||||
from app.routes.auth.tenant import router as tenant_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.role import router as role_router
|
||||||
@@ -197,27 +207,85 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(access_router, prefix="/api/access", tags=["Access 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(user_router, prefix="/api/user", tags=["User Management"])
|
||||||
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
|
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
|
from app.routes.internal.module import router as internal_module_router
|
||||||
|
|
||||||
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
|
|
||||||
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
|
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(module_router, prefix="/api/modules", tags=["Modules"])
|
||||||
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal 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"])
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
from app.routes.theme.color_palette import router as palette_router
|
||||||
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
||||||
|
|
||||||
# === Admin Routes ===
|
|
||||||
from app.routes.admin.modules import router as admin_modules_router
|
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.module_environments import router as admin_module_env_router
|
||||||
from app.routes.admin.tenant_modules import router as admin_tenant_modules_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(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_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_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.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
||||||
|
|
||||||
# === Basic Routes ===
|
|
||||||
@app.get("/", tags=["Root"])
|
@app.get("/", tags=["Root"])
|
||||||
def root():
|
def root():
|
||||||
"""
|
"""
|
||||||
@@ -249,10 +317,17 @@ def create_app() -> FastAPI:
|
|||||||
logger.error(f"Health check DB error: {e}")
|
logger.error(f"Health check DB error: {e}")
|
||||||
db_status = "unhealthy"
|
db_status = "unhealthy"
|
||||||
|
|
||||||
|
from app.services.auth import module_identity
|
||||||
|
|
||||||
|
module_identity_status = (
|
||||||
|
"configured" if module_identity.is_configured() else "not configured"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||||
"environment": settings.APP_ENV,
|
"environment": settings.APP_ENV,
|
||||||
"database": db_status,
|
"database": db_status,
|
||||||
|
"module_identity": module_identity_status,
|
||||||
"version": settings.VERSION,
|
"version": settings.VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-13
@@ -9,23 +9,21 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
DATABASE_URL = settings.DATABASE_URL
|
DATABASE_URL = settings.DATABASE_URL
|
||||||
|
|
||||||
# Build connection arguments based on SSL setting
|
|
||||||
connect_args = {"connect_timeout": 10}
|
connect_args = {"connect_timeout": 10}
|
||||||
if settings.DB_SSL:
|
if settings.DB_SSL:
|
||||||
connect_args["sslmode"] = "require"
|
connect_args["sslmode"] = "require"
|
||||||
|
|
||||||
# Create the SQLAlchemy engine with optimized connection pool
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_recycle=300, # Recycle connections every 5 minutes
|
pool_recycle=300,
|
||||||
pool_size=20, # Increased from 5 to handle higher concurrency
|
pool_size=20,
|
||||||
max_overflow=30, # Increased from 10 for peak load handling
|
max_overflow=30,
|
||||||
pool_timeout=30, # Connection acquisition timeout
|
pool_timeout=30,
|
||||||
pool_reset_on_return='commit', # Reset connections on return
|
pool_reset_on_return='commit',
|
||||||
connect_args=connect_args,
|
connect_args=connect_args,
|
||||||
echo=False, # Disable SQL logging in production
|
echo=False,
|
||||||
future=True # Use SQLAlchemy 2.0 style
|
future=True
|
||||||
)
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
@@ -36,16 +34,12 @@ def get_db():
|
|||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# Re-raise HTTPExceptions without logging as database errors
|
|
||||||
# These are application-level errors, not database errors
|
|
||||||
raise
|
raise
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
# Log actual database errors
|
|
||||||
logger.error(f"Database error: {e}")
|
logger.error(f"Database error: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log other unexpected errors
|
|
||||||
logger.error(f"Unexpected database session error: {e}")
|
logger.error(f"Unexpected database session error: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|||||||
+120
-9
@@ -24,17 +24,52 @@ class SecurityUtils:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
"""Verify a password against its hash."""
|
"""Verify a password against its hash (supports bcrypt and PBKDF2)."""
|
||||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
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
|
@staticmethod
|
||||||
def generate_access_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
def is_legacy_hash(hashed_password: str) -> bool:
|
||||||
"""Generate JWT access token."""
|
"""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,
|
||||||
|
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()
|
to_encode = data.copy()
|
||||||
|
if is_superadmin:
|
||||||
|
to_encode["is_superadmin"] = True
|
||||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
|
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
|
||||||
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
|
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
|
||||||
|
|
||||||
# Include tenant_id if provided
|
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
to_encode["tenant_id"] = str(tenant_id)
|
to_encode["tenant_id"] = str(tenant_id)
|
||||||
|
|
||||||
@@ -45,13 +80,25 @@ class SecurityUtils:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
def generate_refresh_token(
|
||||||
"""Generate JWT 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()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
|
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
|
||||||
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
to_encode.update({
|
||||||
|
"exp": expire,
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": str(jti) if jti else str(uuid.uuid4()),
|
||||||
|
})
|
||||||
|
|
||||||
# Include tenant_id if provided
|
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
to_encode["tenant_id"] = str(tenant_id)
|
to_encode["tenant_id"] = str(tenant_id)
|
||||||
|
|
||||||
@@ -118,6 +165,19 @@ class SecurityUtils:
|
|||||||
detail="Invalid token type"
|
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
|
return payload
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -130,6 +190,57 @@ class SecurityUtils:
|
|||||||
detail="Invalid refresh token"
|
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
|
@staticmethod
|
||||||
def generate_otp(length: int = 6) -> str:
|
def generate_otp(length: int = 6) -> str:
|
||||||
"""Generate a random OTP."""
|
"""Generate a random OTP."""
|
||||||
|
|||||||
+22
-11
@@ -10,6 +10,8 @@ env_filename = f".env.{app_env}"
|
|||||||
base_path = Path(__file__).resolve().parent.parent.parent
|
base_path = Path(__file__).resolve().parent.parent.parent
|
||||||
backend_path = Path(__file__).resolve().parent.parent
|
backend_path = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
_explicit = dict(os.environ)
|
||||||
|
|
||||||
load_dotenv(dotenv_path=base_path / '.env')
|
load_dotenv(dotenv_path=base_path / '.env')
|
||||||
load_dotenv(dotenv_path=backend_path / '.env')
|
load_dotenv(dotenv_path=backend_path / '.env')
|
||||||
|
|
||||||
@@ -18,31 +20,40 @@ if (base_path / env_filename).exists():
|
|||||||
if (backend_path / env_filename).exists():
|
if (backend_path / env_filename).exists():
|
||||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||||
|
|
||||||
|
os.environ.update(_explicit)
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
PROJECT_NAME: str
|
PROJECT_NAME: str
|
||||||
VERSION: str
|
VERSION: str
|
||||||
|
|
||||||
# FastAPI
|
|
||||||
PORT: int
|
PORT: int
|
||||||
HOST: str
|
HOST: str
|
||||||
APP_ENV: str
|
APP_ENV: str
|
||||||
SECRET_KEY: str
|
SECRET_KEY: str
|
||||||
ALLOWED_HOSTS: str = "*"
|
ALLOWED_HOSTS: str = "*"
|
||||||
|
|
||||||
# Frontend
|
|
||||||
FRONTEND_URL: str
|
FRONTEND_URL: str
|
||||||
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
||||||
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
||||||
|
|
||||||
# Security
|
|
||||||
ENCRYPTION_KEY: Optional[str] = None
|
ENCRYPTION_KEY: Optional[str] = None
|
||||||
BCRYPT_ROUNDS: int = 12
|
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
|
DATABASE_URL: str
|
||||||
DB_SSL: bool = False
|
DB_SSL: bool = False
|
||||||
|
|
||||||
# Redis Configuration
|
|
||||||
REDIS_HOST: str
|
REDIS_HOST: str
|
||||||
REDIS_PORT: int
|
REDIS_PORT: int
|
||||||
REDIS_PASSWORD: Optional[str]
|
REDIS_PASSWORD: Optional[str]
|
||||||
@@ -58,7 +69,6 @@ class Settings(BaseSettings):
|
|||||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
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}"
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||||
|
|
||||||
# Email
|
|
||||||
SMTP_HOST: str
|
SMTP_HOST: str
|
||||||
SMTP_PORT: int
|
SMTP_PORT: int
|
||||||
SMTP_SECURE: bool = True
|
SMTP_SECURE: bool = True
|
||||||
@@ -66,28 +76,29 @@ class Settings(BaseSettings):
|
|||||||
SMTP_PASSWORD: str
|
SMTP_PASSWORD: str
|
||||||
EMAIL_FROM: str
|
EMAIL_FROM: str
|
||||||
|
|
||||||
# JWT settings
|
|
||||||
ACCESS_TOKEN_SECRET: str
|
ACCESS_TOKEN_SECRET: str
|
||||||
ACCESS_TOKEN_EXPIRES: int = 900
|
ACCESS_TOKEN_EXPIRES: int = 900
|
||||||
REFRESH_TOKEN_SECRET: str
|
REFRESH_TOKEN_SECRET: str
|
||||||
REFRESH_TOKEN_EXPIRES: int = 864000
|
REFRESH_TOKEN_EXPIRES: int = 864000
|
||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
|
||||||
# Cookie settings
|
|
||||||
COOKIE_SECURE: bool = False
|
COOKIE_SECURE: bool = False
|
||||||
COOKIE_DOMAIN: Optional[str] = None
|
COOKIE_DOMAIN: Optional[str] = None
|
||||||
|
|
||||||
# Super Admin Setup
|
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_EMAIL: str
|
||||||
SUPER_ADMIN_PASSWORD: str
|
SUPER_ADMIN_PASSWORD: str
|
||||||
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
||||||
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
||||||
|
|
||||||
# Module Integration Security (RS256)
|
|
||||||
SAAS_PRIVATE_KEY: Optional[str] = None
|
SAAS_PRIVATE_KEY: Optional[str] = None
|
||||||
SAAS_KEY_ID: str = "saas-key-v1"
|
SAAS_KEY_ID: str = "saas-key-v1"
|
||||||
|
|
||||||
# PayPal Integration
|
|
||||||
PAYPAL_CLIENT_ID: str
|
PAYPAL_CLIENT_ID: str
|
||||||
PAYPAL_CLIENT_SECRET: str
|
PAYPAL_CLIENT_SECRET: str
|
||||||
PAYPAL_MODE: str = "sandbox"
|
PAYPAL_MODE: str = "sandbox"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List, Any
|
||||||
from app.services.auth.access_service import AccessService
|
from app.services.auth.access_service import AccessService
|
||||||
from app.schemas.auth.access_schema import AccessResponse
|
from app.schemas.auth.access_schema import AccessResponse
|
||||||
|
|
||||||
class AccessController:
|
class AccessController:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all_accesses(db: Session, category: str = None) -> List[AccessResponse]:
|
def get_all_accesses(db: Session, category: str = None, tenant_id: Any = None) -> List[AccessResponse]:
|
||||||
return AccessService.get_all_accesses(db, category)
|
return AccessService.get_all_accesses(db, category, tenant_id)
|
||||||
@@ -20,8 +20,10 @@ class AuthController:
|
|||||||
return AuthService.create_user(db, user_data, tenant_id)
|
return AuthService.create_user(db, user_data, tenant_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def signin(db: Session, signin_data: UserSignin):
|
def signin(db: Session, signin_data: UserSignin, user_agent=None, ip_address=None):
|
||||||
return AuthService.signin(db, signin_data)
|
return AuthService.signin(
|
||||||
|
db, signin_data, user_agent=user_agent, ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def refresh_token(db: Session, token_data: RefreshTokenRequest):
|
def refresh_token(db: Session, token_data: RefreshTokenRequest):
|
||||||
@@ -44,8 +46,20 @@ class AuthController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def logout(current_user: User, token: str):
|
def logout(current_user: User, token: str, refresh_token: str = None, db: Session = None):
|
||||||
return AuthService.logout(current_user, token)
|
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
|
@staticmethod
|
||||||
def me(db: Session, current_user: User):
|
def me(db: Session, current_user: User):
|
||||||
|
|||||||
@@ -26,8 +26,19 @@ class RoleController:
|
|||||||
return RoleService.get_all_roles(db, tenant_id)
|
return RoleService.get_all_roles(db, tenant_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_role_by_id(db: Session, role_id: uuid.UUID) -> Role:
|
def get_role_by_id(
|
||||||
return RoleService.get_role_by_id(db, role_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
|
@staticmethod
|
||||||
def update_role(
|
def update_role(
|
||||||
@@ -35,20 +46,43 @@ class RoleController:
|
|||||||
role_id: uuid.UUID,
|
role_id: uuid.UUID,
|
||||||
role_data: RoleUpdate,
|
role_data: RoleUpdate,
|
||||||
is_superadmin: bool = False,
|
is_superadmin: bool = False,
|
||||||
|
*,
|
||||||
|
actor_tenant_id: Optional[uuid.UUID] = None,
|
||||||
) -> Role:
|
) -> Role:
|
||||||
return RoleService.update_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
|
@staticmethod
|
||||||
def delete_role(db: Session, role_id: uuid.UUID, is_superadmin: bool = False):
|
def delete_role(
|
||||||
return RoleService.delete_role(db, role_id, is_superadmin=is_superadmin)
|
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
|
@staticmethod
|
||||||
def get_role_with_accesses(
|
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:
|
) -> 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 = [
|
accesses = [
|
||||||
{
|
{
|
||||||
@@ -57,11 +91,12 @@ class RoleController:
|
|||||||
"category": ra.access.category,
|
"category": ra.access.category,
|
||||||
"name": ra.access.name,
|
"name": ra.access.name,
|
||||||
"parent_id": str(ra.access.parent_id) if ra.access.parent_id else None,
|
"parent_id": str(ra.access.parent_id) if ra.access.parent_id else None,
|
||||||
|
"module_name": "SaaS (Internal)",
|
||||||
}
|
}
|
||||||
for ra in role.role_accesses
|
for ra in role.role_accesses
|
||||||
|
if ra.access
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add Module Permissions
|
|
||||||
accesses.extend([
|
accesses.extend([
|
||||||
{
|
{
|
||||||
"id": str(rma.module_access.id),
|
"id": str(rma.module_access.id),
|
||||||
@@ -69,8 +104,11 @@ class RoleController:
|
|||||||
"category": rma.module_access.category,
|
"category": rma.module_access.category,
|
||||||
"name": rma.module_access.name,
|
"name": rma.module_access.name,
|
||||||
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
|
"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
|
for rma in role.role_module_accesses
|
||||||
|
if rma.module_access
|
||||||
])
|
])
|
||||||
|
|
||||||
return RoleWithAccessesResponse(
|
return RoleWithAccessesResponse(
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class SSOController:
|
|||||||
db: Session,
|
db: Session,
|
||||||
payload: SSOExchangeRequest,
|
payload: SSOExchangeRequest,
|
||||||
x_module_signature: Optional[str] = None,
|
x_module_signature: Optional[str] = None,
|
||||||
x_module_key: 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()
|
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
|
||||||
if not module:
|
if not module:
|
||||||
@@ -46,23 +47,21 @@ class SSOController:
|
|||||||
headers["X-Module-Signature"] = x_module_signature
|
headers["X-Module-Signature"] = x_module_signature
|
||||||
if x_module_key:
|
if x_module_key:
|
||||||
headers["X-Module-Key"] = 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()
|
actual_body = payload.model_dump_json()
|
||||||
try:
|
|
||||||
TrustService.validate_module_trust(
|
TrustService.validate_module_trust(
|
||||||
environment=env,
|
environment=env,
|
||||||
request_headers=headers,
|
request_headers=headers,
|
||||||
request_body=actual_body
|
request_body=actual_body,
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
logger.warning(
|
|
||||||
"HMAC verify with body failed for %s, trying empty fallback (DEPRECATED)",
|
|
||||||
payload.module_id,
|
|
||||||
)
|
|
||||||
TrustService.validate_module_trust(
|
|
||||||
environment=env,
|
|
||||||
request_headers=headers,
|
|
||||||
request_body=""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return SSOService.exchange_grant(
|
return SSOService.exchange_grant(
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from typing import List, Optional
|
|||||||
class TenantController:
|
class TenantController:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_tenant(db: Session, tenant_data: TenantCreate):
|
def create_tenant(db: Session, tenant_data: TenantCreate, actor=None):
|
||||||
return TenantService.create_tenant(db, tenant_data)
|
return TenantService.create_tenant(db, tenant_data, actor=actor)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all_tenants(db: Session):
|
def get_all_tenants(db: Session):
|
||||||
@@ -19,8 +19,14 @@ class TenantController:
|
|||||||
return TenantService.get_tenant_by_id(db, tenant_id)
|
return TenantService.get_tenant_by_id(db, tenant_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate):
|
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate, actor=None):
|
||||||
return TenantService.update_tenant(db, tenant_id, tenant_data)
|
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
|
@staticmethod
|
||||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import uuid
|
|||||||
from app.models.auth.user_model import User
|
from app.models.auth.user_model import User
|
||||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate
|
from app.schemas.auth.user_schema import UserCreate, UserUpdate
|
||||||
from app.services.auth.user_service import UserService
|
from app.services.auth.user_service import UserService
|
||||||
|
from app.middleware.tenant_middleware import is_superadmin
|
||||||
|
|
||||||
class UserController:
|
class UserController:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_tenant_id(current_user: User, requested_tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
|
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
|
return requested_tenant_id
|
||||||
|
|
||||||
if requested_tenant_id and requested_tenant_id != current_user.tenant_id:
|
if requested_tenant_id and requested_tenant_id != current_user.tenant_id:
|
||||||
@@ -25,28 +26,86 @@ class UserController:
|
|||||||
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||||
return UserService.create_user(db, user_data, tenant_id, background_tasks)
|
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
|
@staticmethod
|
||||||
def get_all_users(db: Session, current_user: User) -> List[User]:
|
def get_all_users(db: Session, current_user: User) -> List[User]:
|
||||||
tenant_id = current_user.tenant_id
|
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||||
return UserService.get_all_users(db, tenant_id)
|
return UserService.get_all_users(
|
||||||
|
db, tenant_id, visible_ids=UserController._visible_ids(db, current_user)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_by_id(db: Session, user_id: uuid.UUID, current_user: User) -> User:
|
def get_user_by_id(db: Session, user_id: uuid.UUID, current_user: User) -> User:
|
||||||
tenant_id = current_user.tenant_id
|
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||||
return UserService.get_user_by_id(db, user_id, tenant_id)
|
return UserService.get_user_by_id(
|
||||||
|
db, user_id, tenant_id,
|
||||||
|
visible_ids=UserController._visible_ids(db, current_user),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, background_tasks: BackgroundTasks) -> User:
|
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, background_tasks: BackgroundTasks) -> User:
|
||||||
if current_user.tenant_id is not None and user_data.tenant_id is not None:
|
if not is_superadmin(current_user) and user_data.tenant_id is not None:
|
||||||
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||||
|
|
||||||
tenant_id = current_user.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)
|
return UserService.update_user(db, user_id, user_data, tenant_id, background_tasks)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
|
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||||
tenant_id = current_user.tenant_id
|
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||||
return UserService.delete_user(db, user_id, tenant_id)
|
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
|
@staticmethod
|
||||||
def get_users_paginated(
|
def get_users_paginated(
|
||||||
@@ -64,6 +123,7 @@ class UserController:
|
|||||||
sort_order: Optional[str] = None,
|
sort_order: Optional[str] = None,
|
||||||
):
|
):
|
||||||
tenant_id = current_user.tenant_id
|
tenant_id = current_user.tenant_id
|
||||||
|
visible = UserController._visible_ids(db, current_user)
|
||||||
return UserService.get_users_paginated(
|
return UserService.get_users_paginated(
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -77,4 +137,5 @@ class UserController:
|
|||||||
filter_role_ids=filter_role_ids,
|
filter_role_ids=filter_role_ids,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
|
visible_ids=visible,
|
||||||
)
|
)
|
||||||
@@ -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")
|
||||||
+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]
|
||||||
@@ -8,6 +8,30 @@ from app.config.security import security
|
|||||||
from app.models.auth.user_model import User
|
from app.models.auth.user_model import User
|
||||||
from app.models.auth.access_model import Access
|
from app.models.auth.access_model import Access
|
||||||
from app.models.auth.tenant_model import Tenant
|
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 (
|
from app.services.auth.subscription_entitlement_service import (
|
||||||
SubscriptionEntitlementService,
|
SubscriptionEntitlementService,
|
||||||
)
|
)
|
||||||
@@ -19,12 +43,19 @@ def get_current_user(
|
|||||||
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
) -> User:
|
) -> 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:
|
if not token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Not authenticated"
|
detail="Not authenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if api_key_service.looks_like_a_key(token):
|
||||||
|
return _user_for_api_key(request, token, db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = security.verify_access_token(token)
|
payload = security.verify_access_token(token)
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
@@ -41,6 +72,7 @@ def get_current_user(
|
|||||||
detail="Could not validate credentials"
|
detail="Could not validate credentials"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with unscoped():
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -49,6 +81,7 @@ def get_current_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
setattr(user, "_saas_db_session", db)
|
setattr(user, "_saas_db_session", db)
|
||||||
|
_forget_api_key(user)
|
||||||
|
|
||||||
if user.status != "active":
|
if user.status != "active":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -56,29 +89,89 @@ def get_current_user(
|
|||||||
detail="User is inactive"
|
detail="User is inactive"
|
||||||
)
|
)
|
||||||
|
|
||||||
if user.tenant_id is not None:
|
_assert_workspace_usable(request, user, db)
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
today = datetime.now(timezone.utc).date()
|
|
||||||
if tenant.end_date and tenant.end_date <= today and tenant.status != "EXPIRED":
|
|
||||||
tenant.status = "EXPIRED"
|
|
||||||
tenant.is_active = False
|
|
||||||
db.commit()
|
|
||||||
db.refresh(tenant)
|
|
||||||
|
|
||||||
if not tenant.is_active or tenant.status in {"INACTIVE", "EXPIRED"}:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Tenant is inactive"
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
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:
|
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
if current_user.status != "active":
|
if current_user.status != "active":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -95,7 +188,11 @@ def has_access(user: User, access_code: str) -> bool:
|
|||||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||||
db, user
|
db, user
|
||||||
)
|
)
|
||||||
return access_code in user_access_codes
|
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:
|
def can_access(user: User, access_code: str, db: Session) -> bool:
|
||||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||||
|
|||||||
@@ -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
|
return user.tenant_id
|
||||||
|
|
||||||
def is_superadmin(user) -> bool:
|
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)):
|
def require_superadmin(user=Depends(get_current_user)):
|
||||||
if not is_superadmin(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",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
|
from sqlalchemy import Column, String, Boolean, DateTime, Text, func, ForeignKey, UniqueConstraint, JSON
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
@@ -20,7 +20,9 @@ class ModuleEnvironment(Base):
|
|||||||
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
|
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
|
||||||
|
|
||||||
trust_type = Column(String, nullable=False)
|
trust_type = Column(String, nullable=False)
|
||||||
|
|
||||||
trust_credentials = Column(JSON, nullable=False)
|
trust_credentials = Column(JSON, nullable=False)
|
||||||
|
trust_credentials_enc = Column(Text, nullable=True)
|
||||||
|
|
||||||
is_default = Column(Boolean, default=False)
|
is_default = Column(Boolean, default=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
@@ -29,6 +31,28 @@ class ModuleEnvironment(Base):
|
|||||||
|
|
||||||
module = relationship("Module", back_populates="environments")
|
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__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
|
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -13,7 +13,6 @@ class RoleAccess(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# Relationships
|
|
||||||
role = relationship("Role", back_populates="role_accesses")
|
role = relationship("Role", back_populates="role_accesses")
|
||||||
access = relationship("Access", back_populates="role_accesses")
|
access = relationship("Access", back_populates="role_accesses")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -15,11 +15,10 @@ class SSOGrant(Base):
|
|||||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
||||||
|
|
||||||
environment_slug = Column(String, nullable=False)
|
environment_slug = Column(String, nullable=False)
|
||||||
# redirect_url removed - stateless grants
|
|
||||||
|
|
||||||
is_used = Column(Boolean, default=False)
|
is_used = Column(Boolean, default=False)
|
||||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=False) # 60 seconds
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class SubscriptionPlan(Base):
|
|||||||
price = Column(Numeric(10, 2), nullable=True)
|
price = Column(Numeric(10, 2), nullable=True)
|
||||||
duration_days = Column(Integer, nullable=True)
|
duration_days = Column(Integer, nullable=True)
|
||||||
max_users_allowed = 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)
|
is_public = Column(Boolean, default=True)
|
||||||
status = Column(String, default="active")
|
status = Column(String, default="active")
|
||||||
|
|
||||||
@@ -21,7 +22,6 @@ class SubscriptionPlan(Base):
|
|||||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
|
||||||
tenants = relationship("Tenant", back_populates="plan")
|
tenants = relationship("Tenant", back_populates="plan")
|
||||||
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
|
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||||
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
|
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ class Tenant(Base):
|
|||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
end_date = Column(Date, nullable=True)
|
end_date = Column(Date, nullable=True)
|
||||||
status = Column(String, nullable=False, default="ACTIVE", index=True)
|
status = Column(String, nullable=False, default="ACTIVE", index=True)
|
||||||
|
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
billing_email = Column(String(255), nullable=True)
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Relationships
|
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
deleted_by_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
|
||||||
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
|
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
@@ -32,5 +36,9 @@ class Tenant(Base):
|
|||||||
def tenant_id(self):
|
def tenant_id(self):
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self) -> bool:
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Tenant {self.tenant_name}>"
|
return f"<Tenant {self.tenant_name}>"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
@@ -16,6 +16,12 @@ class User(Base):
|
|||||||
preferred_language = Column(String, default="en", nullable=True)
|
preferred_language = Column(String, default="en", nullable=True)
|
||||||
status = Column(String, default="active", nullable=False)
|
status = Column(String, default="active", nullable=False)
|
||||||
|
|
||||||
|
failed_login_attempts = Column(Integer, default=0, nullable=False)
|
||||||
|
locked_until = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_failed_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
is_superadmin = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
||||||
|
|
||||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True)
|
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True)
|
||||||
@@ -24,9 +30,16 @@ class User(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||||
password_updated_at = Column(DateTime(timezone=True), server_default=func.now())
|
password_updated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
deleted_by_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
deleted_email = Column(String, nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self) -> bool:
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
tenant = relationship("Tenant", back_populates="users")
|
tenant = relationship("Tenant", back_populates="users")
|
||||||
role = relationship("Role", back_populates="users")
|
role = relationship("Role", back_populates="users")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User {self.email}>"
|
return f"<User {self.email}>"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AlertState(Base):
|
||||||
|
"""One open condition, and when it was last shouted about.
|
||||||
|
|
||||||
|
Not tenant-scoped, deliberately: a stuck outbox or a detected token reuse
|
||||||
|
belongs to the platform, not to any one workspace, and the people who act on
|
||||||
|
them are the ones who can already see everything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "alert_state"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
alert_key = Column(String(60), nullable=False, unique=True, index=True)
|
||||||
|
severity = Column(String(20), nullable=False)
|
||||||
|
detail = Column(Text, nullable=True)
|
||||||
|
observed = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
opened_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
last_notified_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
notify_count = Column(Integer, nullable=False, server_default="0", default=0)
|
||||||
|
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self.resolved_at is None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<AlertState {self.alert_key} {'open' if self.is_open else 'resolved'}>"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import Column, String, DateTime, Text
|
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
@@ -9,6 +9,13 @@ class AuditLog(Base):
|
|||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||||
|
|
||||||
|
tenant_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tenants.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
module_name = Column(String(100), nullable=False, index=True)
|
module_name = Column(String(100), nullable=False, index=True)
|
||||||
|
|
||||||
action_type = Column(String(20), nullable=False, index=True)
|
action_type = Column(String(20), nullable=False, index=True)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""A file attached to something."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
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)
|
||||||
|
uploaded_by_id = Column(UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
entity_type = Column(String(60))
|
||||||
|
entity_id = Column(String(64))
|
||||||
|
filename = Column(String(255), nullable=False)
|
||||||
|
content_type = Column(String(120), nullable=False)
|
||||||
|
size_bytes = Column(BigInteger, nullable=False)
|
||||||
|
checksum = Column(String(64), nullable=False)
|
||||||
|
storage_key = Column(String(120), nullable=False)
|
||||||
|
description = Column(String(500))
|
||||||
|
deleted_at = Column(DateTime(timezone=True))
|
||||||
|
deleted_by_id = Column(UUID(as_uuid=True))
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self) -> bool:
|
||||||
|
return self.deleted_at is not None
|
||||||
@@ -13,7 +13,7 @@ class EventLog(Base):
|
|||||||
__tablename__ = "event_logs"
|
__tablename__ = "event_logs"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
event_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Idempotency Key
|
event_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
event_type = Column(String, nullable=False)
|
event_type = Column(String, nullable=False)
|
||||||
payload = Column(JSONB, nullable=False)
|
payload = Column(JSONB, nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""A record of a request that has already been answered."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyRecord(Base):
|
||||||
|
__tablename__ = "idempotency_records"
|
||||||
|
|
||||||
|
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"))
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
idempotency_key = Column(String(255), nullable=False)
|
||||||
|
endpoint = Column(String(255), nullable=False)
|
||||||
|
request_hash = Column(String(64), nullable=False)
|
||||||
|
state = Column(String(20), nullable=False, default="in_progress")
|
||||||
|
response_status = Column(Integer)
|
||||||
|
response_body = Column(JSONB)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
completed_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return self.expires_at <= datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_replayable(self) -> bool:
|
||||||
|
return self.state == "completed" and not self.is_expired
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Reference lists and the items in them."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LookupList(Base):
|
||||||
|
__tablename__ = "lookup_lists"
|
||||||
|
|
||||||
|
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"))
|
||||||
|
code = Column(String(60), nullable=False)
|
||||||
|
name = Column(String(150), nullable=False)
|
||||||
|
description = Column(String(500))
|
||||||
|
allows_custom_items = Column(Boolean, nullable=False, default=True)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
items = relationship("LookupItem", back_populates="list", lazy="raise",
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_platform(self) -> bool:
|
||||||
|
return self.tenant_id is None
|
||||||
|
|
||||||
|
|
||||||
|
class LookupItem(Base):
|
||||||
|
__tablename__ = "lookup_items"
|
||||||
|
|
||||||
|
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"))
|
||||||
|
list_id = Column(UUID(as_uuid=True),
|
||||||
|
ForeignKey("lookup_lists.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
code = Column(String(60), nullable=False)
|
||||||
|
label = Column(String(200), nullable=False)
|
||||||
|
metadata_json = Column(JSONB)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
list = relationship("LookupList", back_populates="items", lazy="raise")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_platform(self) -> bool:
|
||||||
|
return self.tenant_id is None
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserMfa(Base):
|
||||||
|
"""Somebody's second factor.
|
||||||
|
|
||||||
|
One per person: two would make "is MFA on for this account" ambiguous, and
|
||||||
|
the answer to that has to be a yes or a no.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_mfa"
|
||||||
|
|
||||||
|
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=True, index=True,
|
||||||
|
)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
secret_enc = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
confirmed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
last_counter = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
disabled_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", name="uq_user_mfa_user"),)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self.confirmed_at is not None and self.disabled_at is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secret(self) -> str | None:
|
||||||
|
from app.core.crypto import decrypt_json, is_encrypted
|
||||||
|
|
||||||
|
if not self.secret_enc or not is_encrypted(self.secret_enc):
|
||||||
|
return None
|
||||||
|
return (decrypt_json(self.secret_enc) or {}).get("secret")
|
||||||
|
|
||||||
|
@secret.setter
|
||||||
|
def secret(self, value: str) -> None:
|
||||||
|
from app.core.crypto import encrypt_json
|
||||||
|
|
||||||
|
self.secret_enc = encrypt_json({"secret": value})
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<UserMfa {self.user_id} {'active' if self.is_active else 'pending'}>"
|
||||||
|
|
||||||
|
|
||||||
|
class MfaRecoveryCode(Base):
|
||||||
|
"""A way back in when the phone is gone.
|
||||||
|
|
||||||
|
Hashed rather than encrypted: this is a credential the *user* holds, and the
|
||||||
|
platform only ever needs to check one, never to read it back. Storing them
|
||||||
|
reversibly would make a database dump a set of working second factors.
|
||||||
|
|
||||||
|
Single-use — `used_at` rather than a delete, so "I used three of my ten" is
|
||||||
|
answerable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "mfa_recovery_codes"
|
||||||
|
|
||||||
|
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=True, index=True,
|
||||||
|
)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
code_hash = Column(String(255), nullable=False)
|
||||||
|
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<MfaRecoveryCode {self.user_id} {'used' if self.used_at else 'unused'}>"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""A notice addressed to one person."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
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"))
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
kind = Column(String(60), nullable=False)
|
||||||
|
severity = Column(String(20), nullable=False, default="info")
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
body = Column(String(1000))
|
||||||
|
link = Column(String(500))
|
||||||
|
data = Column(JSONB)
|
||||||
|
read_at = Column(DateTime(timezone=True))
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_read(self) -> bool:
|
||||||
|
return self.read_at is not None
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""What somebody has chosen not to be told about.
|
||||||
|
|
||||||
|
A row exists only when a preference differs from the default. Absence means
|
||||||
|
enabled — see the migration for why that is the right way round.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
IN_APP = "in_app"
|
||||||
|
EMAIL = "email"
|
||||||
|
CHANNELS = (IN_APP, EMAIL)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPreference(Base):
|
||||||
|
__tablename__ = "notification_preferences"
|
||||||
|
|
||||||
|
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"))
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
notification_type = Column(String(100), nullable=False)
|
||||||
|
channel = Column(String(50), nullable=False, default=IN_APP)
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
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())
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Date, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSubscriptionHistory(Base):
|
||||||
|
"""What changed about a workspace's subscription, when, and who did it.
|
||||||
|
|
||||||
|
Append-only by convention: nothing updates a row here. Answering "why is this
|
||||||
|
workspace on that plan" previously required guessing from the current state,
|
||||||
|
which is no answer at all once more than one person can make the change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tenant_subscription_history"
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
from_plan_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
to_plan_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
change_type = Column(String(30), nullable=False)
|
||||||
|
|
||||||
|
from_end_date = Column(Date, nullable=True)
|
||||||
|
to_end_date = Column(Date, nullable=True)
|
||||||
|
from_status = Column(String(30), nullable=True)
|
||||||
|
to_status = Column(String(30), nullable=True)
|
||||||
|
|
||||||
|
changed_by_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
changed_by_email = Column(String, nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TenantSubscriptionHistory {self.tenant_id} {self.change_type}>"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Date, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionNotice(Base):
|
||||||
|
"""A record that a workspace has already been told something.
|
||||||
|
|
||||||
|
Exists so a worker running twice in a day does not send the same warning
|
||||||
|
twice. Keyed on the end date the notice was *about*, so a renewal starts a
|
||||||
|
fresh cycle and next time's warning fires normally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "subscription_notices"
|
||||||
|
|
||||||
|
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(30), nullable=False)
|
||||||
|
for_end_date = Column(Date, nullable=True)
|
||||||
|
sent_to = Column(String(255), nullable=True)
|
||||||
|
sent_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "kind", "for_end_date", name="uq_subscription_notice_once"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SubscriptionNotice {self.kind} {self.tenant_id}>"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""A workspace's own outgoing mail configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TenantEmailSettings(Base):
|
||||||
|
__tablename__ = "tenant_email_settings"
|
||||||
|
|
||||||
|
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)
|
||||||
|
smtp_host = Column(String(255), nullable=False)
|
||||||
|
smtp_port = Column(Integer, nullable=False, default=587)
|
||||||
|
smtp_user = Column(String(255))
|
||||||
|
smtp_password_enc = Column(Text)
|
||||||
|
use_ssl = Column(Boolean, nullable=False, default=False)
|
||||||
|
from_address = Column(String(255), nullable=False)
|
||||||
|
from_name = Column(String(150))
|
||||||
|
is_active = Column(Boolean, nullable=False, default=False)
|
||||||
|
last_verified_at = Column(DateTime(timezone=True))
|
||||||
|
last_error = Column(String(500))
|
||||||
|
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())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def smtp_password(self) -> str | None:
|
||||||
|
"""Encrypted, not hashed — unlike every other credential here.
|
||||||
|
|
||||||
|
The platform has to *present* this one to somebody else's server, so
|
||||||
|
there is no version of this where a hash would do. Fernet, like the
|
||||||
|
module trust credentials and the webhook signing secrets.
|
||||||
|
"""
|
||||||
|
from app.core.crypto import decrypt_json, is_encrypted
|
||||||
|
|
||||||
|
if not self.smtp_password_enc:
|
||||||
|
return None
|
||||||
|
if is_encrypted(self.smtp_password_enc):
|
||||||
|
return (decrypt_json(self.smtp_password_enc) or {}).get("password")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@smtp_password.setter
|
||||||
|
def smtp_password(self, value: str | None) -> None:
|
||||||
|
from app.core.crypto import encrypt_json
|
||||||
|
|
||||||
|
self.smtp_password_enc = (
|
||||||
|
encrypt_json({"password": value}) if value else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sender(self) -> str:
|
||||||
|
"""`Name <address>` when a name is set, which is what a mail client
|
||||||
|
shows. Without it every message reads as coming from an address."""
|
||||||
|
if self.from_name:
|
||||||
|
return f"{self.from_name} <{self.from_address}>"
|
||||||
|
return self.from_address
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import INET, UUID
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(Base):
|
||||||
|
"""One signed-in session, and the refresh token currently standing for it.
|
||||||
|
|
||||||
|
Revocation used to live only in a Redis blacklist. That failed open — a
|
||||||
|
Redis outage made `verify_refresh_token` log a warning and accept the token
|
||||||
|
anyway — and it did not survive a flush, so every revoked token quietly came
|
||||||
|
back. It also meant nobody could be shown where they were signed in, because
|
||||||
|
nothing recorded it.
|
||||||
|
|
||||||
|
A session row is the durable fact. Redis stays useful as a fast negative
|
||||||
|
cache, but it is no longer the only thing standing between a stolen token
|
||||||
|
and an account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_sessions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
current_jti = Column(UUID(as_uuid=True), nullable=False, unique=True, index=True)
|
||||||
|
previous_jti = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
user_agent = Column(String(512), nullable=True)
|
||||||
|
ip_address = Column(INET, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
last_used_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
revoked_reason = Column(String(40), nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self.revoked_at is None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<UserSession {self.user_id} {'active' if self.is_active else 'revoked'}>"
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""A customer's own endpoint, and what has been sent to it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookEndpoint(Base):
|
||||||
|
__tablename__ = "webhook_endpoints"
|
||||||
|
|
||||||
|
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)
|
||||||
|
url = Column(String(2048), nullable=False)
|
||||||
|
description = Column(String(255))
|
||||||
|
event_types = Column(JSONB, nullable=False, default=list)
|
||||||
|
secret_enc = Column(String, nullable=False)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
disabled_reason = Column(String(255))
|
||||||
|
consecutive_failures = Column(Integer, nullable=False, default=0)
|
||||||
|
last_success_at = Column(DateTime(timezone=True))
|
||||||
|
last_failure_at = Column(DateTime(timezone=True))
|
||||||
|
created_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
deliveries = relationship("WebhookDelivery", back_populates="endpoint",
|
||||||
|
lazy="raise", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secret(self) -> str | None:
|
||||||
|
"""Encrypted, not hashed — unlike an API key.
|
||||||
|
|
||||||
|
The customer has to configure this same value in their receiver to check
|
||||||
|
signatures, so the platform genuinely needs to be able to hand it back.
|
||||||
|
Storing it reversibly is a real cost; the alternative is a secret nobody
|
||||||
|
can use, which is not a security property, only an obstacle.
|
||||||
|
"""
|
||||||
|
from app.core.crypto import decrypt_json, is_encrypted
|
||||||
|
|
||||||
|
if not self.secret_enc:
|
||||||
|
return None
|
||||||
|
if is_encrypted(self.secret_enc):
|
||||||
|
return (decrypt_json(self.secret_enc) or {}).get("secret")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@secret.setter
|
||||||
|
def secret(self, value: str | None) -> None:
|
||||||
|
from app.core.crypto import encrypt_json
|
||||||
|
|
||||||
|
self.secret_enc = encrypt_json({"secret": value}) if value else None
|
||||||
|
|
||||||
|
def wants(self, event_type: str) -> bool:
|
||||||
|
"""Empty means everything.
|
||||||
|
|
||||||
|
The first endpoint a workspace registers is usually "send me what you
|
||||||
|
have" — making them enumerate the catalogue before anything arrives is
|
||||||
|
how a setup gets abandoned halfway.
|
||||||
|
"""
|
||||||
|
wanted = list(self.event_types or [])
|
||||||
|
return not wanted or event_type in wanted
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookDelivery(Base):
|
||||||
|
__tablename__ = "webhook_deliveries"
|
||||||
|
|
||||||
|
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)
|
||||||
|
endpoint_id = Column(UUID(as_uuid=True),
|
||||||
|
ForeignKey("webhook_endpoints.id", ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
event_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
|
event_type = Column(String(120), nullable=False)
|
||||||
|
payload = Column(JSONB, nullable=False)
|
||||||
|
status = Column(String(20), nullable=False, default="pending")
|
||||||
|
attempts = Column(Integer, nullable=False, default=0)
|
||||||
|
next_attempt_at = Column(DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
response_status = Column(Integer)
|
||||||
|
error = Column(String(1000))
|
||||||
|
delivered_at = Column(DateTime(timezone=True))
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
endpoint = relationship("WebhookEndpoint", back_populates="deliveries", lazy="raise")
|
||||||
@@ -5,6 +5,7 @@ from sqlalchemy import asc, desc, or_, cast, String
|
|||||||
|
|
||||||
from app.config.database import get_db
|
from app.config.database import get_db
|
||||||
from app.middleware.auth_middleware import get_current_user, User
|
from app.middleware.auth_middleware import get_current_user, User
|
||||||
|
from app.middleware.tenant_middleware import is_superadmin
|
||||||
from app.models.system.audit_log import AuditLog
|
from app.models.system.audit_log import AuditLog
|
||||||
from app.schemas.auth.audit_schema import AuditLogListResponse
|
from app.schemas.auth.audit_schema import AuditLogListResponse
|
||||||
|
|
||||||
@@ -26,12 +27,25 @@ def get_audit_logs(
|
|||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
"""
|
"""Audit entries for the caller's workspace, newest first.
|
||||||
Fetch audit logs with optional filters.
|
|
||||||
Only accessible to authenticated users (super-admin in practice).
|
"Super-admin in practice" is what the previous comment claimed, and the only
|
||||||
|
dependency was `get_current_user`. There was no workspace filter and, until
|
||||||
|
migration f6b8c2d4e104, 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 address, with the full before-and-after values of
|
||||||
|
workspace and plan changes, and every administrator's email address across
|
||||||
|
every customer.
|
||||||
|
|
||||||
|
A superadmin sees everything, including the unattributed entries written
|
||||||
|
before the column existed. Everyone else sees their own workspace and nothing
|
||||||
|
beyond it.
|
||||||
"""
|
"""
|
||||||
query = db.query(AuditLog)
|
query = db.query(AuditLog)
|
||||||
|
|
||||||
|
if not is_superadmin(current_user):
|
||||||
|
query = query.filter(AuditLog.tenant_id == current_user.tenant_id)
|
||||||
|
|
||||||
if module_name:
|
if module_name:
|
||||||
query = query.filter(AuditLog.module_name == module_name)
|
query = query.filter(AuditLog.module_name == module_name)
|
||||||
if module_names:
|
if module_names:
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def create_environment(
|
|||||||
description=f"Environment '{result.slug}' created for '{module.module_name}'",
|
description=f"Environment '{result.slug}' created for '{module.module_name}'",
|
||||||
performed_by_id=str(current_user.id),
|
performed_by_id=str(current_user.id),
|
||||||
performed_by_email=current_user.email,
|
performed_by_email=current_user.email,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
ip_address=get_client_ip(request),
|
ip_address=get_client_ip(request),
|
||||||
new_values=env_data.model_dump(exclude={"trust_credentials"})
|
new_values=env_data.model_dump(exclude={"trust_credentials"})
|
||||||
)
|
)
|
||||||
@@ -89,6 +90,7 @@ def update_environment(
|
|||||||
description=f"Environment '{result.slug}' updated",
|
description=f"Environment '{result.slug}' updated",
|
||||||
performed_by_id=str(current_user.id),
|
performed_by_id=str(current_user.id),
|
||||||
performed_by_email=current_user.email,
|
performed_by_email=current_user.email,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
ip_address=get_client_ip(request),
|
ip_address=get_client_ip(request),
|
||||||
old_values=old_values,
|
old_values=old_values,
|
||||||
new_values=env_data.model_dump(exclude_unset=True, exclude={"trust_credentials"})
|
new_values=env_data.model_dump(exclude_unset=True, exclude={"trust_credentials"})
|
||||||
@@ -116,6 +118,7 @@ def set_default_environment(
|
|||||||
description=f"Set default environment for {module.module_name}",
|
description=f"Set default environment for {module.module_name}",
|
||||||
performed_by_id=str(current_user.id),
|
performed_by_id=str(current_user.id),
|
||||||
performed_by_email=current_user.email,
|
performed_by_email=current_user.email,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
ip_address=get_client_ip(request),
|
ip_address=get_client_ip(request),
|
||||||
new_values={"is_default": True}
|
new_values={"is_default": True}
|
||||||
)
|
)
|
||||||
@@ -144,6 +147,7 @@ def delete_environment(
|
|||||||
description=f"Environment deleted from {module.module_name}",
|
description=f"Environment deleted from {module.module_name}",
|
||||||
performed_by_id=str(current_user.id),
|
performed_by_id=str(current_user.id),
|
||||||
performed_by_email=current_user.email,
|
performed_by_email=current_user.email,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
ip_address=get_client_ip(request),
|
ip_address=get_client_ip(request),
|
||||||
old_values=snapshot
|
old_values=snapshot
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user