Files
KF-OnlineJudge/scripts/release/database.py
T

179 lines
7.5 KiB
Python

"""Explicit DB identities only; all application schema changes belong to Alembic."""
import asyncio
import os
import re
from pathlib import Path
from urllib.parse import urlsplit
from .configuration import PROFILES, connections, private
from .policy import DeliveryError, fields, read_json
GROUPS = (
"kf_oj_platform",
"kf_oj_coordinator",
"kf_oj_problem_import",
"kf_oj_identity_operator",
"kf_oj_identity_authority_owner",
)
MEMBERSHIP = {
"kf_platform": "kf_oj_platform",
"kf_coordinator": "kf_oj_coordinator",
"kf_problem_import": "kf_oj_problem_import",
"kf_identity_operator": "kf_oj_identity_operator",
"kf_migrator": "kf_oj_identity_authority_owner",
}
DATABASE = "kf_onlinejudge"
SETTINGS = {
"statement_timeout": "5000",
"lock_timeout": "1000",
"idle_in_transaction_session_timeout": "5000",
"application_name": "kf-delivery-identities",
}
def operator_url(path: Path) -> str:
private(path)
value = fields(read_json(path, 4096), {"schema_version", "url"}, "DB operator connection")
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
raise DeliveryError("unsupported DB operator input")
raw = value["url"]
if not isinstance(raw, str) or len(raw) > 1024:
raise DeliveryError("invalid DB operator connection")
parts = urlsplit(raw)
if (
parts.scheme != "postgresql"
or parts.hostname != "127.0.0.1"
or parts.port != 5432
or parts.username != "postgres"
or parts.path != "/postgres"
or parts.query
or parts.fragment
or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "")
):
raise DeliveryError("explicit local DB operator identity required")
return raw
async def _provision(operator: Path, endpoint_path: Path) -> dict:
import asyncpg
endpoints = connections(endpoint_path)
db = await asyncpg.connect(
operator_url(operator),
timeout=3,
command_timeout=5,
server_settings={**SETTINGS, "log_min_error_statement": "panic"},
)
try:
if not await db.fetchval("SELECT rolsuper FROM pg_roles WHERE rolname=current_user"):
raise DeliveryError("explicit local DB identity operator required")
for name, expected in (
("log_statement", "none"),
("log_min_duration_statement", "-1"),
("log_min_duration_sample", "-1"),
("log_transaction_sample_rate", "0"),
):
if await db.fetchval("SHOW " + name) != expected:
raise DeliveryError("DB statement logging incompatible with secret provisioning")
async with db.transaction():
for name in (*GROUPS, *PROFILES.values()):
login = name in PROFILES.values()
row = await db.fetchrow(
"SELECT rolcanlogin,rolsuper,rolcreatedb,rolcreaterole,"
"rolreplication,rolbypassrls,"
"rolconnlimit FROM pg_roles WHERE rolname=$1",
name,
)
wanted = (login, False, False, False, False, False, 8 if login else -1)
if row is None:
# Names are fixed public constants. The password alphabet
# is validated before any utility SQL is constructed.
statement = (
"CREATE ROLE "
+ name
+ (" LOGIN" if login else " NOLOGIN")
+ " NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION "
"NOBYPASSRLS INHERIT"
)
if login:
statement += " CONNECTION LIMIT 8"
key = next(key for key, role in PROFILES.items() if role == name)
password = urlsplit(endpoints["database"][key]).password
statement += " PASSWORD '" + password + "'"
await db.execute(statement)
elif tuple(row) != wanted:
raise DeliveryError("existing DB authority differs; refused overwrite")
memberships = [
tuple(row)
for row in await db.fetch(
"SELECT r.rolname,m.admin_option,m.inherit_option,m.set_option "
"FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.roleid "
"JOIN pg_roles u ON u.oid=m.member WHERE u.rolname=$1",
name,
)
]
expected = (
[]
if not login
else [(MEMBERSHIP[name], False, name != "kf_migrator", name == "kf_migrator")]
)
if memberships and memberships != expected:
raise DeliveryError("existing DB role membership differs")
for login, group in MEMBERSHIP.items():
inherit = "FALSE" if login == "kf_migrator" else "TRUE"
set_role = "TRUE" if login == "kf_migrator" else "FALSE"
for option, value in (("ADMIN", "FALSE"), ("INHERIT", inherit), ("SET", set_role)):
# Each command uses the documented single membership-option
# form; all three changes are in the same transaction.
await db.execute(f"GRANT {group} TO {login} WITH {option} {value}")
existing = await db.fetchval(
"SELECT r.rolname FROM pg_database d JOIN pg_roles r ON r.oid=d.datdba "
"WHERE d.datname=$1",
DATABASE,
)
if existing is None:
await db.execute(
"CREATE DATABASE kf_onlinejudge OWNER kf_migrator "
"TEMPLATE template0 ENCODING 'UTF8'"
)
elif existing != "kf_migrator":
raise DeliveryError("existing application database owner differs")
finally:
await db.close(timeout=3)
verified = []
for name, raw in endpoints["database"].items():
session = await asyncpg.connect(raw, timeout=3, command_timeout=5, server_settings=SETTINGS)
try:
row = await session.fetchrow("SELECT current_user,current_database()")
if tuple(row) != (PROFILES[name], DATABASE):
raise DeliveryError("DB identity readback differs")
verified.append(name)
finally:
await session.close(timeout=3)
return {
"database_identities_provisioned": sorted(verified),
"application_schema_migrated": False,
"application_accounts_created": False,
"secrets_reset": False,
}
def provision(operator: Path, endpoint_path: Path) -> dict:
"""No table DDL, no secret reset and no automatic application initialization.
Pre-created NOLOGIN groups allow the unchanged Backend Alembic migrations
to run under a separate non-superuser database owner. Only this migration
login can SET the authority-function owner; ordinary logins inherit their
own one group and cannot grant it. Unexpected existing authority is refused.
"""
if os.geteuid() != 0:
raise DeliveryError("DB provisioning requires the root operator")
import asyncpg
try:
return asyncio.run(asyncio.wait_for(_provision(operator, endpoint_path), timeout=120))
except (asyncpg.PostgresError, asyncpg.InterfaceError, OSError, TimeoutError) as error:
# A server error may include utility SQL; never format it or its query.
raise DeliveryError("DB identity provisioning/readback refused") from error