444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""Compare a live database against the models (DB-001).
|
|
|
|
Why this exists
|
|
---------------
|
|
`db.create_all()` creates missing tables and never ALTERs an existing one. A
|
|
column added to a model months ago is therefore simply absent from any
|
|
database that already had the table, and nothing says so: the application
|
|
starts, and the first query touching that column fails at runtime. The audit
|
|
called the accumulated result "les dérives" and could not measure it, because
|
|
measuring it needs the production database.
|
|
|
|
This script measures it. It is **read-only** — it opens a connection, reads
|
|
the catalogue, prints a report and exits. It issues no DDL and no DML, and
|
|
takes no locks beyond what reading `information_schema` takes.
|
|
|
|
It is the prerequisite for everything in the DB wave: `DB-002` asks for an
|
|
initial Alembic migration describing the **real** schema rather than the
|
|
models', and this is what tells you what the real schema is.
|
|
|
|
Usage
|
|
-----
|
|
# Against whatever DATABASE_URL points at
|
|
python app/supporting_scripts/schema_report.py
|
|
|
|
# Against a restored copy, which is the safe way to do it first
|
|
python app/supporting_scripts/schema_report.py \
|
|
--url postgresql://user:pass@host:5432/restored_copy
|
|
|
|
# Also look for the seeded admin/password account (SEC-003)
|
|
python app/supporting_scripts/schema_report.py --check-seed-accounts
|
|
|
|
# Find Discord identities that must be reconciled before UNIQUE (SEC-012)
|
|
python app/supporting_scripts/schema_report.py --check-discord-identities
|
|
|
|
Exit codes
|
|
----------
|
|
0 the live schema matches the models
|
|
1 drift or requested data risk found — the report says what
|
|
2 could not connect or read the catalogue
|
|
|
|
Reading the output
|
|
------------------
|
|
Findings are grouped by what they cost you:
|
|
|
|
BLOCKING the application will fail at runtime — a table or column the
|
|
models use and the database does not have.
|
|
RISK the database has something the models do not describe. Harmless
|
|
to the running application, but an Alembic autogenerate would
|
|
propose to DROP it, which is how a corrective migration deletes
|
|
a column somebody still needed.
|
|
DIFFERENCE type, nullability, default or constraint disagreements. Each one
|
|
needs a human: some are dialect spelling, some are real.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
# Importable as a script from the project root, like the other supporting
|
|
# scripts: `python app/supporting_scripts/schema_report.py`.
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
|
|
from sqlalchemy import create_engine, inspect # noqa: E402
|
|
from sqlalchemy.exc import SQLAlchemyError # noqa: E402
|
|
|
|
BLOCKING = 'BLOCKING'
|
|
RISK = 'RISK'
|
|
DIFFERENCE = 'DIFFERENCE'
|
|
|
|
|
|
class Finding:
|
|
"""One disagreement between the models and the live database."""
|
|
|
|
def __init__(self, severity, table, detail, consequence=''):
|
|
self.severity = severity
|
|
self.table = table
|
|
self.detail = detail
|
|
self.consequence = consequence
|
|
|
|
def __str__(self):
|
|
line = f' [{self.severity:10}] {self.table}: {self.detail}'
|
|
if self.consequence:
|
|
line += f'\n → {self.consequence}'
|
|
return line
|
|
|
|
def __repr__(self): # pragma: no cover — debugging aid
|
|
return f'<Finding {self.severity} {self.table} {self.detail}>'
|
|
|
|
|
|
def model_metadata():
|
|
"""The schema the models describe.
|
|
|
|
Imports app.models for its side effect: importing the modules is what
|
|
registers every table on the shared metadata.
|
|
"""
|
|
from app.extensions import db
|
|
from app.models import User # noqa: F401 — registers the whole model package
|
|
|
|
return db.metadata
|
|
|
|
|
|
def _type_of(column_type, dialect):
|
|
"""A type as this dialect spells it, so the two sides are comparable.
|
|
|
|
Comparing `String(200)` with `VARCHAR(200)` as strings would report every
|
|
column as different. Compiling both against the same dialect makes the
|
|
comparison mean something.
|
|
"""
|
|
try:
|
|
return column_type.compile(dialect=dialect)
|
|
except Exception: # noqa: BLE001 — an uncompilable type is still reportable
|
|
return str(column_type)
|
|
|
|
|
|
def compare_tables(metadata, inspector):
|
|
"""Tables the models expect against tables the database has."""
|
|
findings = []
|
|
model_tables = set(metadata.tables)
|
|
live_tables = set(inspector.get_table_names())
|
|
|
|
for name in sorted(model_tables - live_tables):
|
|
findings.append(
|
|
Finding(
|
|
BLOCKING,
|
|
name,
|
|
'table is missing from the database',
|
|
'every query against this model fails. create_all() would '
|
|
'create it — which is why the absence can survive unnoticed '
|
|
'on a machine where AUTO_CREATE_TABLES is on.',
|
|
)
|
|
)
|
|
|
|
for name in sorted(live_tables - model_tables):
|
|
findings.append(
|
|
Finding(
|
|
RISK,
|
|
name,
|
|
'table exists in the database and in no model',
|
|
'an Alembic autogenerate would propose to DROP it. Decide '
|
|
'before running one: it may be a leftover, or it may be the '
|
|
'only copy of something.',
|
|
)
|
|
)
|
|
|
|
return findings, sorted(model_tables & live_tables)
|
|
|
|
|
|
def compare_columns(metadata, inspector, table_name, dialect):
|
|
"""Column-by-column, for one table."""
|
|
findings = []
|
|
model_columns = {c.name: c for c in metadata.tables[table_name].columns}
|
|
live_columns = {c['name']: c for c in inspector.get_columns(table_name)}
|
|
|
|
for name in sorted(set(model_columns) - set(live_columns)):
|
|
column = model_columns[name]
|
|
findings.append(
|
|
Finding(
|
|
BLOCKING,
|
|
table_name,
|
|
f'column "{name}" is in the model and not in the database',
|
|
'this is exactly what create_all() cannot fix: it never '
|
|
'ALTERs. Any query selecting or writing this column fails.'
|
|
+ (
|
|
''
|
|
if column.nullable
|
|
else ' The column is NOT NULL, so the '
|
|
'corrective migration needs a default or a backfill.'
|
|
),
|
|
)
|
|
)
|
|
|
|
for name in sorted(set(live_columns) - set(model_columns)):
|
|
findings.append(
|
|
Finding(
|
|
RISK,
|
|
table_name,
|
|
f'column "{name}" is in the database and not in any model',
|
|
'an autogenerated migration would propose to DROP it, taking '
|
|
'its data. Check whether something outside the application '
|
|
'reads it before agreeing.',
|
|
)
|
|
)
|
|
|
|
for name in sorted(set(model_columns) & set(live_columns)):
|
|
model_column, live_column = model_columns[name], live_columns[name]
|
|
|
|
model_type = _type_of(model_column.type, dialect)
|
|
live_type = _type_of(live_column['type'], dialect)
|
|
if model_type != live_type:
|
|
findings.append(
|
|
Finding(
|
|
DIFFERENCE,
|
|
table_name,
|
|
f'column "{name}" type: model says {model_type}, database says {live_type}',
|
|
'a narrower column in the database silently truncates or '
|
|
'rejects; a wider one is usually harmless.',
|
|
)
|
|
)
|
|
|
|
if bool(model_column.nullable) != bool(live_column.get('nullable', True)):
|
|
findings.append(
|
|
Finding(
|
|
DIFFERENCE,
|
|
table_name,
|
|
f'column "{name}" nullability: model says '
|
|
f'{"NULL" if model_column.nullable else "NOT NULL"}, database says '
|
|
f'{"NULL" if live_column.get("nullable", True) else "NOT NULL"}',
|
|
'a NOT NULL the database does not enforce is a constraint '
|
|
'the application only believes it has.',
|
|
)
|
|
)
|
|
|
|
return findings
|
|
|
|
|
|
def compare_constraints(metadata, inspector, table_name):
|
|
"""Unique constraints, indexes and foreign keys.
|
|
|
|
Named constraints are compared by the columns they cover rather than by
|
|
name: the same rule declared under two names is the same rule, and
|
|
reporting it as a difference would bury the ones that matter.
|
|
"""
|
|
findings = []
|
|
table = metadata.tables[table_name]
|
|
|
|
def column_sets(entries, key):
|
|
return {tuple(sorted(entry[key] or [])) for entry in entries}
|
|
|
|
model_unique = {
|
|
tuple(sorted(c.name for c in constraint.columns))
|
|
for constraint in table.constraints
|
|
if constraint.__class__.__name__ == 'UniqueConstraint'
|
|
}
|
|
live_unique = column_sets(inspector.get_unique_constraints(table_name), 'column_names')
|
|
for columns in sorted(model_unique - live_unique):
|
|
findings.append(
|
|
Finding(
|
|
DIFFERENCE,
|
|
table_name,
|
|
f'unique constraint on {list(columns)} is declared and absent from the database',
|
|
'the application believes duplicates are impossible here. '
|
|
'They are not, and two concurrent requests will prove it.',
|
|
)
|
|
)
|
|
|
|
model_fks = {
|
|
tuple(sorted(fk.parent.name for fk in constraint.elements))
|
|
for constraint in table.foreign_key_constraints
|
|
}
|
|
live_fks = column_sets(inspector.get_foreign_keys(table_name), 'constrained_columns')
|
|
for columns in sorted(model_fks - live_fks):
|
|
findings.append(
|
|
Finding(
|
|
DIFFERENCE,
|
|
table_name,
|
|
f'foreign key on {list(columns)} is declared and absent from the database',
|
|
'orphan rows are possible, and ON DELETE behaviour is not '
|
|
'being enforced by the database at all.',
|
|
)
|
|
)
|
|
|
|
model_indexes = {tuple(sorted(c.name for c in index.columns)) for index in table.indexes}
|
|
live_indexes = column_sets(inspector.get_indexes(table_name), 'column_names')
|
|
for columns in sorted(model_indexes - live_indexes):
|
|
findings.append(
|
|
Finding(
|
|
DIFFERENCE,
|
|
table_name,
|
|
f'index on {list(columns)} is declared and absent from the database',
|
|
'correctness is unaffected; the queries that rely on it are '
|
|
'doing sequential scans.',
|
|
)
|
|
)
|
|
|
|
return findings
|
|
|
|
|
|
def collect_findings(engine):
|
|
"""Every disagreement between the models and this database."""
|
|
metadata = model_metadata()
|
|
inspector = inspect(engine)
|
|
|
|
findings, shared_tables = compare_tables(metadata, inspector)
|
|
for table_name in shared_tables:
|
|
findings.extend(compare_columns(metadata, inspector, table_name, engine.dialect))
|
|
findings.extend(compare_constraints(metadata, inspector, table_name))
|
|
return findings
|
|
|
|
|
|
def find_seed_accounts(engine):
|
|
"""Accounts matching the credentials clear_db.py used to seed (SEC-003).
|
|
|
|
The script was removed from the deployment, but it had already been run:
|
|
the audit could not tell whether an `admin` account with the password
|
|
`password` still exists in production, and that question cannot be
|
|
answered from the repository.
|
|
|
|
Returns:
|
|
list[tuple]: (username, role, whether the known password matches).
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
from app.extensions import check_password
|
|
|
|
with engine.connect() as connection:
|
|
rows = connection.execute(
|
|
text('SELECT username, role, password_hash FROM users WHERE username = :name'),
|
|
{'name': 'admin'},
|
|
).fetchall()
|
|
|
|
results = []
|
|
for username, role, password_hash in rows:
|
|
try:
|
|
matches = check_password(password_hash, 'password')
|
|
except Exception: # noqa: BLE001 — an unreadable hash is not a match
|
|
matches = False
|
|
results.append((username, role, matches))
|
|
return results
|
|
|
|
|
|
def find_duplicate_discord_identities(engine):
|
|
"""Discord snowflakes claimed by more than one account (SEC-012).
|
|
|
|
New links are now refused in application code, but existing production
|
|
rows predate that guard. These groups must be reconciled before Alembic
|
|
can add the database-level UNIQUE constraint.
|
|
|
|
Returns:
|
|
list[tuple]: (discord_user_id, comma-separated usernames, count).
|
|
"""
|
|
from sqlalchemy import text
|
|
|
|
with engine.connect() as connection:
|
|
rows = connection.execute(
|
|
text(
|
|
'SELECT discord_user_id, COUNT(*) AS account_count '
|
|
'FROM users '
|
|
"WHERE discord_user_id IS NOT NULL AND discord_user_id <> '' "
|
|
'GROUP BY discord_user_id HAVING COUNT(*) > 1 '
|
|
'ORDER BY discord_user_id'
|
|
)
|
|
).fetchall()
|
|
|
|
duplicates = []
|
|
for discord_user_id, account_count in rows:
|
|
usernames = connection.execute(
|
|
text(
|
|
'SELECT username FROM users '
|
|
'WHERE discord_user_id = :discord_user_id ORDER BY username'
|
|
),
|
|
{'discord_user_id': discord_user_id},
|
|
).scalars()
|
|
duplicates.append((discord_user_id, ', '.join(usernames), account_count))
|
|
return duplicates
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
|
parser.add_argument(
|
|
'--url',
|
|
default=os.getenv('DATABASE_URL'),
|
|
help='Database URL. Defaults to DATABASE_URL. Point it at a restored copy the first time.',
|
|
)
|
|
parser.add_argument(
|
|
'--check-seed-accounts',
|
|
action='store_true',
|
|
help='Also look for the admin/password account seeded by clear_db.py (SEC-003).',
|
|
)
|
|
parser.add_argument(
|
|
'--check-discord-identities',
|
|
action='store_true',
|
|
help='Find duplicate Discord IDs that block the SEC-012 UNIQUE constraint.',
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if not args.url:
|
|
print('No database URL. Pass --url or set DATABASE_URL.', file=sys.stderr)
|
|
return 2
|
|
|
|
from app.app import normalise_database_url
|
|
|
|
try:
|
|
engine = create_engine(normalise_database_url(args.url))
|
|
findings = collect_findings(engine)
|
|
except SQLAlchemyError as exc:
|
|
print(f'Could not read the schema: {exc}', file=sys.stderr)
|
|
return 2
|
|
|
|
print('=' * 78)
|
|
print('Schema report — models vs live database (DB-001)')
|
|
print('=' * 78)
|
|
|
|
if not findings:
|
|
print('\nNo drift. The live schema matches the models.')
|
|
for severity in (BLOCKING, RISK, DIFFERENCE):
|
|
group = [f for f in findings if f.severity == severity]
|
|
if not group:
|
|
continue
|
|
print(f'\n{severity} — {len(group)} finding(s)')
|
|
for finding in group:
|
|
print(finding)
|
|
|
|
if args.check_seed_accounts:
|
|
print('\n' + '=' * 78)
|
|
print('Seeded accounts (SEC-003)')
|
|
print('=' * 78)
|
|
try:
|
|
accounts = find_seed_accounts(engine)
|
|
except SQLAlchemyError as exc:
|
|
print(f'Could not check: {exc}')
|
|
else:
|
|
if not accounts:
|
|
print('No account named "admin".')
|
|
for username, role, matches in accounts:
|
|
verdict = (
|
|
'PASSWORD IS STILL "password" — change it now'
|
|
if matches
|
|
else 'password has been changed'
|
|
)
|
|
print(f' {username} ({role}): {verdict}')
|
|
|
|
duplicate_discord_identities = []
|
|
if args.check_discord_identities:
|
|
print('\n' + '=' * 78)
|
|
print('Duplicate Discord identities (SEC-012)')
|
|
print('=' * 78)
|
|
try:
|
|
duplicate_discord_identities = find_duplicate_discord_identities(engine)
|
|
except SQLAlchemyError as exc:
|
|
print(f'Could not check: {exc}')
|
|
else:
|
|
if not duplicate_discord_identities:
|
|
print('No Discord identity is shared by multiple accounts.')
|
|
for discord_user_id, usernames, account_count in duplicate_discord_identities:
|
|
print(f' {discord_user_id}: {account_count} accounts ({usernames})')
|
|
|
|
blocking = sum(1 for f in findings if f.severity == BLOCKING)
|
|
print(f'\n{len(findings)} finding(s), {blocking} blocking.')
|
|
return 1 if findings or duplicate_discord_identities else 0
|
|
|
|
|
|
if __name__ == '__main__': # pragma: no cover
|
|
sys.exit(main())
|