feat(identity): protect administrator bootstrap and recovery
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""Real local authority/CLI transactions; run privilege faults in the independent VM."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
from conftest import connect
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from kf_oj.modules.identity.authority import manage_authority
|
||||
|
||||
|
||||
async def seed(connection, *usernames):
|
||||
for name in usernames:
|
||||
await connection.execute(
|
||||
"INSERT INTO accounts(id,username,password_hash) VALUES($1,$2,repeat('synthetic-',6))",
|
||||
uuid4(),
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
async def authority(connection, username, operation):
|
||||
async with connection.transaction():
|
||||
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
|
||||
return await connection.fetchrow(
|
||||
"SELECT * FROM public.kf_manage_account_authority($1,$2)", username, operation
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_bootstrap_repeated_grants_recovery_and_last_administrator(postgres_url):
|
||||
async def run():
|
||||
c = await connect(postgres_url)
|
||||
try:
|
||||
await seed(c, "first_admin", "second_admin")
|
||||
assert await c.fetchval("SELECT count(*) FROM accounts WHERE is_administrator") == 0
|
||||
with pytest.raises(asyncpg.CheckViolationError):
|
||||
await authority(c, "first_admin", "grant")
|
||||
first = await authority(c, "first_admin", "bootstrap")
|
||||
assert first["changed"] and first["authorization_version"] == 2
|
||||
assert not (await authority(c, "first_admin", "bootstrap"))["changed"]
|
||||
assert not (await authority(c, "first_admin", "grant"))["changed"]
|
||||
with pytest.raises(asyncpg.CheckViolationError):
|
||||
await authority(c, "second_admin", "bootstrap")
|
||||
for operation in ("disable", "revoke"):
|
||||
with pytest.raises(asyncpg.CheckViolationError):
|
||||
await authority(c, "first_admin", operation)
|
||||
await authority(c, "second_admin", "grant")
|
||||
disabled = await authority(c, "first_admin", "disable")
|
||||
assert not disabled["enabled"] and disabled["is_administrator"]
|
||||
assert disabled["authorization_version"] == 3
|
||||
assert not (await authority(c, "first_admin", "disable"))["changed"]
|
||||
restored = await authority(c, "first_admin", "enable")
|
||||
assert restored["enabled"] and restored["authorization_version"] == 4
|
||||
revoked = await authority(c, "first_admin", "revoke")
|
||||
assert not revoked["is_administrator"] and revoked["authorization_version"] == 5
|
||||
assert not (await authority(c, "first_admin", "revoke"))["changed"]
|
||||
await authority(c, "first_admin", "grant")
|
||||
with pytest.raises(asyncpg.NoDataFoundError):
|
||||
await authority(c, "absent_user", "grant")
|
||||
for role in ("kf_oj_platform", "kf_oj_coordinator", "kf_oj_problem_import"):
|
||||
assert not await c.fetchval(
|
||||
"SELECT has_function_privilege($1,"
|
||||
"'kf_manage_account_authority(text,text)','EXECUTE')",
|
||||
role,
|
||||
)
|
||||
for column in ("password_hash", "is_administrator", "enabled"):
|
||||
assert not await c.fetchval(
|
||||
"SELECT has_column_privilege('kf_oj_identity_operator','accounts',$1,'SELECT')",
|
||||
column,
|
||||
)
|
||||
async with c.transaction():
|
||||
await c.execute("SET LOCAL ROLE kf_oj_identity_operator")
|
||||
for sql in (
|
||||
"SELECT password_hash FROM accounts",
|
||||
"UPDATE accounts SET is_administrator=false",
|
||||
"SELECT token_hash FROM sessions",
|
||||
):
|
||||
with pytest.raises(asyncpg.InsufficientPrivilegeError):
|
||||
async with c.transaction():
|
||||
await c.execute(sql)
|
||||
finally:
|
||||
await c.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("isolation", ("read_committed", "repeatable_read", "serializable"))
|
||||
def test_concurrent_different_account_revocations_preserve_one_admin(postgres_url, isolation):
|
||||
async def serialized_run():
|
||||
c, other = await connect(postgres_url), await connect(postgres_url)
|
||||
first_tx = second_tx = pending = None
|
||||
try:
|
||||
await seed(c, "first_admin", "second_admin")
|
||||
await authority(c, "first_admin", "bootstrap")
|
||||
await authority(c, "second_admin", "grant")
|
||||
first_tx, second_tx = (
|
||||
c.transaction(isolation=isolation),
|
||||
other.transaction(isolation=isolation),
|
||||
)
|
||||
await first_tx.start()
|
||||
await second_tx.start()
|
||||
assert (
|
||||
await other.fetchval(
|
||||
"SELECT count(*) FROM accounts WHERE is_administrator AND enabled"
|
||||
)
|
||||
== 2
|
||||
)
|
||||
await authority(c, "first_admin", "revoke")
|
||||
pending = asyncio.create_task(authority(other, "second_admin", "revoke"))
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if await c.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM pg_locks WHERE pid=$1 AND NOT granted)",
|
||||
other.get_server_pid(),
|
||||
):
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
raise AssertionError("second authority operation never blocked on PG lock")
|
||||
assert not pending.done()
|
||||
await first_tx.commit()
|
||||
first_tx = None
|
||||
with pytest.raises((asyncpg.CheckViolationError, asyncpg.SerializationError)):
|
||||
await asyncio.wait_for(pending, 5)
|
||||
await second_tx.rollback()
|
||||
second_tx = None
|
||||
assert (
|
||||
await c.fetchval("SELECT count(*) FROM accounts WHERE is_administrator AND enabled")
|
||||
== 1
|
||||
)
|
||||
assert await c.fetchval(
|
||||
"SELECT is_administrator FROM accounts WHERE username='second_admin'"
|
||||
)
|
||||
finally:
|
||||
if pending is not None and not pending.done():
|
||||
pending.cancel()
|
||||
await asyncio.gather(pending, return_exceptions=True)
|
||||
if first_tx is not None:
|
||||
await first_tx.rollback()
|
||||
if second_tx is not None:
|
||||
await second_tx.rollback()
|
||||
await c.close()
|
||||
await other.close()
|
||||
|
||||
asyncio.run(serialized_run())
|
||||
|
||||
|
||||
def test_real_cli_uses_dedicated_login_and_never_prints_database_secret(postgres_url):
|
||||
role = "authority_test_" + uuid4().hex
|
||||
|
||||
async def setup():
|
||||
c = await connect(postgres_url)
|
||||
try:
|
||||
await seed(c, "cli_admin", "cli_backup")
|
||||
await c.execute(
|
||||
f"CREATE ROLE {role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE "
|
||||
"NOREPLICATION NOBYPASSRLS"
|
||||
)
|
||||
await c.execute(f"GRANT kf_oj_identity_operator TO {role}")
|
||||
finally:
|
||||
await c.close()
|
||||
|
||||
asyncio.run(setup())
|
||||
url = make_url(postgres_url).set(username=role).render_as_string(hide_password=False)
|
||||
env = os.environ | {
|
||||
"KF_OJ_DATABASE_URL": postgres_url,
|
||||
"KF_OJ_IDENTITY_OPERATOR_DATABASE_URL": url,
|
||||
}
|
||||
cwd = Path(__file__).resolve().parents[1]
|
||||
|
||||
def invoke(command, username, expected=0):
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "kf_oj.modules.identity.cli", command, username],
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode == expected, result.stderr
|
||||
assert url not in result.stdout + result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
return result.stdout
|
||||
|
||||
try:
|
||||
assert "changed=True" in invoke("bootstrap-admin", "cli_admin")
|
||||
assert "changed=False" in invoke("bootstrap-admin", "cli_admin")
|
||||
invoke("revoke-admin", "cli_admin", 1)
|
||||
invoke("grant-admin", "cli_backup")
|
||||
invoke("disable", "cli_admin")
|
||||
invoke("enable", "cli_admin")
|
||||
invoke("revoke-admin", "cli_admin")
|
||||
invoke("grant-admin", "cli_admin")
|
||||
finally:
|
||||
|
||||
async def cleanup():
|
||||
c = await connect(postgres_url)
|
||||
try:
|
||||
await c.execute(f"REVOKE kf_oj_identity_operator FROM {role}")
|
||||
await c.execute(f"DROP ROLE {role}")
|
||||
finally:
|
||||
await c.close()
|
||||
|
||||
asyncio.run(cleanup())
|
||||
|
||||
|
||||
def test_bad_cli_operation_rejected_without_connecting():
|
||||
async def run():
|
||||
for username, operation in (("invalid name", "grant"), ("valid_name", "unknown")):
|
||||
with pytest.raises(ValueError):
|
||||
await manage_authority(None, username, operation)
|
||||
|
||||
asyncio.run(run())
|
||||
Reference in New Issue
Block a user