feat(identity): persist administrator authority with scoped database roles
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""Actual temporary PG migration and authority storage; API/CLI acceptance is separate."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
from conftest import connect, migrate
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from test_database_foundation import fixture_rows
|
||||
|
||||
from kf_oj.modules.identity.public import AuthenticationFailed, Principal
|
||||
from kf_oj.modules.identity.service import IdentityService, token_digest
|
||||
|
||||
|
||||
class RollBackAuthorityChange(Exception):
|
||||
"""Abort the test transaction before any authority change commits."""
|
||||
|
||||
|
||||
async def submission_snapshot(connection):
|
||||
# Synthetic queued metadata only; this does not claim real program execution.
|
||||
return {
|
||||
table: list(
|
||||
await connection.fetch(
|
||||
f"SELECT to_jsonb(t)::text FROM {table} t ORDER BY to_jsonb(t)::text"
|
||||
)
|
||||
)
|
||||
for table in (
|
||||
"artifacts",
|
||||
"problems",
|
||||
"problem_versions",
|
||||
"problem_cases",
|
||||
"submissions",
|
||||
"evaluations",
|
||||
"outbox",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async def account_and_session(connection):
|
||||
account = uuid4()
|
||||
token, csrf = uuid4().hex * 2, uuid4().hex * 2
|
||||
await connection.execute(
|
||||
"INSERT INTO accounts(id,username,password_hash) VALUES($1,'storage_user',$2)",
|
||||
account,
|
||||
"synthetic-irreversible-placeholder-hash-" * 2,
|
||||
)
|
||||
now = await connection.fetchval("SELECT clock_timestamp()")
|
||||
await connection.execute(
|
||||
"INSERT INTO sessions(token_hash,csrf_hash,account_id,expires_at) VALUES($1,$2,$3,$4)",
|
||||
token_digest(token),
|
||||
token_digest(csrf),
|
||||
account,
|
||||
now + timedelta(hours=1),
|
||||
)
|
||||
return account, token, csrf
|
||||
|
||||
|
||||
def test_upgrade_preserves_ordinary_account_and_current_session(postgres_url):
|
||||
migrate(postgres_url, "downgrade", "0004_performance_watermark")
|
||||
|
||||
async def seed():
|
||||
connection = await connect(postgres_url)
|
||||
try:
|
||||
account, token, csrf = await account_and_session(connection)
|
||||
await fixture_rows(connection)
|
||||
return account, token, csrf, await submission_snapshot(connection)
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
account, token, csrf, before = asyncio.run(seed())
|
||||
migrate(postgres_url, "upgrade", "head")
|
||||
migrate(postgres_url, "check")
|
||||
|
||||
async def inspect():
|
||||
connection = await connect(postgres_url)
|
||||
engine = create_async_engine(postgres_url)
|
||||
try:
|
||||
row = await connection.fetchrow("SELECT * FROM accounts WHERE id=$1", account)
|
||||
assert row["enabled"] and not row["is_administrator"]
|
||||
assert row["authorization_version"] == 1
|
||||
assert await submission_snapshot(connection) == before
|
||||
service = IdentityService(async_sessionmaker(engine, expire_on_commit=False))
|
||||
assert await service.authenticate(token, csrf_token=csrf) == Principal(
|
||||
account, "storage_user"
|
||||
)
|
||||
finally:
|
||||
await connection.close()
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(inspect())
|
||||
|
||||
|
||||
def test_authority_columns_and_import_role_are_not_runtime_write_permissions(postgres_url):
|
||||
async def inspect():
|
||||
connection = await connect(postgres_url)
|
||||
try:
|
||||
for role in ("kf_oj_platform", "kf_oj_coordinator", "kf_oj_problem_import"):
|
||||
for column in ("is_administrator", "authorization_version"):
|
||||
assert not await connection.fetchval(
|
||||
"SELECT has_column_privilege($1,'accounts',$2,'UPDATE')", role, column
|
||||
)
|
||||
assert not await connection.fetchval(
|
||||
"SELECT has_column_privilege($1,'accounts',$2,'INSERT')", role, column
|
||||
)
|
||||
for table in (
|
||||
"evaluations",
|
||||
"judge_attempts",
|
||||
"case_results",
|
||||
"evaluation_performance",
|
||||
):
|
||||
for privilege in ("SELECT", "INSERT", "UPDATE", "DELETE"):
|
||||
assert not await connection.fetchval(
|
||||
"SELECT has_table_privilege('kf_oj_problem_import',$1,$2)", table, privilege
|
||||
)
|
||||
assert await connection.fetchval(
|
||||
"SELECT has_column_privilege('kf_oj_platform','accounts','enabled','UPDATE')"
|
||||
)
|
||||
assert await connection.fetchval(
|
||||
"SELECT has_column_privilege('kf_oj_identity_operator',"
|
||||
"'accounts','is_administrator','UPDATE')"
|
||||
)
|
||||
assert not await connection.fetchval(
|
||||
"SELECT has_column_privilege('kf_oj_identity_operator',"
|
||||
"'accounts','authorization_version','UPDATE')"
|
||||
)
|
||||
assert not await connection.fetchval(
|
||||
"SELECT pg_has_role('kf_oj_platform','kf_oj_identity_operator','USAGE')"
|
||||
)
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
asyncio.run(inspect())
|
||||
|
||||
|
||||
def test_authority_change_derives_version_and_revokes_old_session_atomically(postgres_url):
|
||||
async def inspect():
|
||||
connection = await connect(postgres_url)
|
||||
engine = create_async_engine(postgres_url)
|
||||
try:
|
||||
account, token, csrf = await account_and_session(connection)
|
||||
service = IdentityService(async_sessionmaker(engine, expire_on_commit=False))
|
||||
await service.authenticate(token, csrf_token=csrf)
|
||||
async with connection.transaction():
|
||||
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
|
||||
await connection.execute(
|
||||
"UPDATE accounts SET is_administrator=true WHERE id=$1", account
|
||||
)
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"SELECT authorization_version FROM accounts WHERE id=$1", account
|
||||
)
|
||||
== 2
|
||||
)
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
await service.authenticate(token, csrf_token=csrf)
|
||||
async with connection.transaction():
|
||||
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
|
||||
await connection.execute(
|
||||
"UPDATE accounts SET is_administrator=true WHERE id=$1", account
|
||||
)
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"SELECT authorization_version FROM accounts WHERE id=$1", account
|
||||
)
|
||||
== 2
|
||||
)
|
||||
fresh_token, fresh_csrf = uuid4().hex * 2, uuid4().hex * 2
|
||||
await connection.execute(
|
||||
"INSERT INTO sessions(token_hash,csrf_hash,account_id,expires_at) "
|
||||
"VALUES($1,$2,$3,clock_timestamp()+interval '1 hour')",
|
||||
token_digest(fresh_token),
|
||||
token_digest(fresh_csrf),
|
||||
account,
|
||||
)
|
||||
with pytest.raises(RollBackAuthorityChange):
|
||||
async with connection.transaction():
|
||||
await connection.execute(
|
||||
"UPDATE accounts SET is_administrator=false WHERE id=$1", account
|
||||
)
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"SELECT authorization_version FROM accounts WHERE id=$1", account
|
||||
)
|
||||
== 3
|
||||
)
|
||||
raise RollBackAuthorityChange
|
||||
assert await connection.fetchval(
|
||||
"SELECT is_administrator FROM accounts WHERE id=$1", account
|
||||
)
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"SELECT authorization_version FROM accounts WHERE id=$1", account
|
||||
)
|
||||
== 2
|
||||
)
|
||||
await service.authenticate(fresh_token, csrf_token=fresh_csrf)
|
||||
async with connection.transaction():
|
||||
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
|
||||
await connection.execute(
|
||||
"UPDATE accounts SET is_administrator=false WHERE id=$1", account
|
||||
)
|
||||
assert (
|
||||
await connection.fetchval(
|
||||
"SELECT authorization_version FROM accounts WHERE id=$1", account
|
||||
)
|
||||
== 3
|
||||
)
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
await service.authenticate(fresh_token, csrf_token=fresh_csrf)
|
||||
finally:
|
||||
await connection.close()
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(inspect())
|
||||
|
||||
|
||||
def test_authority_history_cannot_be_erased_by_downgrade(postgres_url):
|
||||
async def change():
|
||||
connection = await connect(postgres_url)
|
||||
try:
|
||||
account, _, _ = await account_and_session(connection)
|
||||
await connection.execute("UPDATE accounts SET enabled=false WHERE id=$1", account)
|
||||
with pytest.raises(asyncpg.CheckViolationError):
|
||||
await connection.execute(
|
||||
"UPDATE accounts SET authorization_version=1 WHERE id=$1", account
|
||||
)
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
asyncio.run(change())
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
migrate(postgres_url, "downgrade", "0004_performance_watermark")
|
||||
@@ -12,9 +12,14 @@ def test_migration_roundtrip_and_role_safety(postgres_url):
|
||||
c = await connect(postgres_url)
|
||||
try:
|
||||
tables = set(await c.fetch("SELECT tablename FROM pg_tables WHERE schemaname='public'"))
|
||||
assert len(tables) == 13 # Twelve business tables and Alembic history.
|
||||
assert len(tables) == 14 # Thirteen business tables and Alembic history.
|
||||
roles = await c.fetch("SELECT * FROM pg_roles WHERE rolname LIKE 'kf_oj_%'")
|
||||
assert {row["rolname"] for row in roles} == {"kf_oj_platform", "kf_oj_coordinator"}
|
||||
assert {row["rolname"] for row in roles} == {
|
||||
"kf_oj_platform",
|
||||
"kf_oj_coordinator",
|
||||
"kf_oj_identity_operator",
|
||||
"kf_oj_problem_import",
|
||||
}
|
||||
assert all(
|
||||
not any(
|
||||
r[k]
|
||||
|
||||
Reference in New Issue
Block a user