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())