ledgerlens / apps /api /scripts /grant_app_role.py
Abdr007's picture
LedgerLens — deployed tree
6741fc6
Raw
History Blame Contribute Delete
9.39 kB
"""Split the database credential in two, and prove the split holds.
AUDIT.md §4c, Residual 1: the service connected as the table owner, because it
creates its own schema on boot. Ownership is what permits
`ALTER TABLE audit_log DISABLE TRIGGER` — so the credential the service carries
could switch off the append-only guarantee and then delete history, in two
statements. A trigger stops `DELETE`; it does not stop being turned off.
The convenience and the weakness were the same privilege, so they are separated
here rather than traded away:
* an **owning** role runs the DDL — this script, run by a human, occasionally;
* an **application** role gets `SELECT`/`INSERT`/`UPDATE` and nothing else, which
is the complete set the request path uses. No `DELETE`, no `TRUNCATE`, no
ownership, no `ALTER`.
Run with the owner's URL in `DATABASE_URL`:
DATABASE_URL='<owner url>' python scripts/grant_app_role.py --migrate
It creates the role if missing, applies the schema, grants exactly those rights,
and then **connects as the new role and tries to disable the trigger**, because a
privilege boundary nobody tested is a privilege boundary nobody has. The
resulting connection URL is written to `apps/api/.env.app` (gitignored) rather
than printed, so it does not land in a terminal scrollback or a chat transcript.
"""
from __future__ import annotations
import argparse
import asyncio
import secrets
import string
import sys
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit
# Allow `python scripts/grant_app_role.py` from the apps/api directory.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
from app.core.bootstrap import init_schema
from app.core.db import dispose_engine, init_engine
from app.core.logging import configure_logging
from app.core.settings import Settings, get_settings
APP_ROLE = "ledgerlens_app"
OUTPUT = Path(__file__).resolve().parent.parent / ".env.app"
#: Exactly what the request path uses. `DELETE` is deliberately absent: nothing in
#: `app/` deletes a row, and withholding it means even a SQL-injection reaching
#: this connection cannot remove a document, let alone an audit record.
GRANTS = (
f"GRANT CONNECT ON DATABASE {{database}} TO {APP_ROLE}",
f"GRANT USAGE ON SCHEMA public TO {APP_ROLE}",
f"GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO {APP_ROLE}",
f"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO {APP_ROLE}",
)
#: Anything created later inherits the same ceiling, so a new table does not
#: silently arrive with no grants and take the service down on its first insert.
DEFAULTS = (
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public "
f"GRANT SELECT, INSERT, UPDATE ON TABLES TO {APP_ROLE}",
f"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO {APP_ROLE}",
)
def _app_url(owner_url: str, password: str) -> str:
"""The owner's URL with the credential swapped for the application role's."""
parts = urlsplit(owner_url)
host = parts.hostname or ""
port = f":{parts.port}" if parts.port else ""
netloc = f"{APP_ROLE}:{quote(password, safe='')}@{host}{port}"
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
def _new_password() -> str:
"""A password that is safe to inline into DDL, by construction.
`CREATE ROLE` and `ALTER ROLE` are utility statements: PostgreSQL will not
accept a bind parameter for the password, so it has to be interpolated. Rather
than escape a value that might contain a quote, the value is drawn from an
alphabet that cannot — 62 symbols over 40 characters is ~238 bits, so nothing
is given up for the guarantee.
"""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(40))
async def _ensure_role(connection: AsyncConnection, password: str) -> bool:
# Belt and braces: the interpolation below is only safe because of this.
if not password.isalnum():
msg = "the generated password must be alphanumeric before it is inlined into DDL"
raise ValueError(msg)
exists = await connection.scalar(
text("SELECT 1 FROM pg_roles WHERE rolname = :role"), {"role": APP_ROLE}
)
# Neither the role name nor the password can be bound here. The role name is a
# module constant and the password is alphanumeric by construction, so there is
# no caller-supplied text in either statement.
verb = "ALTER" if exists else "CREATE"
suffix = "WITH LOGIN PASSWORD" if exists else "LOGIN PASSWORD"
await connection.execute(text(f"{verb} ROLE {APP_ROLE} {suffix} '{password}'"))
return not exists
async def run(*, migrate: bool) -> int:
configure_logging("WARNING")
settings = get_settings()
owner_url = settings.database_url
engine = init_engine(settings)
password = _new_password()
try:
if migrate:
await init_schema(engine, manage=True)
print(" schema applied as the owning role")
async with engine.begin() as connection:
database = await connection.scalar(text("SELECT current_database()"))
owner = await connection.scalar(text("SELECT current_user"))
print(f" owner {owner} on {database}")
created = await _ensure_role(connection, password)
print(f" role {APP_ROLE} {'created' if created else 'password rotated'}")
for statement in GRANTS:
await connection.execute(text(statement.format(database=database)))
for statement in DEFAULTS:
await connection.execute(text(statement))
print(f" granted SELECT, INSERT, UPDATE — and nothing else — to {APP_ROLE}")
finally:
await dispose_engine()
app_url = _app_url(owner_url, password)
verdict = await _prove_the_boundary(app_url)
OUTPUT.write_text(
"# Least-privilege application credential, written by scripts/grant_app_role.py.\n"
"# Gitignored. Set this as DATABASE_URL on the Space, with DB_MANAGE_SCHEMA=false.\n"
f"DATABASE_URL={app_url}\n",
encoding="utf-8",
)
print(f" wrote {OUTPUT.relative_to(OUTPUT.parents[2])} (gitignored, not printed)")
return verdict
def _why(exc: BaseException) -> str:
"""The database's own reason, not SQLAlchemy's wrapper around it.
`str()` on a wrapped driver error leads with the exception class and the
dialect path, so the first 60 characters are all scaffolding and the actual
words `permission denied` never appear. This check exists to be read, and a
refusal nobody can read is indistinguishable from a refusal that did not happen.
"""
original = getattr(exc, "orig", None) or exc
text_form = str(original).strip()
return text_form.splitlines()[0][:80] if text_form else type(original).__name__
async def _prove_the_boundary(app_url: str) -> int:
"""Connect as the application role and try to defeat the guarantee.
Three attempts, each of which the old credential could do and the new one must
not: turn the trigger off, delete an audit record, and delete a document (whose
cascade reaches the audit log).
"""
settings = Settings(database_url=app_url, db_manage_schema=False)
# `dispose_engine` above cleared the module-level engine, so this builds a
# fresh one bound to the least-privilege credential.
engine = init_engine(settings)
attempts = (
("disable the append-only trigger", "ALTER TABLE audit_log DISABLE TRIGGER ALL"),
(
"delete an audit record",
"DELETE FROM audit_log WHERE id = (SELECT min(id) FROM audit_log)",
),
(
"delete a document",
"DELETE FROM documents WHERE id = (SELECT id FROM documents LIMIT 1)",
),
)
print(f"\n proving the boundary as {APP_ROLE}:")
failures = 0
try:
for label, statement in attempts:
try:
async with engine.begin() as connection:
await connection.execute(text(statement))
print(f" FAIL {label:32} PERMITTED — the boundary does not hold")
failures += 1
except Exception as exc:
print(f" PASS {label:32} refused: {_why(exc)}")
# It must still be able to do its actual job.
async with engine.connect() as connection:
documents = await connection.scalar(text("SELECT count(*) FROM documents"))
print(f" PASS {'can still read the ledger':32} {documents} documents")
finally:
await dispose_engine()
if failures:
print(f"\n {failures} of {len(attempts)} boundaries did not hold\n")
return 1
print("\n the application credential can read and write, and cannot rewrite history\n")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--migrate",
action="store_true",
help="apply the schema as the owner before granting (needed on a fresh database)",
)
args = parser.parse_args(argv)
return asyncio.run(run(migrate=args.migrate))
if __name__ == "__main__":
sys.exit(main())