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=*
|
||||
HOST=127.0.0.1
|
||||
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
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -28,12 +28,12 @@ REDIS_PORT=6381
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
EMAIL_FROM=info.maskantech@gmail.com
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
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
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -28,12 +28,12 @@ REDIS_PORT=6383
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
SMTP_USER=info.maskantech@gmail.com
|
||||
SMTP_PASSWORD=tuthpljtkrwchgxd
|
||||
EMAIL_FROM=info.maskantech@gmail.com
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
|
||||
+8
-1
@@ -11,4 +11,11 @@ node_modules
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.idea/
|
||||
|
||||
# Environment files. .env was ignored but .env.production was not, which is
|
||||
# how .env.development/.env.local/.env.production/.env.testing came to be
|
||||
# committed with live secrets in them.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
# Backend handover
|
||||
|
||||
For the next developer. What this service was, what it is now, what changed, and
|
||||
what is still waiting for somebody.
|
||||
|
||||
Two companion documents sit one directory up and go deeper:
|
||||
|
||||
- `../REVIEW.md` — the assessment. What was wrong, what was built, what I would
|
||||
not claim. Read §1 and §10 if you read nothing else.
|
||||
- `../SAAS_HARDENING.md` — the chronological log, including the mistakes and the
|
||||
reasoning behind each decision.
|
||||
|
||||
This file is the orientation. Those two are the detail.
|
||||
|
||||
---
|
||||
|
||||
## 1. In one paragraph
|
||||
|
||||
This is a multi-tenant SaaS control plane: workspaces (tenants), the people in
|
||||
them, what those people are allowed to do, what their workspace has paid for, and
|
||||
which downstream modules they can reach. It was a working application with a
|
||||
serious isolation problem — **any signed-in user of any customer could read every
|
||||
other customer's data through several endpoints**, because tenancy was a
|
||||
convention in the query layer rather than a rule in the database. That is now
|
||||
enforced by PostgreSQL row-level security, and roughly twenty capabilities a
|
||||
business customer expects have been built on top.
|
||||
|
||||
---
|
||||
|
||||
## 2. What was here before
|
||||
|
||||
Fifteen migrations, and these concerns:
|
||||
|
||||
| Area | What existed |
|
||||
|---|---|
|
||||
| Auth | Email/password sign-in, JWT access tokens, refresh tokens |
|
||||
| Tenancy | A `tenant_id` column, filtered by hand in each query |
|
||||
| Roles | Roles and access codes per workspace |
|
||||
| Subscriptions | Plans, and an assignment of plan to workspace |
|
||||
| Modules | A registry of downstream modules and their environments |
|
||||
| SSO **outbound** | Signing users *into* modules — the platform as identity provider |
|
||||
| Audit | An `audit_logs` table and a service writing to it |
|
||||
| Theme | Colour palettes |
|
||||
|
||||
**Not present:** row-level security, MFA, inbound SSO, SCIM, invitations, API
|
||||
keys, webhooks, notifications, org units, documents, reference data, sessions
|
||||
anybody could see, seat enforcement, rate limiting, an outbox, or a test suite.
|
||||
|
||||
The state of the code is recoverable exactly: **everything in this repository is
|
||||
uncommitted on branch `furqan`.** `git diff` shows every change to a pre-existing
|
||||
file, and `git ls-files --others --exclude-standard` shows every new one. Nothing
|
||||
was committed by design — see §7.
|
||||
|
||||
---
|
||||
|
||||
## 3. The single most important change
|
||||
|
||||
**Workspace isolation is now a database rule, not a coding convention.**
|
||||
|
||||
Migration `c3d5e7f9a801` enables and FORCEs row-level security on every
|
||||
workspace-owned table. The application connects as a role created by
|
||||
`scripts/create_app_role.py`, which is `NOBYPASSRLS` — so a query that forgets
|
||||
its `WHERE tenant_id = ...` returns nothing rather than everything.
|
||||
|
||||
Three pieces make it work, and you need all three:
|
||||
|
||||
1. `app/core/tenant_context.py` — a `contextvar` holding the current workspace.
|
||||
`scoped_to(tenant_id)` sets it; `unscoped()` lifts it for genuine
|
||||
platform-wide work.
|
||||
2. `app/middleware/tenant_scope_middleware.py` — sets it per request from the
|
||||
access token.
|
||||
3. A session listener that pushes the value into PostgreSQL via `set_config`
|
||||
before each statement.
|
||||
|
||||
**The trap:** `unscoped()` only affects sessions wired to that listener. A raw
|
||||
`create_engine` session has no listener, so the flag never reaches PostgreSQL and
|
||||
every policy hides every row. Such a session must issue
|
||||
`SELECT set_config('app.bypass_rls', 'on', false)` itself.
|
||||
`app/services/system/audit_retention.py` is the worked example.
|
||||
|
||||
---
|
||||
|
||||
## 4. What was built, grouped
|
||||
|
||||
Twenty-six migrations were added. Rather than list them, here is what they
|
||||
bought, roughly in dependency order. Each has tests; `../REVIEW.md` §9 argues why
|
||||
each was built the way it was.
|
||||
|
||||
### Security and correctness
|
||||
|
||||
| Capability | Why it mattered |
|
||||
|---|---|
|
||||
| **Row-level security** | Above. The reason for everything else. |
|
||||
| **Encrypted module trust credentials** | They were stored in plaintext. |
|
||||
| **Superadmin as an explicit flag** | It was an *absent* `tenant_id`, so a signup that omitted the workspace produced a platform superadmin. |
|
||||
| **MFA (TOTP) + lockout** | No second factor existed, for anyone, including superadmins. |
|
||||
| **Sessions people can see and end** | Refresh tokens rotated and revoked correctly; nobody could ever *see* the result. |
|
||||
| **Rate limiting** | Credential endpoints had none. |
|
||||
| **One account per address** | `Alice@` and `alice@` were two accounts. Matched on `lower(email)` now. |
|
||||
| **Append-only audit log** | `UPDATE`/`DELETE` revoked from the application role. |
|
||||
| **Soft delete** | On `tenants` and `org_units`, where a hard delete destroyed history. |
|
||||
|
||||
### Customer-facing capability
|
||||
|
||||
| Capability | What it is |
|
||||
|---|---|
|
||||
| **Inbound SSO** | Sign in with the customer's own Azure AD / Okta / Google. The opposite direction to the outbound SSO that already existed. |
|
||||
| **SCIM 2.0** | Provisioning and de-provisioning from the customer's directory. Without it, leavers kept their accounts. |
|
||||
| **Invitations** | Inviting somebody instead of choosing their password for them. |
|
||||
| **API keys** | Keys a customer can automate against, scoped. |
|
||||
| **Webhooks** | Telling a customer's systems when something happens. HMAC-signed. |
|
||||
| **Notifications + preferences** | In-product notices, and the ability to turn each kind off. |
|
||||
| **Org units** | Departments and branches, with user administration scoped to a sub-tree. |
|
||||
| **Seat allocation** | Seats divided between branches, not only counted per workspace. |
|
||||
| **Documents** | Files attached to things, with content sniffed by magic bytes. |
|
||||
| **Reference data** | Generic lookup lists — the things dropdowns are made of. |
|
||||
| **Per-workspace outgoing email** | Sending from the customer's own address. |
|
||||
| **Subscription lifecycle** | Grace periods, cancellation, expiry notices, history. |
|
||||
|
||||
### Operational
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| **Background worker**, six jobs | `app/services/system/worker.py` — outbox, alerts, sessions, webhooks, notices, audit-retention |
|
||||
| **Event outbox** | Events survive a crash between the write and the send |
|
||||
| **Idempotency** | A retried request is safe to retry |
|
||||
| **Alert state** | Remembers which alerts are already open, so they do not re-fire hourly |
|
||||
| **Operations endpoints** | `/api/admin/operations/*` — what the background work has been doing |
|
||||
|
||||
---
|
||||
|
||||
## 5. Where to look
|
||||
|
||||
```
|
||||
app/
|
||||
core/
|
||||
tenant_context.py Read this first. Workspace scoping.
|
||||
rls.py Policy helpers and the enforcement check.
|
||||
crypto.py Encryption at rest for module credentials.
|
||||
ssrf.py Resolve-then-pin. Used by webhooks and SSO discovery.
|
||||
document_storage.py Four functions. Swap for S3 here and nowhere else.
|
||||
file_types.py Magic-byte sniffing. Never trusts Content-Type.
|
||||
middleware/
|
||||
tenant_scope_middleware.py Sets the workspace per request.
|
||||
rate_limit.py Credential endpoints.
|
||||
idempotency_middleware.py Safe retries.
|
||||
services/
|
||||
auth/ Workspace-owned concerns.
|
||||
system/ Platform concerns, plus worker.py.
|
||||
routes/ Thin. Validation and permission checks only.
|
||||
scripts/ Operational. See §6.
|
||||
tests/ 52 files, 878 tests.
|
||||
docs/
|
||||
MODULE_CONTRACT.md Hand to the authors of downstream modules.
|
||||
SCIM.md, WEBHOOKS.md Customer-facing integration docs.
|
||||
```
|
||||
|
||||
**56 pre-existing files were modified.** The largest changes are in
|
||||
`app/__init__.py` (router mounting, middleware order), `app/config/settings.py`,
|
||||
and the tenant/user/role services, which had their hand-written workspace filters
|
||||
replaced by policy-backed queries.
|
||||
|
||||
---
|
||||
|
||||
## 6. Running it
|
||||
|
||||
```bash
|
||||
python -m venv venv && venv/Scripts/pip install -r requirements.txt
|
||||
|
||||
# Tests. The role matters — see the note below.
|
||||
TEST_DATABASE_URL=postgresql://saas_app:...@localhost/saas_test \
|
||||
python -m pytest tests/ -q # 878 pass, 3 skip
|
||||
|
||||
python scripts/verify_security_fixes.py # 23 checks
|
||||
python scripts/report_drift.py # read-only; what the live data looks like
|
||||
python scripts/measure_query_plans.py # query plans at volume
|
||||
```
|
||||
|
||||
**Run the suite as `saas_app`, not as `postgres`.** A superuser bypasses RLS, so
|
||||
the policy tests cannot prove anything and skip themselves with a message saying
|
||||
so. Three audit-backfill tests do the reverse — they need a role that *can*
|
||||
`UPDATE audit_logs`, so they skip on the app role. Between the two
|
||||
configurations everything is covered; in either one alone, something is not.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pending — needs you, not me
|
||||
|
||||
These cannot be done from this machine. `../REVIEW.md` §5 has the full text.
|
||||
|
||||
### 7.1 Apply the migrations, in this order
|
||||
|
||||
RLS FORCEs its policies, which applies them to the owner too. Without the
|
||||
context-setting code already deployed, every query returns nothing.
|
||||
|
||||
```
|
||||
1. Deploy the code first
|
||||
2. alembic upgrade head
|
||||
3. python scripts/create_app_role.py # prints a password once
|
||||
4. Put that role in DATABASE_URL, restart
|
||||
5. python scripts/create_audit_retention_role.py # prints a second password once
|
||||
6. Put that one in AUDIT_RETENTION_DATABASE_URL, restart
|
||||
```
|
||||
|
||||
- **Until step 4 the policies exist but do not apply.** The application is still
|
||||
connecting as a role that bypasses them, and nothing in the schema shows the
|
||||
difference. `check_rls_enforced()` reports it.
|
||||
- **Step 3 must be re-run even if it was run before**, because it now also
|
||||
revokes `UPDATE, DELETE` on `audit_logs`. A role created by the earlier version
|
||||
still holds those rights.
|
||||
- **Steps 5–6 are not optional.** The nightly retention sweep is a DELETE and the
|
||||
application role can no longer do it. Unconfigured, the job raises on every run
|
||||
— deliberately: a retention job that no-ops for a year while its last-run
|
||||
timestamp keeps updating is the failure nobody notices until the table is why a
|
||||
query times out.
|
||||
- Rolling back is `alembic downgrade b2e1d4f5a602`.
|
||||
|
||||
### 7.2 Rotate the secrets
|
||||
|
||||
`.env.local` and `.env.production` are in the working tree with live values and
|
||||
have been for the whole history. **Treat every value in them as known.** Rotating
|
||||
them and purging the files from git history is outstanding and is not something I
|
||||
should do to your repository.
|
||||
|
||||
### 7.3 Run the drift report against real data
|
||||
|
||||
```bash
|
||||
python scripts/report_drift.py
|
||||
```
|
||||
|
||||
Read-only. It has only ever run against an empty local database, so it currently
|
||||
proves the plumbing works and **nothing about your data**. Nobody yet knows how
|
||||
many workspaces are over their seat limit, how many roles grant permissions their
|
||||
plan never allowed, or how many accounts are stranded with no workspace. Expect
|
||||
the seat number to be non-zero — the limit was never enforced, so the data can
|
||||
exceed it.
|
||||
|
||||
### 7.4 Ship signature version 2 in the modules
|
||||
|
||||
The receiving half is built, tested and **off by default**.
|
||||
`MODULE_TRUST_REQUIRE_REPLAY_CONTROLS` cannot be turned on until the modules send
|
||||
the new headers — turning it on first refuses every legitimate call. Hand
|
||||
`docs/MODULE_CONTRACT.md` to their authors.
|
||||
|
||||
### 7.5 Point the alerts somewhere
|
||||
|
||||
`ALERT_WEBHOOK_URL` and `ALERT_EMAIL` are unset, so alerting is built and silent.
|
||||
Where they go, and who answers at three in the morning, is not a code decision.
|
||||
|
||||
### 7.6 Decide about `/internal/sso/exchange`
|
||||
|
||||
It works, and **nothing issues a grant code**, so no module can be using it. I
|
||||
finished it rather than deleting it, because removing a mounted public endpoint
|
||||
is your call. I would lean towards deleting it — the signed-payload handoff
|
||||
supersedes it.
|
||||
|
||||
### 7.7 Backfill audit attribution (optional)
|
||||
|
||||
Entries written before `audit_logs` had a workspace column are superadmin-only.
|
||||
`scripts/backfill_audit_tenants.py` recovers most of them from `entity_id` and
|
||||
has a `--dry-run`. It needs the **schema owner**, not the application role,
|
||||
passed as `--database-url` or `AUDIT_BACKFILL_DATABASE_URL` — `audit_logs` is
|
||||
append-only for the application now. It says so and stops if you forget.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deliberately not built
|
||||
|
||||
Named as decisions so the next person finds an answer rather than a gap.
|
||||
|
||||
- **`locations`, `cost_codes`, `currencies`** exist in the base application.
|
||||
They are the customer's domain, not the platform's — a construction business
|
||||
needs cost codes; a platform that hosts one does not. The reference-list tables
|
||||
hold them if you want them, with no migration.
|
||||
- **Virus scanning on uploads is absent, and named as absent.** A function that
|
||||
pretends to scan is worse than an honest gap: it converts "we do not check"
|
||||
into "we checked", which is the belief that gets a file opened.
|
||||
- **`user_sessions` is outside RLS on purpose.** It is written during sign-in
|
||||
before workspace context exists and read during refresh after the access token
|
||||
has expired. A policy there isolates nothing and breaks signing in. Every query
|
||||
on it is keyed on `user_id` or an unguessable `jti`.
|
||||
- **No reference-list content is seeded.** Which currencies and document types
|
||||
the platform publishes is a decision;
|
||||
`lookup_service.seed_platform_list` is where it goes.
|
||||
|
||||
---
|
||||
|
||||
## 9. What I would not claim
|
||||
|
||||
Being explicit, because you are inheriting this.
|
||||
|
||||
- **Nothing has been verified against production data.** Every test runs against
|
||||
a local scratch database.
|
||||
- **CI has never been run by GitHub Actions.** The repository has no remote.
|
||||
Every step has been run locally in order against a database created from
|
||||
scratch, which is most of the value, but the YAML has only been parsed.
|
||||
- **Coverage is ~78%, not 100%.** The thinnest areas are the email service, parts
|
||||
of `role_service`, and route modules generally.
|
||||
- **The migration chain is verified up from empty and back down to base** on a
|
||||
throwaway database — never against anything shared.
|
||||
- **Defects were introduced and caught in the same pass.** Several. They are
|
||||
listed in `../REVIEW.md` §7 rather than quietly omitted, because the pattern is
|
||||
the useful part: each was caught by a test written for a different reason.
|
||||
@@ -170,6 +170,31 @@ python run.py
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# One-time: a local scratch database the suite owns
|
||||
createdb saas_test
|
||||
APP_ENV=pytest python -m alembic upgrade head
|
||||
|
||||
python -m pytest tests/ -q # run
|
||||
python -m pytest tests/ --cov=app # with coverage
|
||||
python scripts/verify_security_fixes.py # the security regression harness
|
||||
```
|
||||
|
||||
**The suite never touches the shared database.** `app/config/settings.py` calls
|
||||
`load_dotenv(..., override=True)`, so `.env.local` — which points at the remote
|
||||
shared server — beats any environment variable you set. That is a real trap: the
|
||||
obvious way to redirect a run does not work.
|
||||
|
||||
`tests/conftest.py` therefore forces `DATABASE_URL` before the application is
|
||||
imported, and refuses to start at all if the target is not local. Better a suite
|
||||
that will not run than one that quietly writes to production.
|
||||
|
||||
Every test runs inside a transaction that is rolled back, so nothing survives and
|
||||
tests can run in any order.
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once the application is running, visit:
|
||||
@@ -277,6 +302,23 @@ The RBAC system supports:
|
||||
- Flexible role-to-permission mapping
|
||||
- Tenant-scoped roles
|
||||
|
||||
## DocQube ➔ SaaS Data Migration
|
||||
|
||||
To migrate tenants, roles, and users from DocQube DB to SaaS DB:
|
||||
|
||||
```bash
|
||||
# 1. Preview changes (Dry Run - No changes committed):
|
||||
python scripts/migrate_docqube_to_saas.py --dry-run
|
||||
|
||||
# 2. Execute and commit to SaaS DB:
|
||||
python scripts/migrate_docqube_to_saas.py --execute
|
||||
```
|
||||
|
||||
### Password Verification & Automatic Upgrade
|
||||
- Users migrated from DocQube with `pbkdf2_sha256` password hashes can log in seamlessly.
|
||||
- Upon first successful login, their password hash is automatically and transparently upgraded to standard SaaS `bcrypt`.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
+4
-19
@@ -8,34 +8,31 @@ from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Load environment variables before importing app
|
||||
from dotenv import load_dotenv
|
||||
|
||||
app_env = os.getenv("APP_ENV", "local")
|
||||
env_filename = f".env.{app_env}"
|
||||
|
||||
# Define paths
|
||||
base_path = Path(__file__).resolve().parent.parent
|
||||
backend_path = base_path
|
||||
|
||||
# Load environment variables
|
||||
_explicit = dict(os.environ)
|
||||
|
||||
load_dotenv(dotenv_path=base_path / '.env')
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
|
||||
# Override with specific environment config
|
||||
if (base_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||
if (backend_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||
|
||||
# Import app settings and database
|
||||
os.environ.update(_explicit)
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.config.database import Base
|
||||
|
||||
# Import all models for autogenerate support
|
||||
import app.models.auth.user_model
|
||||
import app.models.auth.role_model
|
||||
import app.models.auth.tenant_model
|
||||
@@ -44,27 +41,15 @@ import app.models.auth.role_access_model
|
||||
import app.models.theme.color_palette_model
|
||||
import app.models.auth.subscription_plan_model
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Set the database URL from app settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@@ -11,7 +11,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '03a1b1f05e99'
|
||||
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('event_logs',
|
||||
sa.Column('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_next_retry_at'), 'event_logs', ['next_retry_at'], unique=False)
|
||||
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""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_next_retry_at'), table_name='event_logs')
|
||||
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
|
||||
op.drop_table('event_logs')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5f2e9c1a7b44"
|
||||
down_revision: Union[str, Sequence[str], None] = "720027c97104"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
|
||||
@@ -11,7 +11,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '63b95ea5b967'
|
||||
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('module_accesses',
|
||||
sa.Column('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_column('accesses', 'module_id')
|
||||
op.drop_column('accesses', 'scope')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""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('module_id', sa.UUID(), autoincrement=False, nullable=True))
|
||||
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_access_code'), table_name='module_accesses')
|
||||
op.drop_table('module_accesses')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6a1b2c3d4e55"
|
||||
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
|
||||
@@ -11,7 +11,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '720027c97104'
|
||||
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""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))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('event_logs', 'follow_up_event')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -11,7 +11,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '73b754d5b2c5'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_logs',
|
||||
sa.Column('id', sa.UUID(), 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_module_name'), 'audit_logs', ['module_name'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""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_id'), table_name='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 alembic import op
|
||||
from app.core.migration_helpers import drop_foreign_key_on
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '74b6ccfaee8e'
|
||||
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('modules',
|
||||
sa.Column('id', sa.UUID(), 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_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('module_id', sa.UUID(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('sync_checksum', sa.String(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# Update existing rows with default scope
|
||||
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
|
||||
|
||||
# Now make it not null
|
||||
op.alter_column('accesses', 'scope', nullable=False)
|
||||
|
||||
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
|
||||
# Inspect to see if constraint/column exists to avoid transaction abortion on failure
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Check and drop foreign key
|
||||
fks = inspector.get_foreign_keys('users')
|
||||
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
|
||||
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
|
||||
|
||||
# Check and drop column
|
||||
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||
if 'palette_id' in columns:
|
||||
op.drop_column('users', 'palette_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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.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_module_id'), table_name='accesses')
|
||||
op.drop_column('accesses', 'last_synced_at')
|
||||
@@ -154,4 +145,3 @@ def downgrade() -> None:
|
||||
op.drop_table('module_environments')
|
||||
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
|
||||
op.drop_table('modules')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Sequence, Union
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7b2c4d5e6f77"
|
||||
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
|
||||
@@ -11,7 +11,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '88cfc7dee19d'
|
||||
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,19 +19,15 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index('ix_access_code_module', 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""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_module', table_name='accesses', postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -11,7 +11,6 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8acd83604252'
|
||||
down_revision: 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:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('accesses',
|
||||
sa.Column('id', sa.UUID(), 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_role_id'), 'users', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""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_role_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_access_code'), table_name='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 alembic import op
|
||||
from app.core.migration_helpers import drop_foreign_key_on
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '91cc93992a91'
|
||||
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('role_module_accesses',
|
||||
sa.Column('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.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'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'module_accesses', type_='foreignkey')
|
||||
drop_foreign_key_on("module_accesses", "parent_id")
|
||||
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
|
||||
op.drop_column('module_accesses', 'parent_id')
|
||||
op.drop_index(op.f('ix_role_module_accesses_role_id'), table_name='role_module_accesses')
|
||||
op.drop_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
|
||||
op.drop_table('role_module_accesses')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -8,10 +8,10 @@ Create Date: 2026-04-06 14:26:41.926020
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from app.core.migration_helpers import drop_foreign_key_on
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9283c3f52a76'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('subscription_plans',
|
||||
sa.Column('id', sa.UUID(), 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.create_index(op.f('ix_tenants_plan_id'), 'tenants', ['plan_id'], unique=False)
|
||||
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'tenants', type_='foreignkey')
|
||||
drop_foreign_key_on("tenants", "plan_id")
|
||||
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
|
||||
op.drop_column('tenants', 'plan_id')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_plan_id'), table_name='plan_module_accesses')
|
||||
@@ -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_id'), table_name='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
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c37ba6143f83'
|
||||
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cd8ba77ffd9e'
|
||||
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('sso_grants', 'redirect_url')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f9cf173f48f9'
|
||||
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
"""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_name', sa.String(length=255), 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_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)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""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_created_at'), 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', 'entity_name')
|
||||
op.drop_column('audit_logs', 'entity_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
+85
-10
@@ -6,7 +6,7 @@ import logging
|
||||
from sqlalchemy import text
|
||||
from app.config.settings import settings
|
||||
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.role_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 fastapi.concurrency import run_in_threadpool
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=settings.LOG_LEVEL.upper(),
|
||||
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)
|
||||
|
||||
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:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||
@@ -125,6 +125,10 @@ async def lifespan(app: FastAPI):
|
||||
pass
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
from app.core.rls import register_rls_listener
|
||||
|
||||
register_rls_listener()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
@@ -135,7 +139,6 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === OpenAPI Security Scheme ===
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
def custom_openapi():
|
||||
@@ -159,7 +162,6 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
# === CORS ===
|
||||
origins = []
|
||||
if settings.CORS_ALLOWED_ORIGINS:
|
||||
origins = [
|
||||
@@ -173,15 +175,23 @@ def create_app() -> FastAPI:
|
||||
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
|
||||
)
|
||||
|
||||
from app.middleware.tenant_scope_middleware import TenantScopeMiddleware
|
||||
|
||||
app.add_middleware(TenantScopeMiddleware)
|
||||
|
||||
from app.middleware.idempotency_middleware import IdempotencyMiddleware
|
||||
|
||||
app.add_middleware(IdempotencyMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-MFA-Required", "Idempotent-Replay"],
|
||||
)
|
||||
|
||||
# === Include Routers ===
|
||||
from app.routes.auth.auth import router as auth_router
|
||||
from app.routes.auth.tenant import router as tenant_router
|
||||
from app.routes.auth.role import router as role_router
|
||||
@@ -197,27 +207,85 @@ def create_app() -> FastAPI:
|
||||
app.include_router(access_router, prefix="/api/access", tags=["Access Management"])
|
||||
app.include_router(user_router, prefix="/api/user", tags=["User Management"])
|
||||
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
|
||||
from app.routes.auth.identity_provider import (
|
||||
public_router as idp_public_router,
|
||||
router as idp_admin_router,
|
||||
)
|
||||
from app.routes.internal.identity import router as identity_router
|
||||
from app.routes.internal.module import router as internal_module_router
|
||||
|
||||
app.include_router(sso_public_router, prefix="/api/sso", tags=["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(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
|
||||
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.module_environments import router as admin_module_env_router
|
||||
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
|
||||
|
||||
app.include_router(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"])
|
||||
app.include_router(operations.router, prefix="/api/admin/operations", tags=["Admin - Operations"])
|
||||
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
|
||||
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
|
||||
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
||||
|
||||
# === Basic Routes ===
|
||||
@app.get("/", tags=["Root"])
|
||||
def root():
|
||||
"""
|
||||
@@ -249,11 +317,18 @@ def create_app() -> FastAPI:
|
||||
logger.error(f"Health check DB error: {e}")
|
||||
db_status = "unhealthy"
|
||||
|
||||
from app.services.auth import module_identity
|
||||
|
||||
module_identity_status = (
|
||||
"configured" if module_identity.is_configured() else "not configured"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
"environment": settings.APP_ENV,
|
||||
"database": db_status,
|
||||
"module_identity": module_identity_status,
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
|
||||
return app
|
||||
return app
|
||||
|
||||
+8
-14
@@ -9,23 +9,21 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
|
||||
# Build connection arguments based on SSL setting
|
||||
connect_args = {"connect_timeout": 10}
|
||||
if settings.DB_SSL:
|
||||
connect_args["sslmode"] = "require"
|
||||
|
||||
# Create the SQLAlchemy engine with optimized connection pool
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300, # Recycle connections every 5 minutes
|
||||
pool_size=20, # Increased from 5 to handle higher concurrency
|
||||
max_overflow=30, # Increased from 10 for peak load handling
|
||||
pool_timeout=30, # Connection acquisition timeout
|
||||
pool_reset_on_return='commit', # Reset connections on return
|
||||
pool_recycle=300,
|
||||
pool_size=20,
|
||||
max_overflow=30,
|
||||
pool_timeout=30,
|
||||
pool_reset_on_return='commit',
|
||||
connect_args=connect_args,
|
||||
echo=False, # Disable SQL logging in production
|
||||
future=True # Use SQLAlchemy 2.0 style
|
||||
echo=False,
|
||||
future=True
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
@@ -36,18 +34,14 @@ def get_db():
|
||||
try:
|
||||
yield db
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions without logging as database errors
|
||||
# These are application-level errors, not database errors
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
# Log actual database errors
|
||||
logger.error(f"Database error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log other unexpected errors
|
||||
logger.error(f"Unexpected database session error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
db.close()
|
||||
|
||||
+122
-11
@@ -24,17 +24,52 @@ class SecurityUtils:
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
"""Verify a password against its hash (supports bcrypt and PBKDF2)."""
|
||||
if not plain_password or not hashed_password:
|
||||
return False
|
||||
|
||||
# Support migrated PBKDF2 hashes from DocQube
|
||||
if hashed_password.startswith("$pbkdf2-sha256$") or hashed_password.startswith("$pbkdf2$"):
|
||||
try:
|
||||
from passlib.hash import pbkdf2_sha256
|
||||
return pbkdf2_sha256.verify(plain_password, hashed_password)
|
||||
except Exception as e:
|
||||
logger.warning(f"PBKDF2 verification failed: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'), hashed_password.encode('utf-8')
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_legacy_hash(hashed_password: str) -> bool:
|
||||
"""Check if hash is in legacy format (e.g. PBKDF2) needing upgrade to bcrypt."""
|
||||
if not hashed_password:
|
||||
return False
|
||||
return hashed_password.startswith("$pbkdf2-sha256$") or hashed_password.startswith("$pbkdf2$")
|
||||
|
||||
@staticmethod
|
||||
def generate_access_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
||||
"""Generate JWT access token."""
|
||||
def generate_access_token(
|
||||
data: Dict[str, Any],
|
||||
tenant_id: Optional[Any] = None,
|
||||
is_superadmin: bool = False,
|
||||
) -> str:
|
||||
"""Generate JWT access token.
|
||||
|
||||
Carries `tenant_id` and `is_superadmin` so the request scope can be
|
||||
established before the first query — including the one that resolves the
|
||||
caller. Both are routing hints, not authorisation: every request is still
|
||||
verified and re-checked against the database.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
if is_superadmin:
|
||||
to_encode["is_superadmin"] = True
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
to_encode["tenant_id"] = str(tenant_id)
|
||||
|
||||
@@ -45,13 +80,25 @@ class SecurityUtils:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
||||
"""Generate JWT refresh token."""
|
||||
def generate_refresh_token(
|
||||
data: Dict[str, Any],
|
||||
tenant_id: Optional[Any] = None,
|
||||
jti: Optional[Any] = None,
|
||||
) -> str:
|
||||
"""Generate JWT refresh token.
|
||||
|
||||
`jti` is supplied by the session store so the token and the session row
|
||||
name each other. Left to itself the token would carry an identifier
|
||||
nothing else knows, and revoking it would mean finding it first.
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "refresh", "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:
|
||||
to_encode["tenant_id"] = str(tenant_id)
|
||||
|
||||
@@ -117,7 +164,20 @@ class SecurityUtils:
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and sync_redis_client.client:
|
||||
try:
|
||||
if sync_redis_client.client.get(f"blacklist:{jti}"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis blacklist check failed: {e}")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
@@ -130,6 +190,57 @@ class SecurityUtils:
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def decode_unverified(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read a token's claims without checking its signature or expiry.
|
||||
|
||||
For identifying *which* session a token belongs to when it is being
|
||||
thrown away — logging out, listing sessions. Never for deciding whether
|
||||
to trust it: nothing here has been verified, and an expired or forged
|
||||
token decodes just as happily as a good one.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
options={"verify_signature": False, "verify_exp": False},
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def revoke_token(token: str, secret: str) -> bool:
|
||||
"""Blacklist a token's jti for whatever remains of its lifetime.
|
||||
|
||||
Decodes without verifying expiry: an already-expired token needs no
|
||||
blacklist entry, and refusing to revoke one because it failed validation
|
||||
would leave the caller unable to clean up.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if not jti or not exp or not sync_redis_client.client:
|
||||
return False
|
||||
|
||||
remaining = int(exp - datetime.now(timezone.utc).timestamp())
|
||||
if remaining <= 0:
|
||||
return True
|
||||
try:
|
||||
sync_redis_client.client.setex(f"blacklist:{jti}", remaining, "1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke token: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_otp(length: int = 6) -> str:
|
||||
"""Generate a random OTP."""
|
||||
@@ -191,4 +302,4 @@ class SecurityUtils:
|
||||
|
||||
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
|
||||
|
||||
security = SecurityUtils()
|
||||
security = SecurityUtils()
|
||||
|
||||
+24
-13
@@ -10,39 +10,50 @@ env_filename = f".env.{app_env}"
|
||||
base_path = Path(__file__).resolve().parent.parent.parent
|
||||
backend_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
_explicit = dict(os.environ)
|
||||
|
||||
load_dotenv(dotenv_path=base_path / '.env')
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
|
||||
if (base_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||
if (backend_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||
|
||||
os.environ.update(_explicit)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str
|
||||
VERSION: str
|
||||
|
||||
# FastAPI
|
||||
PORT: int
|
||||
HOST: str
|
||||
APP_ENV: str
|
||||
SECRET_KEY: str
|
||||
ALLOWED_HOSTS: str = "*"
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL: str
|
||||
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
||||
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY: Optional[str] = None
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# Database settings
|
||||
ALLOW_PUBLIC_SIGNUP: bool = False
|
||||
|
||||
MODULE_TRUST_REQUIRE_REPLAY_CONTROLS: bool = False
|
||||
|
||||
ALERT_WEBHOOK_URL: Optional[str] = None
|
||||
ALERT_EMAIL: Optional[str] = None
|
||||
ALERT_RENOTIFY_MINUTES: int = 60
|
||||
ALERT_STUCK_EVENTS_THRESHOLD: int = 5
|
||||
ALERT_STUCK_EVENTS_CRITICAL: int = 50
|
||||
ALERT_TOKEN_REUSE_WINDOW_HOURS: int = 24
|
||||
MODULE_TRUST_MAX_SKEW_SECONDS: int = 120
|
||||
|
||||
DATABASE_URL: str
|
||||
DB_SSL: bool = False
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST: str
|
||||
REDIS_PORT: int
|
||||
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_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# Email
|
||||
SMTP_HOST: str
|
||||
SMTP_PORT: int
|
||||
SMTP_SECURE: bool = True
|
||||
@@ -66,28 +76,29 @@ class Settings(BaseSettings):
|
||||
SMTP_PASSWORD: str
|
||||
EMAIL_FROM: str
|
||||
|
||||
# JWT settings
|
||||
ACCESS_TOKEN_SECRET: str
|
||||
ACCESS_TOKEN_EXPIRES: int = 900
|
||||
REFRESH_TOKEN_SECRET: str
|
||||
REFRESH_TOKEN_EXPIRES: int = 864000
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
|
||||
# Cookie settings
|
||||
COOKIE_SECURE: bool = False
|
||||
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_PASSWORD: str
|
||||
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
||||
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
||||
|
||||
# Module Integration Security (RS256)
|
||||
SAAS_PRIVATE_KEY: Optional[str] = None
|
||||
SAAS_KEY_ID: str = "saas-key-v1"
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID: str
|
||||
PAYPAL_CLIENT_SECRET: str
|
||||
PAYPAL_MODE: str = "sandbox"
|
||||
@@ -122,4 +133,4 @@ class Settings(BaseSettings):
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from typing import List, Any
|
||||
from app.services.auth.access_service import AccessService
|
||||
from app.schemas.auth.access_schema import AccessResponse
|
||||
|
||||
class AccessController:
|
||||
@staticmethod
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[AccessResponse]:
|
||||
return AccessService.get_all_accesses(db, category)
|
||||
def get_all_accesses(db: Session, category: str = None, tenant_id: Any = None) -> List[AccessResponse]:
|
||||
return AccessService.get_all_accesses(db, category, tenant_id)
|
||||
@@ -20,8 +20,10 @@ class AuthController:
|
||||
return AuthService.create_user(db, user_data, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def signin(db: Session, signin_data: UserSignin):
|
||||
return AuthService.signin(db, signin_data)
|
||||
def signin(db: Session, signin_data: UserSignin, user_agent=None, ip_address=None):
|
||||
return AuthService.signin(
|
||||
db, signin_data, user_agent=user_agent, ip_address=ip_address
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token(db: Session, token_data: RefreshTokenRequest):
|
||||
@@ -44,8 +46,20 @@ class AuthController:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User, token: str):
|
||||
return AuthService.logout(current_user, token)
|
||||
def logout(current_user: User, token: str, refresh_token: str = None, db: Session = None):
|
||||
return AuthService.logout(current_user, token, refresh_token, db=db)
|
||||
|
||||
@staticmethod
|
||||
def list_sessions(db: Session, current_user: User, current_refresh_token: str = None):
|
||||
return AuthService.list_sessions(db, current_user, current_refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def end_session(db: Session, current_user: User, session_id):
|
||||
return AuthService.end_session(db, current_user, session_id)
|
||||
|
||||
@staticmethod
|
||||
def end_other_sessions(db: Session, current_user: User, current_refresh_token: str = None):
|
||||
return AuthService.end_other_sessions(db, current_user, current_refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
|
||||
@@ -26,8 +26,19 @@ class RoleController:
|
||||
return RoleService.get_all_roles(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(db: Session, role_id: uuid.UUID) -> Role:
|
||||
return RoleService.get_role_by_id(db, role_id)
|
||||
def get_role_by_id(
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID],
|
||||
actor_is_superadmin: bool,
|
||||
) -> Role:
|
||||
return RoleService.get_role_by_id(
|
||||
db,
|
||||
role_id,
|
||||
actor_tenant_id=actor_tenant_id,
|
||||
actor_is_superadmin=actor_is_superadmin,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_role(
|
||||
@@ -35,20 +46,43 @@ class RoleController:
|
||||
role_id: uuid.UUID,
|
||||
role_data: RoleUpdate,
|
||||
is_superadmin: bool = False,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID] = None,
|
||||
) -> Role:
|
||||
return RoleService.update_role(
|
||||
db, role_id, role_data, is_superadmin=is_superadmin
|
||||
db,
|
||||
role_id,
|
||||
role_data,
|
||||
is_superadmin=is_superadmin,
|
||||
actor_tenant_id=actor_tenant_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_role(db: Session, role_id: uuid.UUID, is_superadmin: bool = False):
|
||||
return RoleService.delete_role(db, role_id, is_superadmin=is_superadmin)
|
||||
def delete_role(
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
is_superadmin: bool = False,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID] = None,
|
||||
):
|
||||
return RoleService.delete_role(
|
||||
db, role_id, is_superadmin=is_superadmin, actor_tenant_id=actor_tenant_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_role_with_accesses(
|
||||
db: Session, role_id: uuid.UUID
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
*,
|
||||
actor_tenant_id: Optional[uuid.UUID],
|
||||
actor_is_superadmin: bool,
|
||||
) -> RoleWithAccessesResponse:
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
role = RoleService.get_role_by_id(
|
||||
db,
|
||||
role_id,
|
||||
actor_tenant_id=actor_tenant_id,
|
||||
actor_is_superadmin=actor_is_superadmin,
|
||||
)
|
||||
|
||||
accesses = [
|
||||
{
|
||||
@@ -57,11 +91,12 @@ class RoleController:
|
||||
"category": ra.access.category,
|
||||
"name": ra.access.name,
|
||||
"parent_id": str(ra.access.parent_id) if ra.access.parent_id else None,
|
||||
"module_name": "SaaS (Internal)",
|
||||
}
|
||||
for ra in role.role_accesses
|
||||
if ra.access
|
||||
]
|
||||
|
||||
# Add Module Permissions
|
||||
accesses.extend([
|
||||
{
|
||||
"id": str(rma.module_access.id),
|
||||
@@ -69,8 +104,11 @@ class RoleController:
|
||||
"category": rma.module_access.category,
|
||||
"name": rma.module_access.name,
|
||||
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
|
||||
"module_id": str(rma.module_access.module_id) if rma.module_access.module_id else None,
|
||||
"module_name": rma.module_access.module.module_name if (rma.module_access and rma.module_access.module) else "DocQube",
|
||||
}
|
||||
for rma in role.role_module_accesses
|
||||
if rma.module_access
|
||||
])
|
||||
|
||||
return RoleWithAccessesResponse(
|
||||
@@ -105,4 +143,4 @@ class RoleController:
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ class SSOController:
|
||||
db: Session,
|
||||
payload: SSOExchangeRequest,
|
||||
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()
|
||||
if not module:
|
||||
@@ -46,28 +47,26 @@ class SSOController:
|
||||
headers["X-Module-Signature"] = x_module_signature
|
||||
if x_module_key:
|
||||
headers["X-Module-Key"] = x_module_key
|
||||
for header in (
|
||||
"X-Module-Signature-Version",
|
||||
"X-Module-Timestamp",
|
||||
"X-Module-Nonce",
|
||||
):
|
||||
value = (raw_headers or {}).get(header.lower())
|
||||
if value:
|
||||
headers[header] = value
|
||||
|
||||
actual_body = payload.model_dump_json()
|
||||
try:
|
||||
TrustService.validate_module_trust(
|
||||
environment=env,
|
||||
request_headers=headers,
|
||||
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=""
|
||||
)
|
||||
|
||||
TrustService.validate_module_trust(
|
||||
environment=env,
|
||||
request_headers=headers,
|
||||
request_body=actual_body,
|
||||
)
|
||||
|
||||
return SSOService.exchange_grant(
|
||||
db=db,
|
||||
grant_code=payload.grant_code,
|
||||
module_id=payload.module_id,
|
||||
environment_slug=payload.environment_slug
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ from typing import List, Optional
|
||||
class TenantController:
|
||||
|
||||
@staticmethod
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate):
|
||||
return TenantService.create_tenant(db, tenant_data)
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate, actor=None):
|
||||
return TenantService.create_tenant(db, tenant_data, actor=actor)
|
||||
|
||||
@staticmethod
|
||||
def get_all_tenants(db: Session):
|
||||
@@ -19,9 +19,15 @@ class TenantController:
|
||||
return TenantService.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate):
|
||||
return TenantService.update_tenant(db, tenant_id, tenant_data)
|
||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate, actor=None):
|
||||
return TenantService.update_tenant(db, tenant_id, tenant_data, actor=actor)
|
||||
|
||||
@staticmethod
|
||||
def subscription_history(db: Session, tenant_id: uuid.UUID, limit: int = 50):
|
||||
from app.services.auth import subscription_history as history
|
||||
|
||||
return history.history_for(db, tenant_id, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||
return TenantService.delete_tenant(db, tenant_id)
|
||||
|
||||
@@ -5,11 +5,12 @@ import uuid
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate
|
||||
from app.services.auth.user_service import UserService
|
||||
from app.middleware.tenant_middleware import is_superadmin
|
||||
|
||||
class UserController:
|
||||
@staticmethod
|
||||
def _resolve_tenant_id(current_user: User, requested_tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
|
||||
if current_user.tenant_id is None:
|
||||
if is_superadmin(current_user):
|
||||
return requested_tenant_id
|
||||
|
||||
if requested_tenant_id and requested_tenant_id != current_user.tenant_id:
|
||||
@@ -25,28 +26,86 @@ class UserController:
|
||||
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||
return UserService.create_user(db, user_data, tenant_id, background_tasks)
|
||||
|
||||
@staticmethod
|
||||
def _scoped_tenant_id(current_user: User) -> Optional[uuid.UUID]:
|
||||
"""The tenant filter to apply for this actor.
|
||||
|
||||
None means "no filter", which is only ever correct for a superadmin.
|
||||
A tenant-less non-superadmin is an artefact of the signup defect and
|
||||
is refused rather than being handed an unfiltered query.
|
||||
"""
|
||||
if is_superadmin(current_user):
|
||||
return None
|
||||
if current_user.tenant_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is not associated with a workspace",
|
||||
)
|
||||
return current_user.tenant_id
|
||||
|
||||
@staticmethod
|
||||
def _visible_ids(db: Session, current_user: User):
|
||||
"""Whose accounts this person may administer, or None for everyone.
|
||||
|
||||
Computed here rather than inside `UserService` so the rule lives in one
|
||||
place: every path an administrator reaches a user through goes past this
|
||||
method, and a query that forgot it would be a scoped administrator
|
||||
quietly seeing the whole workspace.
|
||||
"""
|
||||
from app.services.auth import org_unit_service
|
||||
|
||||
if is_superadmin(current_user):
|
||||
return None
|
||||
return org_unit_service.visible_user_ids(db, current_user)
|
||||
|
||||
@staticmethod
|
||||
def get_all_users(db: Session, current_user: User) -> List[User]:
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_all_users(db, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
return UserService.get_all_users(
|
||||
db, tenant_id, visible_ids=UserController._visible_ids(db, current_user)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: uuid.UUID, current_user: User) -> User:
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
return UserService.get_user_by_id(
|
||||
db, user_id, tenant_id,
|
||||
visible_ids=UserController._visible_ids(db, current_user),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, 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)
|
||||
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.delete_user(db, user_id, tenant_id)
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
UserService.get_user_by_id(
|
||||
db, user_id, tenant_id,
|
||||
visible_ids=UserController._visible_ids(db, current_user),
|
||||
)
|
||||
return UserService.delete_user(db, user_id, tenant_id,
|
||||
actor_id=current_user.id)
|
||||
|
||||
@staticmethod
|
||||
def deleted_users(db: Session, current_user: User):
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
return UserService.deleted_users(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def restore_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||
tenant_id = UserController._scoped_tenant_id(current_user)
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id,
|
||||
include_deleted=True)
|
||||
return UserService.restore(db, user)
|
||||
|
||||
@staticmethod
|
||||
def get_users_paginated(
|
||||
@@ -64,6 +123,7 @@ class UserController:
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
tenant_id = current_user.tenant_id
|
||||
visible = UserController._visible_ids(db, current_user)
|
||||
return UserService.get_users_paginated(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
@@ -77,4 +137,5 @@ class UserController:
|
||||
filter_role_ids=filter_role_ids,
|
||||
sort_by=sort_by,
|
||||
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.access_model import Access
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.core.tenant_context import unscoped
|
||||
from app.services.auth import api_key_service
|
||||
from app.services.auth.subscription_lifecycle import (
|
||||
SubscriptionState,
|
||||
resolve as resolve_lifecycle,
|
||||
)
|
||||
|
||||
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _lifecycle_message(lifecycle) -> str:
|
||||
"""Say which of the several reasons applies, rather than "inactive".
|
||||
|
||||
"Tenant is inactive" covered suspension, cancellation and expiry alike, so
|
||||
nobody reading it could tell whether to renew, contact support, or check
|
||||
their own admin settings.
|
||||
"""
|
||||
if lifecycle.state is SubscriptionState.CANCELLED:
|
||||
return "This workspace has been cancelled. Contact support to reinstate it."
|
||||
if lifecycle.state is SubscriptionState.SUSPENDED:
|
||||
return "This workspace has been suspended. Contact support."
|
||||
if lifecycle.state is SubscriptionState.EXPIRED:
|
||||
return "Your subscription has expired. Renew it to regain access."
|
||||
return "This workspace is not active."
|
||||
from app.services.auth.subscription_entitlement_service import (
|
||||
SubscriptionEntitlementService,
|
||||
)
|
||||
@@ -19,12 +43,19 @@ def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
||||
token = (
|
||||
credentials.credentials if credentials
|
||||
else request.headers.get("X-API-Key") or request.cookies.get("access_token")
|
||||
)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated"
|
||||
)
|
||||
|
||||
if api_key_service.looks_like_a_key(token):
|
||||
return _user_for_api_key(request, token, db)
|
||||
|
||||
try:
|
||||
payload = security.verify_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
@@ -41,7 +72,8 @@ def get_current_user(
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
with unscoped():
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -49,36 +81,97 @@ def get_current_user(
|
||||
)
|
||||
|
||||
setattr(user, "_saas_db_session", db)
|
||||
|
||||
_forget_api_key(user)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is inactive"
|
||||
)
|
||||
|
||||
if user.tenant_id is not None:
|
||||
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"
|
||||
)
|
||||
_assert_workspace_usable(request, user, db)
|
||||
|
||||
return user
|
||||
|
||||
def _forget_api_key(user: User) -> None:
|
||||
for marker in ("_saas_api_key_scopes", "_saas_api_key_id", "_saas_api_key_name"):
|
||||
if hasattr(user, marker):
|
||||
delattr(user, marker)
|
||||
|
||||
|
||||
def _user_for_api_key(request: Request, raw: str, db: Session) -> User:
|
||||
"""Turn a key into the principal it acts as.
|
||||
|
||||
The principal is the **user who issued it**, not a separate kind of account.
|
||||
That is the decision that makes everything else fall out: row-level security,
|
||||
the entitlement chain, subscription lifecycle and audit attribution all work
|
||||
unchanged, and deactivating somebody disables their integrations in the same
|
||||
moment rather than leaving them running after that person has gone.
|
||||
|
||||
What the key adds is a ceiling. `_saas_api_key_scopes` is set on the returned
|
||||
user, and `has_access` intersects with it — so a key can only ever be
|
||||
narrower than its owner, never wider.
|
||||
"""
|
||||
key = api_key_service.resolve(db, raw)
|
||||
if key is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
with unscoped():
|
||||
owner = db.query(User).filter(User.id == key.user_id).first()
|
||||
if owner is None or owner.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
)
|
||||
|
||||
setattr(owner, "_saas_db_session", db)
|
||||
setattr(owner, "_saas_api_key_scopes", api_key_service.effective_scopes(db, key, owner))
|
||||
setattr(owner, "_saas_api_key_id", key.id)
|
||||
setattr(owner, "_saas_api_key_name", key.name)
|
||||
request.state.api_key_id = str(key.id)
|
||||
|
||||
_assert_workspace_usable(request, owner, db)
|
||||
|
||||
api_key_service.touch(key.id, key.last_used_at)
|
||||
return owner
|
||||
|
||||
|
||||
def _assert_workspace_usable(request: Request, user: User, db: Session) -> None:
|
||||
"""The subscription checks, applied to any way of authenticating.
|
||||
|
||||
Extracted rather than repeated: a key that kept working through a
|
||||
cancellation, or kept writing during the read-only grace period, would be a
|
||||
hole that exists only because a second code path forgot about it.
|
||||
"""
|
||||
if user.tenant_id is None:
|
||||
return
|
||||
|
||||
with unscoped():
|
||||
tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found")
|
||||
|
||||
lifecycle = resolve_lifecycle(tenant)
|
||||
if not lifecycle.can_sign_in:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=_lifecycle_message(lifecycle))
|
||||
|
||||
if not lifecycle.can_write and request.method in _WRITE_METHODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Your subscription has expired. You can still view and export your "
|
||||
"data until "
|
||||
f"{lifecycle.grace_until.isoformat() if lifecycle.grace_until else 'renewal'}"
|
||||
", but changes are paused until it is renewed."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.status != "active":
|
||||
raise HTTPException(
|
||||
@@ -95,7 +188,11 @@ def has_access(user: User, access_code: str) -> bool:
|
||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||
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:
|
||||
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
|
||||
|
||||
def is_superadmin(user) -> bool:
|
||||
return user.tenant_id is None
|
||||
"""Platform superadmin — an explicit property of the account.
|
||||
|
||||
This deliberately does NOT infer privilege from a missing tenant_id. Doing so
|
||||
made every tenant-less account a superadmin, and the public signup endpoint
|
||||
creates tenant-less accounts.
|
||||
"""
|
||||
return bool(getattr(user, "is_superadmin", False))
|
||||
|
||||
def require_superadmin(user=Depends(get_current_user)):
|
||||
if not is_superadmin(user):
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Establish the caller's workspace for the whole request.
|
||||
|
||||
The workspace comes from the access token rather than from a database lookup, so
|
||||
the context is in place before the first query runs — including the one that
|
||||
resolves the caller. `generate_access_token` already puts `tenant_id` in the
|
||||
claims; this reads it back.
|
||||
|
||||
Middleware rather than a dependency, because entering and exiting the context has
|
||||
to be one function. A `with` block cannot span a dependency and the endpoint that
|
||||
follows it, and a `ContextVar` set without a matching reset outlives the request.
|
||||
|
||||
The token is decoded here **for routing only** — to decide which workspace the
|
||||
queries belong to. It is deliberately not treated as authentication: a forged or
|
||||
expired token yields no context, and `get_current_user` still verifies properly,
|
||||
still checks the blacklist, and still refuses. The worst a bad token achieves
|
||||
here is scoping a request that is about to be rejected anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.tenant_context import scoped_to, unscoped
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _presented(request: Request) -> str | None:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if header.startswith("Bearer "):
|
||||
return header[7:]
|
||||
return request.headers.get("X-API-Key") or request.cookies.get("access_token")
|
||||
|
||||
|
||||
def _claims(request: Request) -> dict | None:
|
||||
token = _presented(request)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def _tenant_for_api_key(raw: str) -> tuple[uuid.UUID, str] | None:
|
||||
"""The workspace an API key belongs to, and its name, for scoping only.
|
||||
|
||||
A key is not a JWT, so the claims route above yields nothing and the request
|
||||
would run with no workspace — which row-level security would answer by
|
||||
showing the caller an empty platform. That is a confusing way to fail: every
|
||||
endpoint returns 200 and nothing.
|
||||
|
||||
This costs one indexed lookup on a short-lived session of its own, because
|
||||
middleware runs before the request's session exists. As with the token
|
||||
above, it is **scoping, not authentication** — `get_current_user` still
|
||||
resolves the key properly and still refuses a bad one.
|
||||
"""
|
||||
from app.config.database import SessionLocal
|
||||
from app.services.auth import api_key_service
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
key = api_key_service.resolve(db, raw)
|
||||
return (key.tenant_id, key.name) if key else None
|
||||
except Exception:
|
||||
logger.exception("could not resolve an API key for request scoping")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class TenantScopeMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
claims = _claims(request)
|
||||
|
||||
if claims is None:
|
||||
from app.services.auth import api_key_service
|
||||
|
||||
presented = _presented(request)
|
||||
if api_key_service.looks_like_a_key(presented):
|
||||
resolved = _tenant_for_api_key(presented)
|
||||
if resolved is not None:
|
||||
tenant_id, key_name = resolved
|
||||
request.state.tenant_id = str(tenant_id)
|
||||
with scoped_to(tenant_id), api_key_service.acting_as(key_name):
|
||||
return await call_next(request)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
if claims.get("is_superadmin"):
|
||||
with unscoped():
|
||||
return await call_next(request)
|
||||
|
||||
raw_tenant = claims.get("tenant_id")
|
||||
if not raw_tenant:
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(raw_tenant))
|
||||
except ValueError:
|
||||
logger.warning("access token carried an unparseable tenant_id")
|
||||
return await call_next(request)
|
||||
|
||||
request.state.tenant_id = str(tenant_id)
|
||||
with scoped_to(tenant_id):
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Every model, in one import.
|
||||
|
||||
`Base.metadata` only knows about a model once its module has been imported.
|
||||
Nothing had imported them all in one place, so anything wanting the whole schema
|
||||
— `create_all`, a scratch database, a tool reading the metadata — had to list
|
||||
twenty modules and stay in step with them.
|
||||
|
||||
Importing here is a side effect on purpose. The names are re-exported so the
|
||||
import is not mistaken for dead code and removed.
|
||||
"""
|
||||
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.identity_model import (
|
||||
IdentityProvider,
|
||||
SsoLoginState,
|
||||
UserIdentity,
|
||||
)
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
from app.models.auth.role_access_model import RoleAccess
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.sso_grant_model import SSOGrant
|
||||
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.system.alert_state_model import AlertState
|
||||
from app.models.system.audit_log import AuditLog
|
||||
from app.models.system.event_log_model import EventLog
|
||||
from app.models.auth.api_key_model import ApiKey # noqa: F401
|
||||
from app.models.auth.seat_allocation_model import OrgUnitSeatAllocation # noqa: F401
|
||||
from app.models.auth.org_unit_model import OrgUnit, UserAdminScope, UserOrgUnit # noqa: F401
|
||||
from app.models.auth.invitation_model import UserInvitation # noqa: F401
|
||||
from app.models.system.idempotency_model import IdempotencyRecord # noqa: F401
|
||||
from app.models.system.notification_preference_model import NotificationPreference # noqa: F401
|
||||
from app.models.system.notification_model import Notification # noqa: F401
|
||||
from app.models.system.tenant_email_model import TenantEmailSettings # noqa: F401
|
||||
from app.models.system.document_model import Document # noqa: F401
|
||||
from app.models.system.lookup_model import LookupItem, LookupList # noqa: F401
|
||||
from app.models.system.webhook_model import WebhookDelivery, WebhookEndpoint # noqa: F401
|
||||
from app.models.system.mfa_model import MfaRecoveryCode, UserMfa
|
||||
from app.models.system.subscription_history_model import TenantSubscriptionHistory
|
||||
from app.models.system.subscription_notice_model import SubscriptionNotice
|
||||
from app.models.system.user_session_model import UserSession
|
||||
from app.models.theme.color_palette_model import ColorPalette
|
||||
|
||||
__all__ = [
|
||||
"Access",
|
||||
"AlertState",
|
||||
"AuditLog",
|
||||
"ColorPalette",
|
||||
"EventLog",
|
||||
"IdentityProvider",
|
||||
"MfaRecoveryCode",
|
||||
"Module",
|
||||
"ModuleAccess",
|
||||
"ModuleEnvironment",
|
||||
"PlanAccess",
|
||||
"PlanModuleAccess",
|
||||
"Role",
|
||||
"RoleAccess",
|
||||
"RoleModuleAccess",
|
||||
"SSOGrant",
|
||||
"SubscriptionNotice",
|
||||
"SsoLoginState",
|
||||
"SubscriptionPlan",
|
||||
"Tenant",
|
||||
"TenantModule",
|
||||
"TenantSubscriptionHistory",
|
||||
"User",
|
||||
"UserIdentity",
|
||||
"UserMfa",
|
||||
"UserSession",
|
||||
]
|
||||
@@ -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
|
||||
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.orm import relationship
|
||||
from app.config.database import Base
|
||||
@@ -20,7 +20,9 @@ class ModuleEnvironment(Base):
|
||||
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
|
||||
|
||||
trust_type = Column(String, nullable=False)
|
||||
|
||||
trust_credentials = Column(JSON, nullable=False)
|
||||
trust_credentials_enc = Column(Text, nullable=True)
|
||||
|
||||
is_default = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -28,10 +30,32 @@ class ModuleEnvironment(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
module = relationship("Module", back_populates="environments")
|
||||
|
||||
@property
|
||||
def credentials(self) -> dict:
|
||||
"""The trust credentials, decrypted.
|
||||
|
||||
Falls back to the legacy plaintext column so a row that has not been
|
||||
migrated yet still works. Once migration b2e1d4f5a602 has run, that
|
||||
fallback returns an empty dict and the encrypted column is the only
|
||||
source.
|
||||
"""
|
||||
from app.core.crypto import decrypt_json, is_encrypted
|
||||
|
||||
if is_encrypted(self.trust_credentials_enc):
|
||||
return decrypt_json(self.trust_credentials_enc)
|
||||
return self.trust_credentials or {}
|
||||
|
||||
@credentials.setter
|
||||
def credentials(self, value: dict) -> None:
|
||||
from app.core.crypto import encrypt_json
|
||||
|
||||
self.trust_credentials_enc = encrypt_json(value or {})
|
||||
self.trust_credentials = {}
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
|
||||
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Departments, branches and teams, and who administers which."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class OrgUnit(Base):
|
||||
__tablename__ = "org_units"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
name = Column(String(160), nullable=False)
|
||||
code = Column(String(60))
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey("org_units.id", ondelete="RESTRICT"))
|
||||
path = Column(Text, nullable=False, default="/")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True))
|
||||
deleted_by_id = Column(UUID(as_uuid=True))
|
||||
|
||||
parent = relationship("OrgUnit", remote_side=[id], lazy="raise")
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
"""How far down the tree. `/` is depth 0."""
|
||||
return max(0, len([part for part in (self.path or "/").split("/") if part]) - 1)
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
return self.deleted_at is not None
|
||||
|
||||
def descendant_prefix(self) -> str:
|
||||
"""What a descendant's path starts with.
|
||||
|
||||
Includes this unit itself, because "everything I administer" always
|
||||
means "this unit and everything under it" — a lead who could not manage
|
||||
their own unit would be a strange kind of lead.
|
||||
"""
|
||||
return self.path or "/"
|
||||
|
||||
|
||||
class UserOrgUnit(Base):
|
||||
__tablename__ = "user_org_units"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
org_unit_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False)
|
||||
is_primary = Column(Boolean, nullable=False, default=False)
|
||||
is_lead = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
org_unit = relationship("OrgUnit", lazy="raise")
|
||||
|
||||
|
||||
class UserAdminScope(Base):
|
||||
"""A unit whose people this person may administer.
|
||||
|
||||
Separate from membership and from being a lead. All three exist because they
|
||||
are genuinely different: somebody can be *in* a unit without administering
|
||||
it, can *lead* it without the platform granting them anything, and can
|
||||
administer a unit they do not belong to — a regional HR administrator, for
|
||||
instance.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_admin_scopes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid())
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
org_unit_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
org_unit = relationship("OrgUnit", lazy="raise")
|
||||
@@ -13,7 +13,6 @@ class RoleAccess(Base):
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
role = relationship("Role", back_populates="role_accesses")
|
||||
access = relationship("Access", back_populates="role_accesses")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
environment_slug = Column(String, nullable=False)
|
||||
# redirect_url removed - stateless grants
|
||||
|
||||
is_used = Column(Boolean, default=False)
|
||||
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())
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class SubscriptionPlan(Base):
|
||||
price = Column(Numeric(10, 2), nullable=True)
|
||||
duration_days = Column(Integer, nullable=True)
|
||||
max_users_allowed = Column(Integer, nullable=True)
|
||||
grace_period_days = Column(Integer, nullable=False, server_default="0", default=0)
|
||||
is_public = Column(Boolean, default=True)
|
||||
status = Column(String, default="active")
|
||||
|
||||
@@ -21,7 +22,6 @@ class SubscriptionPlan(Base):
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenants = relationship("Tenant", back_populates="plan")
|
||||
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||
|
||||
@@ -16,13 +16,17 @@ class Tenant(Base):
|
||||
start_date = Column(Date, nullable=True)
|
||||
end_date = Column(Date, nullable=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())
|
||||
updated_at = Column(
|
||||
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")
|
||||
roles = relationship("Role", 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):
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
return self.deleted_at is not None
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.orm import relationship
|
||||
from app.config.database import Base
|
||||
@@ -15,7 +15,13 @@ class User(Base):
|
||||
phone_number = Column(String, nullable=True)
|
||||
preferred_language = Column(String, default="en", nullable=True)
|
||||
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)
|
||||
|
||||
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())
|
||||
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")
|
||||
role = relationship("Role", back_populates="users")
|
||||
|
||||
def __repr__(self):
|
||||
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
|
||||
from sqlalchemy import Column, String, DateTime, Text
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
action_type = Column(String(20), nullable=False, index=True)
|
||||
@@ -35,4 +42,4 @@ class AuditLog(Base):
|
||||
return (
|
||||
f"<AuditLog {self.action_type} on {self.module_name}"
|
||||
f" by {self.performed_by_email}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
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)
|
||||
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.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.schemas.auth.audit_schema import AuditLogListResponse
|
||||
|
||||
@@ -26,11 +27,24 @@ def get_audit_logs(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""
|
||||
Fetch audit logs with optional filters.
|
||||
Only accessible to authenticated users (super-admin in practice).
|
||||
"""Audit entries for the caller's workspace, newest first.
|
||||
|
||||
"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)
|
||||
|
||||
if not is_superadmin(current_user):
|
||||
query = query.filter(AuditLog.tenant_id == current_user.tenant_id)
|
||||
|
||||
if module_name:
|
||||
query = query.filter(AuditLog.module_name == module_name)
|
||||
@@ -82,4 +96,4 @@ def get_audit_logs(
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -60,6 +60,7 @@ def create_environment(
|
||||
description=f"Environment '{result.slug}' created for '{module.module_name}'",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
tenant_id=current_user.tenant_id,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=env_data.model_dump(exclude={"trust_credentials"})
|
||||
)
|
||||
@@ -89,6 +90,7 @@ def update_environment(
|
||||
description=f"Environment '{result.slug}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
tenant_id=current_user.tenant_id,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=old_values,
|
||||
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}",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
tenant_id=current_user.tenant_id,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values={"is_default": True}
|
||||
)
|
||||
@@ -144,6 +147,7 @@ def delete_environment(
|
||||
description=f"Environment deleted from {module.module_name}",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
tenant_id=current_user.tenant_id,
|
||||
ip_address=get_client_ip(request),
|
||||
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