2 Commits
Author SHA1 Message Date
Furqan-14 6cd665e95a fix: code cleanup 2026-08-31 20:39:41 -04:00
Furqan-14 b923b3ed15 feat: updated saas platform 2026-08-31 20:04:12 -04:00
240 changed files with 35002 additions and 682 deletions
BIN
View File
Binary file not shown.
+151
View File
@@ -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=
+8 -1
View File
@@ -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
View File
@@ -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 56 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.
+25
View File
@@ -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:
+4 -19
View File
@@ -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")
+139
View File
@@ -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")
+90
View File
@@ -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")
+105
View File
@@ -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,63 @@
"""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
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_email_trgm "
"ON users USING gin (email gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_first_name_trgm "
"ON users USING gin (first_name gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_last_name_trgm "
"ON users USING gin (last_name gin_trgm_ops)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_audit_entity_name_trgm "
"ON audit_logs USING gin (entity_name gin_trgm_ops)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_audit_entity_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_last_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_first_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_email_trgm")
@@ -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 ###
+83 -9
View File
@@ -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",
@@ -125,6 +124,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 +138,6 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
# === OpenAPI Security Scheme ===
from fastapi.openapi.utils import get_openapi
def custom_openapi():
@@ -159,7 +161,6 @@ def create_app() -> FastAPI:
app.openapi = custom_openapi
# === CORS ===
origins = []
if settings.CORS_ALLOWED_ORIGINS:
origins = [
@@ -173,15 +174,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 +206,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 +316,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
View File
@@ -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()
+116 -11
View File
@@ -24,17 +24,46 @@ 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.
A stored value that is not a bcrypt hash means "this account has no
password" — which is now a real state, because an account provisioned
through an identity provider has no password to store and holds a
placeholder instead.
`bcrypt.checkpw` raises `ValueError: Invalid salt` on such a value, and
an unhandled exception on the sign-in path is a 500 where the answer is
plainly "no". Returning False says the same thing without telling the
caller which kind of account they just probed.
"""
if not plain_password or not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode('utf-8'), hashed_password.encode('utf-8')
)
except ValueError:
return False
@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 +74,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 +158,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 +184,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 +296,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
View File
@@ -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()
+18 -4
View File
@@ -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):
+42 -9
View File
@@ -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 = [
{
@@ -61,7 +95,6 @@ class RoleController:
for ra in role.role_accesses
]
# Add Module Permissions
accesses.extend([
{
"id": str(rma.module_access.id),
@@ -105,4 +138,4 @@ class RoleController:
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
)
)
+17 -18
View File
@@ -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
)
)
+10 -4
View File
@@ -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)
+71 -10
View File
@@ -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,
)
+76
View File
@@ -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))
+127
View File
@@ -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
+43
View File
@@ -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)
+126
View File
@@ -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"
+44
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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
+96
View File
@@ -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]
+121 -24
View File
@@ -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(
+163
View File
@@ -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,
)
+122
View File
@@ -0,0 +1,122 @@
"""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."""
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"
)
+7 -1
View File
@@ -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):
+112
View File
@@ -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)
+78
View File
@@ -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",
]
+72
View File
@@ -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 [])
+177
View File
@@ -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]}…>"
+70
View File
@@ -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"
+26 -2
View File
@@ -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}>"
+89
View File
@@ -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")
-1
View File
@@ -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")
+27
View File
@@ -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")
+1 -2
View File
@@ -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())
+1 -1
View File
@@ -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")
+10 -2
View File
@@ -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}>"
+16 -3
View File
@@ -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}>"
+35
View File
@@ -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'}>"
+9 -2
View File
@@ -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}"
)
)
+34
View File
@@ -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
+1 -1
View File
@@ -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)
+36
View File
@@ -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
+60
View File
@@ -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
+97
View File
@@ -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'}>"
+30
View File
@@ -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}>"
+71
View File
@@ -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
+50
View File
@@ -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'}>"
+100
View File
@@ -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")
+18 -4
View File
@@ -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,
)
)
+4
View File
@@ -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
)
+3 -5
View File
@@ -33,7 +33,6 @@ def create_module(
):
result = ModuleController.create_module(db, module_data)
# Professional Audit Logging
AuditLogService.log(
db=db,
module_name="Modules",
@@ -43,6 +42,7 @@ def create_module(
description=f"Module '{result.module_name}' created",
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=module_data.model_dump()
)
@@ -66,7 +66,6 @@ def update_module(
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Snapshot before update
existing = ModuleController.get_module(db, module_id)
old_values = {"name": existing.module_name, "status": existing.status}
@@ -81,6 +80,7 @@ def update_module(
description=f"Module '{result.module_name}' 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=module_data.model_dump(exclude_unset=True)
@@ -95,7 +95,6 @@ def delete_module(
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Capture name for the log before it's deleted
existing = ModuleController.get_module(db, module_id)
module_name = existing.module_name
@@ -110,11 +109,11 @@ def delete_module(
description=f"Module '{module_name}' deleted",
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)
)
return result
# ── Permission Sync Routes ──────────────────────────────────────────────────
@router.get("/{module_id}/permissions")
def get_module_permissions(
@@ -123,7 +122,6 @@ def get_module_permissions(
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
# Now using the Service method you just shared!
return ModulePermissionService.get_module_permissions(db, module_id)
@router.post("/{module_id}/permissions/sync")
+295
View File
@@ -0,0 +1,295 @@
"""What the background work has actually been doing.
Four jobs run on their own — the event outbox, the session sweep, the
subscription notices and the alert checks — and every one of them reported only
into a log file.
"How many customers lapsed this month, and how many of them we could not reach"
was answerable, but only by someone willing to grep a worker's stdout, which
means in practice it was not answered.
Read-only, superadmin-only, and cheap: counts and a short recent list, nothing
that walks a whole table.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.core.tenant_context import unscoped
from app.middleware.tenant_middleware import require_superadmin
from app.models.auth.tenant_model import Tenant
from app.models.auth.user_model import User
from app.models.system.audit_log import AuditLog
from app.services.auth import lockout_service
from app.models.system.event_log_model import EventLog, EventStatus
from app.models.system.subscription_notice_model import SubscriptionNotice
from app.models.system.user_session_model import UserSession
router = APIRouter()
@router.get("/subscription-notices")
def subscription_notice_summary(
days: int = Query(30, ge=1, le=365),
_=Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Who has been told what, and who could not be told.
The second number is the one that matters: a workspace with no
`billing_email` gets no warning at all before it lapses, and the only
previous sign was a line in a log nobody reads on a customer's behalf.
"""
since = datetime.now(timezone.utc) - timedelta(days=days)
with unscoped():
by_kind = dict(
db.query(SubscriptionNotice.kind, func.count(SubscriptionNotice.id))
.filter(SubscriptionNotice.sent_at >= since)
.group_by(SubscriptionNotice.kind)
.all()
)
unreachable = (
db.query(SubscriptionNotice)
.filter(
SubscriptionNotice.sent_at >= since,
SubscriptionNotice.sent_to.is_(None),
)
.count()
)
no_billing_contact = (
db.query(Tenant)
.filter(Tenant.end_date.isnot(None), Tenant.billing_email.is_(None))
.count()
)
recent = (
db.query(SubscriptionNotice, Tenant.tenant_name)
.join(Tenant, Tenant.id == SubscriptionNotice.tenant_id)
.filter(SubscriptionNotice.sent_at >= since)
.order_by(SubscriptionNotice.sent_at.desc())
.limit(50)
.all()
)
return {
"window_days": days,
"by_kind": by_kind,
"total": sum(by_kind.values()),
"recorded_but_not_sent": unreachable,
"workspaces_with_no_billing_contact": no_billing_contact,
"recent": [
{
"tenant_name": name,
"kind": notice.kind,
"for_end_date": notice.for_end_date,
"sent_to": notice.sent_to,
"sent_at": notice.sent_at,
}
for notice, name in recent
],
}
@router.get("/outbox")
def outbox_summary(
_=Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Whether events are getting through.
`stuck` is the number worth an alert: pending, overdue, and already retried.
A module that has been refusing deliveries for a day shows up here long
before anyone notices its data is stale.
"""
now = datetime.now(timezone.utc)
with unscoped():
by_status = dict(
db.query(EventLog.status, func.count(EventLog.id))
.group_by(EventLog.status)
.all()
)
stuck = (
db.query(EventLog)
.filter(
EventLog.status == EventStatus.PENDING,
EventLog.next_retry_at <= now,
EventLog.retry_count > 0,
)
.count()
)
oldest_pending = (
db.query(func.min(EventLog.created_at))
.filter(EventLog.status == EventStatus.PENDING)
.scalar()
)
failing = (
db.query(
EventLog.target_url,
func.count(EventLog.id),
func.max(EventLog.error_log),
)
.filter(EventLog.status.in_((EventStatus.PENDING, EventStatus.FAILED)))
.filter(EventLog.retry_count > 0)
.group_by(EventLog.target_url)
.order_by(func.count(EventLog.id).desc())
.limit(10)
.all()
)
return {
"by_status": by_status,
"stuck": stuck,
"oldest_pending_at": oldest_pending,
"failing_targets": [
{"target_url": url, "count": count, "last_error": error}
for url, count, error in failing
],
}
@router.get("/sessions")
def session_summary(
_=Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Live sessions, and how many ended because a token was reused.
`reuse_detected` is a security signal, not a capacity one. A non-zero count
means somebody presented a refresh token the legitimate client had already
spent — which is what a copied token looks like.
"""
now = datetime.now(timezone.utc)
with unscoped():
active = (
db.query(UserSession)
.filter(UserSession.revoked_at.is_(None), UserSession.expires_at > now)
.count()
)
by_reason = dict(
db.query(UserSession.revoked_reason, func.count(UserSession.id))
.filter(UserSession.revoked_at.isnot(None))
.group_by(UserSession.revoked_reason)
.all()
)
sweepable = (
db.query(UserSession)
.filter(UserSession.expires_at < now - timedelta(days=30))
.count()
)
return {
"active": active,
"ended_by_reason": by_reason,
"reuse_detected": by_reason.get("reuse_detected", 0),
"awaiting_sweep": sweepable,
}
@router.get("/alerts")
def open_alerts(
_=Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Conditions currently firing.
The same three the worker sends out, kept here so the console shows what is
open rather than what has been sent — somebody arriving after the message
scrolled past still needs to know the thing is still wrong.
A `notify_count` of 0 means the condition is open and nothing has managed to
deliver it: a webhook outage, or no destination configured at all.
"""
from app.services.system import alerting
return [
{
"key": alert.alert_key,
"severity": alert.severity,
"detail": alert.detail,
"observed": alert.observed,
"opened_at": alert.opened_at,
"last_notified_at": alert.last_notified_at,
"notify_count": alert.notify_count,
}
for alert in alerting.open_alerts(db)
]
@router.get("/locked-accounts")
def locked_accounts(
_=Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""Who is shut out right now, across every workspace.
A handful is people mistyping. A spike is an attack in progress against
named accounts, and nothing else on the platform would show it — the lockout
is deliberately silent to the person triggering it, so this is the only
place it becomes visible.
"""
with unscoped():
users = lockout_service.locked_accounts(db)
by_tenant = dict(
db.query(User.tenant_id, func.count(User.id))
.filter(User.locked_until.isnot(None),
User.locked_until > datetime.now(timezone.utc))
.group_by(User.tenant_id)
.all()
)
return {
"locked_now": len(users),
"by_workspace": [
{"tenant_id": str(tid) if tid else None, "locked": count}
for tid, count in by_tenant.items()
],
"listed": min(len(users), 50),
"truncated": len(users) > 50,
"accounts": [
{
"email": u.email,
"tenant_id": str(u.tenant_id) if u.tenant_id else None,
"locked_until": u.locked_until,
"failed_attempts": u.failed_login_attempts,
}
for u in users[:50]
],
}
@router.get("/audit-retention")
def audit_retention_status(
_=Depends(require_superadmin),
db: Session = Depends(get_db),
):
"""How far behind the audit sweep is.
A number that stays high across runs means the sweep is not keeping up,
which is worth knowing before the table is the reason an operations query
times out.
"""
from app.services.system import audit_retention
with unscoped():
total = db.query(func.count(AuditLog.id)).scalar() or 0
oldest = db.query(func.min(AuditLog.created_at)).scalar()
return {
"total_entries": total,
"oldest_entry": oldest,
"past_retention": audit_retention.pending(db),
"retention_days": audit_retention.DEFAULT_RETENTION_DAYS,
"security_retention_days": audit_retention.SECURITY_RETENTION_DAYS,
}
+3
View File
@@ -40,6 +40,7 @@ def assign_module_to_tenant(
description=f"Module assigned to tenant {tenant_id}",
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=assignment_data.model_dump(mode='json')
)
@@ -66,6 +67,7 @@ def update_tenant_module(
description=f"Tenant module assignment 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),
new_values=update_data.model_dump(exclude_unset=True)
)
@@ -91,6 +93,7 @@ def remove_module_from_tenant(
description=f"Tenant module assignment deleted",
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),
)
return result

Some files were not shown because too many files have changed in this diff Show More