158 lines
5.6 KiB
Python
158 lines
5.6 KiB
Python
"""
|
|
Read-only readiness report for retiring `users.role_id`.
|
|
|
|
**This script changes nothing.** It reports; the decisions are somebody's to make
|
|
with the numbers in front of them.
|
|
|
|
Two audiences:
|
|
|
|
1. **Before deploying the multi-role work.** Section A lists grants that stop
|
|
conferring access once `PermissionService` filters on expiry and tenant, as
|
|
`ScopeService` already does. Both were defects; the expectation is zero rows,
|
|
and if it is not zero, those rows are the answer to "who is relying on a bug".
|
|
|
|
2. **Before retiring `users.role_id`** — the one irreversible step, deliberately
|
|
left out of the multi-role release. Section B reports how far the data is from
|
|
being able to lose the column: every user whose primary role has no equivalent
|
|
tenant-wide `user_roles` row would lose that role the moment the column goes.
|
|
|
|
Run it against a copy of production first, because that is the environment whose
|
|
answers matter and none of this has ever met real data:
|
|
|
|
APP_ENV=production python scripts/multi_role_preflight.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from sqlalchemy import text # noqa: E402
|
|
|
|
from app.db.database import engine # noqa: E402
|
|
|
|
|
|
SECTION_A = {
|
|
"expired grants that used to confer access through the coarse gate": """
|
|
SELECT ur.id, ur.user_id, ur.group_id, r.name AS role_name,
|
|
ur.expires_at, t.name AS tenant_name
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
LEFT JOIN tenants t ON t.id = ur.tenant_id
|
|
WHERE ur.expires_at IS NOT NULL AND ur.expires_at <= now()
|
|
ORDER BY ur.expires_at DESC
|
|
""",
|
|
"grants naming another tenant's role": """
|
|
SELECT ur.id, ur.user_id, ur.group_id, r.name AS role_name,
|
|
ur.tenant_id AS grant_tenant, r.tenant_id AS role_tenant
|
|
FROM user_roles ur
|
|
JOIN roles r ON r.id = ur.role_id
|
|
WHERE r.tenant_id IS NOT NULL
|
|
AND ur.tenant_id IS NOT NULL
|
|
AND r.tenant_id <> ur.tenant_id
|
|
""",
|
|
"grants naming neither a user nor a group": """
|
|
SELECT ur.id, ur.role_id
|
|
FROM user_roles ur
|
|
WHERE ur.user_id IS NULL AND ur.group_id IS NULL
|
|
""",
|
|
"grants whose user no longer exists": """
|
|
SELECT ur.id, ur.user_id
|
|
FROM user_roles ur
|
|
LEFT JOIN users u ON u.id = ur.user_id
|
|
WHERE ur.user_id IS NOT NULL AND u.id IS NULL
|
|
""",
|
|
}
|
|
|
|
SECTION_B = {
|
|
"users whose primary role has no equivalent tenant-wide grant": """
|
|
SELECT u.id, u.email, r.name AS primary_role, t.name AS tenant_name
|
|
FROM users u
|
|
JOIN roles r ON r.id = u.role_id
|
|
LEFT JOIN tenants t ON t.id = u.tenant_id
|
|
WHERE u.role_id IS NOT NULL
|
|
AND u.is_deleted = false
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM user_roles ur
|
|
WHERE ur.user_id = u.id
|
|
AND ur.role_id = u.role_id
|
|
AND ur.org_unit_id IS NULL
|
|
AND (ur.expires_at IS NULL OR ur.expires_at > now())
|
|
)
|
|
ORDER BY t.name, u.email
|
|
""",
|
|
"users holding more than one role today": """
|
|
SELECT u.id, u.email, count(ur.id) AS extra_grants
|
|
FROM users u
|
|
JOIN user_roles ur ON ur.user_id = u.id
|
|
WHERE u.is_deleted = false
|
|
AND (ur.expires_at IS NULL OR ur.expires_at > now())
|
|
AND (u.role_id IS NULL OR ur.role_id <> u.role_id)
|
|
GROUP BY u.id, u.email
|
|
ORDER BY extra_grants DESC
|
|
""",
|
|
"users with no role at all, by either mechanism": """
|
|
SELECT u.id, u.email, t.name AS tenant_name
|
|
FROM users u
|
|
LEFT JOIN tenants t ON t.id = u.tenant_id
|
|
WHERE u.is_deleted = false
|
|
AND u.role_id IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id)
|
|
ORDER BY t.name, u.email
|
|
""",
|
|
}
|
|
|
|
|
|
def _run(title: str, queries: dict, expect_zero: bool) -> int:
|
|
print(f"\n{'=' * 78}\n{title}\n{'=' * 78}")
|
|
total = 0
|
|
with engine.connect() as conn:
|
|
for label, sql in queries.items():
|
|
rows = conn.execute(text(sql)).mappings().all()
|
|
total += len(rows)
|
|
marker = "OK " if (not rows or not expect_zero) else "!! "
|
|
print(f"\n{marker}{label}: {len(rows)}")
|
|
for row in rows[:20]:
|
|
print(" " + ", ".join(f"{k}={v!r}" for k, v in row.items()))
|
|
if len(rows) > 20:
|
|
print(f" ... and {len(rows) - 20} more")
|
|
return total
|
|
|
|
|
|
def main() -> int:
|
|
print("Multi-role preflight — read-only. Nothing below writes to the database.")
|
|
|
|
a = _run(
|
|
"A. Grants that stop conferring access once the resolver is corrected\n"
|
|
" Expect zero. Any row here is somebody relying on a defect.",
|
|
SECTION_A,
|
|
expect_zero=True,
|
|
)
|
|
|
|
_run(
|
|
"B. Distance from being able to drop users.role_id\n"
|
|
" Informational. The first list is the backfill that step would need.",
|
|
SECTION_B,
|
|
expect_zero=False,
|
|
)
|
|
|
|
print(f"\n{'=' * 78}")
|
|
if a:
|
|
print(
|
|
f"!! {a} row(s) in section A. Read them before deploying: each one is an\n"
|
|
" access that exists today and will not exist afterwards."
|
|
)
|
|
else:
|
|
print("OK Section A is clean — the resolver fix takes nothing away.")
|
|
print(
|
|
" Section B is not a blocker for the multi-role release. `users.role_id`\n"
|
|
" stays as the primary role; retiring it is a separate, deliberate step."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|