252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
"""
|
|
B1 step 2 — is this database safe to turn tenant isolation on against?
|
|
|
|
**Read-only.** It opens a connection, runs SELECTs, and prints. It writes
|
|
nothing, changes no settings, and is safe to point at production. That is the
|
|
whole design: the decision it informs is a production decision, so the check has
|
|
to run there.
|
|
|
|
APP_ENV=production python scripts/b1_preflight.py
|
|
python scripts/b1_preflight.py --database-url postgresql://.../docqube
|
|
|
|
Run it *before* deleting the `tenant_id IS NULL` fallback (step 3) and before
|
|
enabling `TENANT_FILTER_ENABLED` (step 4). The order matters: enable the filter
|
|
while the fallback still exists and any request that loses its tenant becomes a
|
|
superadmin request — the exact escalation B1 exists to remove.
|
|
|
|
Exit code is 0 if nothing needs a decision, 1 if something does. Nothing here is
|
|
a hard failure — a finding means "fix the data or accept it knowingly", not
|
|
"stop".
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
# Read the table list from the migration itself, so the two cannot disagree.
|
|
# Loaded by path rather than imported: `alembic` resolves to the installed
|
|
# package, and `alembic/versions/` is a plain directory, not a subpackage of it.
|
|
#
|
|
# The isolation ratchet (tests/probes/test_isolation_ratchet.py) fails if a
|
|
# tenant-owned model is missing from that migration, so this list cannot drift
|
|
# silently away from the models either.
|
|
import importlib.util # noqa: E402
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"_b1_3_rls",
|
|
Path(__file__).parent.parent / "alembic" / "versions" / "b1_3_row_level_security.py",
|
|
)
|
|
_rls = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_rls)
|
|
TENANT_TABLES = _rls.TENANT_TABLES
|
|
|
|
FINDINGS: list[str] = []
|
|
|
|
|
|
def _rule(title: str) -> None:
|
|
print(f"\n{title}\n{'-' * len(title)}")
|
|
|
|
|
|
def check_migration_applied(conn) -> bool:
|
|
from sqlalchemy import text
|
|
|
|
exists = conn.execute(
|
|
text(
|
|
"SELECT 1 FROM information_schema.columns "
|
|
"WHERE table_name = 'users' AND column_name = 'is_superadmin'"
|
|
)
|
|
).first()
|
|
_rule("1. Has b1_0 been deployed?")
|
|
if not exists:
|
|
print(" NO — users.is_superadmin does not exist.")
|
|
print(" Steps 3-5 cannot proceed. Deploy the migration first (step 1).")
|
|
FINDINGS.append("b1_0 not applied")
|
|
return False
|
|
print(" Yes — users.is_superadmin exists.")
|
|
return True
|
|
|
|
|
|
def check_fallback_dependents(conn) -> None:
|
|
"""
|
|
Who currently gets privilege *only* from `tenant_id IS NULL`?
|
|
|
|
These are the people who lose access the moment the fallback is deleted.
|
|
b1_0 backfilled the ones that existed when it ran; anyone created since,
|
|
with a null tenant and the flag unset, is relying on the fallback today.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
_rule("2. Who relies on the `tenant_id IS NULL` fallback?")
|
|
rows = conn.execute(
|
|
text(
|
|
"SELECT id, email, is_deleted FROM users "
|
|
"WHERE tenant_id IS NULL AND is_superadmin = false "
|
|
"ORDER BY is_deleted, id"
|
|
)
|
|
).fetchall()
|
|
|
|
live = [r for r in rows if not r[2]]
|
|
if not live:
|
|
print(" None. Every tenant-less operator carries the explicit flag.")
|
|
if rows:
|
|
print(f" ({len(rows)} deleted user(s) also match; they do not matter.)")
|
|
return
|
|
|
|
print(f" {len(live)} ACTIVE user(s) HAVE ALREADY LOST superadmin:")
|
|
for uid, email, _ in live:
|
|
print(f" id={uid} {email}")
|
|
print()
|
|
print(" This is no longer a warning about a future step. The fallback has")
|
|
print(" been deleted, so on the deploy carrying that change these accounts")
|
|
print(" stop being superadmins. Fix the data before deploying:")
|
|
print(" UPDATE users SET is_superadmin = true WHERE id IN (...);")
|
|
print()
|
|
print(" If this database has not had migration b1_0 applied yet, that is")
|
|
print(" the real answer — apply it first; it backfills exactly these rows.")
|
|
FINDINGS.append(f"{len(live)} active user(s) lose superadmin on deploy")
|
|
|
|
|
|
def check_superadmin_population(conn) -> None:
|
|
from sqlalchemy import text
|
|
|
|
_rule("3. How many superadmins are there?")
|
|
total, supers = conn.execute(
|
|
text(
|
|
"SELECT count(*), count(*) FILTER (WHERE is_superadmin) "
|
|
"FROM users WHERE is_deleted = false"
|
|
)
|
|
).first()
|
|
print(f" {supers} of {total} active users hold is_superadmin.")
|
|
if total and supers / total > 0.10:
|
|
print(" That is a large share. A superadmin bypasses tenant scoping")
|
|
print(" entirely, so this is worth eyeballing before step 4.")
|
|
FINDINGS.append(f"{supers}/{total} users are superadmin")
|
|
for (email,) in conn.execute(
|
|
text(
|
|
"SELECT email FROM users WHERE is_superadmin AND is_deleted = false "
|
|
"ORDER BY id LIMIT 20"
|
|
)
|
|
):
|
|
print(f" {email}")
|
|
|
|
|
|
def check_null_tenant_rows(conn) -> None:
|
|
"""
|
|
The one that actually bites.
|
|
|
|
The RLS policy reads `... OR tenant_id IS NULL OR tenant_id = <session>`.
|
|
The null branch is deliberate — it keeps shared system records such as
|
|
global roles readable by everyone. But it applies to *every* row in the
|
|
table, so a data row that lost its tenant is not hidden by RLS; it is
|
|
published to every tenant at once.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
_rule("4. Rows with no tenant (visible to EVERY tenant under RLS)")
|
|
offenders = []
|
|
for table in TENANT_TABLES:
|
|
present = conn.execute(
|
|
text(
|
|
"SELECT 1 FROM information_schema.columns "
|
|
"WHERE table_name = :t AND column_name = 'tenant_id'"
|
|
),
|
|
{"t": table},
|
|
).first()
|
|
if not present:
|
|
print(f" {table:<28} (no tenant_id column — skipped)")
|
|
continue
|
|
n = conn.execute(text(f"SELECT count(*) FROM {table} WHERE tenant_id IS NULL")).scalar()
|
|
if n:
|
|
offenders.append((table, n))
|
|
print(f" {table:<28}{n:>10} null-tenant row(s)")
|
|
|
|
if not offenders:
|
|
print("\n Clean. Every row carries a tenant.")
|
|
return
|
|
|
|
print("\n These rows will be readable by every tenant once RLS is on.")
|
|
print(" For `roles` a shared row is usually intentional (global role")
|
|
print(" templates). For drive_files, projects or notifications it is not —")
|
|
print(" assign the tenant, or delete the row, before step 5.")
|
|
FINDINGS.append(
|
|
"null-tenant rows in: " + ", ".join(f"{t} ({n})" for t, n in offenders)
|
|
)
|
|
|
|
|
|
def check_connection_role(conn) -> None:
|
|
"""
|
|
RLS does not apply to superusers, and it does not apply to a role with
|
|
BYPASSRLS. Deploying the policy under such a role silently does nothing —
|
|
every test passes, every query still returns everything.
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
_rule("5. Can the application's role bypass RLS?")
|
|
name, is_super, bypass = conn.execute(
|
|
text(
|
|
"SELECT current_user, "
|
|
" (SELECT rolsuper FROM pg_roles WHERE rolname = current_user), "
|
|
" (SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user)"
|
|
)
|
|
).first()
|
|
print(f" connected as: {name} (superuser={is_super}, bypassrls={bypass})")
|
|
if is_super or bypass:
|
|
print("\n This role IGNORES row-level security. If the application")
|
|
print(" connects as this role, enabling RLS changes nothing at all.")
|
|
print(" Create the unprivileged role first:")
|
|
print(" python scripts/create_app_role.py --role docqube_app --password ...")
|
|
FINDINGS.append(f"connection role {name} bypasses RLS")
|
|
else:
|
|
print(" No — RLS will apply to this connection.")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--database-url",
|
|
help="Override the URL from settings. Use to point at production "
|
|
"without changing APP_ENV.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
url = args.database_url
|
|
if not url:
|
|
from app.core.settings import settings
|
|
|
|
url = settings.DATABASE_URL
|
|
|
|
shown = url.split("@")[-1] if "@" in url else url
|
|
print(f"B1 preflight — read-only checks against {shown}")
|
|
|
|
engine = create_engine(url)
|
|
try:
|
|
with engine.connect() as conn:
|
|
if check_migration_applied(conn):
|
|
check_fallback_dependents(conn)
|
|
check_superadmin_population(conn)
|
|
check_null_tenant_rows(conn)
|
|
check_connection_role(conn)
|
|
finally:
|
|
engine.dispose()
|
|
|
|
print("\n" + "=" * 60)
|
|
if not FINDINGS:
|
|
print("Nothing needs a decision. Steps 3-5 are safe to sequence.")
|
|
return 0
|
|
print(f"{len(FINDINGS)} thing(s) need a decision before proceeding:")
|
|
for f in FINDINGS:
|
|
print(f" - {f}")
|
|
print("\nNone of these is automatically fatal. Each is a data fix or an")
|
|
print("informed acceptance — but make it knowingly, not by deploying past it.")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|