Spaces:
Running
Running
File size: 9,390 Bytes
6741fc6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """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())
|