feat(identity): protect administrator bootstrap and recovery

This commit is contained in:
2026-10-11 19:22:55 +08:00
parent a7b2324a3e
commit 234ab23155
12 changed files with 642 additions and 22 deletions
@@ -0,0 +1,226 @@
"""Fixed local authority operations and serialized last-administrator protection."""
import sqlalchemy as sa
from alembic import op
revision = "0006_authority_operations"
down_revision = "0005_identity_authority"
branch_labels = None
depends_on = None
OWNER = "kf_oj_identity_authority_owner"
def upgrade() -> None:
op.execute(f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='{OWNER}') THEN
CREATE ROLE {OWNER} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE
NOREPLICATION NOBYPASSRLS;
ELSIF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='{OWNER}' AND
(rolcanlogin OR rolsuper OR rolcreatedb OR rolcreaterole
OR rolreplication OR rolbypassrls))
OR EXISTS (SELECT 1 FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member
WHERE r.rolname='{OWNER}') THEN
RAISE EXCEPTION 'unsafe existing authority function owner';
END IF;
END $$
""")
op.create_table(
"identity_authority_guard",
sa.Column("id", sa.SmallInteger(), primary_key=True),
sa.Column("generation", sa.BigInteger(), nullable=False, server_default="0"),
sa.CheckConstraint("id = 1", name="singleton"),
sa.CheckConstraint("generation >= 0", name="generation"),
)
op.execute("INSERT INTO public.identity_authority_guard(id) VALUES(1)")
for role in (OWNER, "kf_oj_identity_operator"):
op.execute(f"REVOKE ALL ON ALL TABLES IN SCHEMA public FROM {role}")
op.execute(f"""
REVOKE SELECT(id,username,password_hash,enabled,created_at,
is_administrator,authorization_version),
UPDATE(id,username,password_hash,enabled,created_at,
is_administrator,authorization_version) ON public.accounts FROM {role}
""")
op.execute(f"""
REVOKE SELECT(token_hash,account_id,csrf_hash,expires_at,revoked_at,created_at),
UPDATE(token_hash,account_id,csrf_hash,expires_at,revoked_at,created_at)
ON public.sessions FROM {role}
""")
op.execute(f"GRANT USAGE ON SCHEMA public TO {OWNER}")
op.execute(f"""
GRANT SELECT(id,username,enabled,is_administrator,authorization_version),
UPDATE(enabled,is_administrator) ON public.accounts TO {OWNER}
""")
op.execute(f"GRANT SELECT(account_id,revoked_at),UPDATE(revoked_at) ON sessions TO {OWNER}")
op.execute(f"GRANT SELECT,UPDATE(generation) ON identity_authority_guard TO {OWNER}")
# Every account write obtains the guard BEFORE it can lock an account row.
# Updating (rather than merely locking) the singleton also makes an old
# REPEATABLE READ/SERIALIZABLE snapshot fail safely with a serialization error.
op.execute("""
CREATE FUNCTION public.kf_serialize_account_authority() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
BEGIN
UPDATE public.identity_authority_guard SET generation=generation+1 WHERE id=1;
IF NOT FOUND THEN
RAISE EXCEPTION 'authority guard unavailable' USING ERRCODE='23514';
END IF;
RETURN NULL;
END $$
""")
op.execute("""
CREATE FUNCTION public.kf_protect_last_administrator() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
BEGIN
IF OLD.is_administrator AND OLD.enabled AND
(TG_OP='DELETE' OR NOT (NEW.is_administrator AND NEW.enabled)) AND
(SELECT count(*) FROM public.accounts WHERE is_administrator AND enabled) <= 1
THEN
RAISE EXCEPTION 'last enabled administrator is protected' USING ERRCODE='23514';
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$
""")
op.execute("""
CREATE OR REPLACE FUNCTION public.kf_account_authority_version() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
BEGIN
IF NEW.authorization_version IS DISTINCT FROM OLD.authorization_version THEN
RAISE EXCEPTION 'authorization version is server derived' USING ERRCODE='23514';
END IF;
IF OLD.is_administrator AND NEW.enabled IS DISTINCT FROM OLD.enabled
AND current_user != 'kf_oj_identity_authority_owner'
AND NOT pg_has_role(current_user,'kf_oj_identity_operator','USAGE') THEN
RAISE EXCEPTION 'administrator enablement requires the local operator'
USING ERRCODE='42501';
END IF;
IF NEW.is_administrator IS DISTINCT FROM OLD.is_administrator
OR NEW.enabled IS DISTINCT FROM OLD.enabled THEN
NEW.authorization_version := OLD.authorization_version + 1;
END IF;
RETURN NEW;
END $$
""")
op.execute("""
CREATE FUNCTION public.kf_manage_account_authority(p_username text,p_operation text)
RETURNS TABLE(account_id uuid,username text,enabled boolean,is_administrator boolean,
authorization_version bigint,changed boolean)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
DECLARE target record; desired_admin boolean; desired_enabled boolean; active bigint;
BEGIN
IF p_username IS NULL OR p_username !~ '^[a-z0-9_]{3,24}$' OR
p_operation IS NULL OR
p_operation NOT IN ('bootstrap','grant','revoke','enable','disable') THEN
RAISE EXCEPTION 'invalid authority operation' USING ERRCODE='22023';
END IF;
UPDATE public.identity_authority_guard g SET generation=g.generation+1 WHERE g.id=1;
IF NOT FOUND THEN
RAISE EXCEPTION 'authority guard unavailable' USING ERRCODE='23514';
END IF;
SELECT a.id,a.username,a.enabled,a.is_administrator,a.authorization_version
INTO target FROM public.accounts a WHERE a.username=p_username FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'account not found' USING ERRCODE='P0002';
END IF;
desired_admin:=target.is_administrator; desired_enabled:=target.enabled;
SELECT count(*) INTO active FROM public.accounts a
WHERE a.is_administrator AND a.enabled;
IF p_operation='bootstrap' THEN
IF active>0 AND NOT (target.is_administrator AND target.enabled) THEN
RAISE EXCEPTION 'bootstrap requires no enabled administrator'
USING ERRCODE='23514';
END IF;
desired_admin:=true; desired_enabled:=true;
ELSIF p_operation='grant' THEN
IF active=0 THEN
RAISE EXCEPTION 'first administrator requires explicit bootstrap'
USING ERRCODE='23514';
END IF;
IF NOT target.enabled THEN
RAISE EXCEPTION 'enable the account before granting authority'
USING ERRCODE='23514';
END IF;
desired_admin:=true;
ELSIF p_operation='revoke' THEN desired_admin:=false;
ELSIF p_operation='enable' THEN desired_enabled:=true;
ELSE desired_enabled:=false;
END IF;
changed:=desired_admin IS DISTINCT FROM target.is_administrator OR
desired_enabled IS DISTINCT FROM target.enabled;
IF changed THEN
UPDATE public.accounts a SET enabled=desired_enabled,is_administrator=desired_admin
WHERE a.id=target.id;
END IF;
RETURN QUERY SELECT a.id,a.username::text,a.enabled,a.is_administrator,
a.authorization_version,changed FROM public.accounts a WHERE a.id=target.id;
END $$
""")
# A narrow NOLOGIN owner holds only these column privileges. The operator
# receives EXECUTE, never table writes or access to password/session digests.
op.execute(f"GRANT CREATE ON SCHEMA public TO {OWNER}")
for signature in (
"kf_serialize_account_authority()",
"kf_protect_last_administrator()",
"kf_manage_account_authority(text,text)",
):
op.execute(f"ALTER FUNCTION public.{signature} OWNER TO {OWNER}")
op.execute(f"REVOKE ALL ON FUNCTION public.{signature} FROM PUBLIC")
op.execute(f"REVOKE CREATE ON SCHEMA public FROM {OWNER}")
op.execute("""
GRANT EXECUTE ON FUNCTION public.kf_manage_account_authority(text,text)
TO kf_oj_identity_operator
""")
op.execute("""
CREATE TRIGGER kf_serialize_account_authority BEFORE INSERT OR UPDATE OR DELETE
ON public.accounts FOR EACH STATEMENT
EXECUTE FUNCTION public.kf_serialize_account_authority()
""")
op.execute("""
CREATE TRIGGER kf_protect_last_administrator BEFORE UPDATE OR DELETE
ON public.accounts FOR EACH ROW EXECUTE FUNCTION public.kf_protect_last_administrator()
""")
def downgrade() -> None:
op.execute("""
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM public.accounts WHERE is_administrator) THEN
RAISE EXCEPTION 'cannot remove active authority safeguards' USING ERRCODE='23514';
END IF;
END $$
""")
for name in ("kf_protect_last_administrator", "kf_serialize_account_authority"):
op.execute(f"DROP TRIGGER {name} ON public.accounts")
op.execute(f"DROP FUNCTION public.{name}()")
op.execute("DROP FUNCTION public.kf_manage_account_authority(text,text)")
op.drop_table("identity_authority_guard")
op.execute(f"REVOKE ALL ON accounts,sessions FROM {OWNER}")
op.execute(f"""
REVOKE SELECT(id,username,enabled,is_administrator,authorization_version),
UPDATE(enabled,is_administrator) ON accounts FROM {OWNER}
""")
op.execute(f"REVOKE SELECT(account_id,revoked_at),UPDATE(revoked_at) ON sessions FROM {OWNER}")
op.execute("GRANT SELECT ON accounts,sessions TO kf_oj_identity_operator")
op.execute("GRANT UPDATE(is_administrator,enabled) ON accounts TO kf_oj_identity_operator")
op.execute("GRANT UPDATE(revoked_at) ON sessions TO kf_oj_identity_operator")
# Preserve the v0005 trigger policy without referring to the function owner.
op.execute("""
CREATE OR REPLACE FUNCTION public.kf_account_authority_version() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$
BEGIN
IF NEW.authorization_version IS DISTINCT FROM OLD.authorization_version THEN
RAISE EXCEPTION 'authorization version is server derived' USING ERRCODE='23514';
END IF;
IF OLD.is_administrator AND NEW.enabled IS DISTINCT FROM OLD.enabled
AND NOT pg_has_role(current_user,'kf_oj_identity_operator','USAGE') THEN
RAISE EXCEPTION 'administrator enablement requires the local operator'
USING ERRCODE='42501';
END IF;
IF NEW.is_administrator IS DISTINCT FROM OLD.is_administrator
OR NEW.enabled IS DISTINCT FROM OLD.enabled THEN
NEW.authorization_version := OLD.authorization_version + 1;
END IF;
RETURN NEW;
END $$
""")
+3
View File
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
environment: Literal["development", "test", "production"] = "development"
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
database_url: SecretStr | None = None
identity_operator_database_url: SecretStr | None = None
amqp_url: SecretStr | None = None
browser_origins: tuple[str, ...] = (
"http://localhost:5173",
@@ -22,6 +23,8 @@ class Settings(BaseSettings):
)
session_lifetime_seconds: int = Field(default=43200, ge=300, le=86400)
artifact_root: Path | None = None
import_staging_root: Path | None = None
import_worker_cgroup: Path | None = None
artifact_max_bytes: int = Field(default=536870912, ge=1, le=16 * 1024**3)
artifact_max_files: int = Field(default=4096, ge=1, le=65536)
judge_admission: NodeAdmission | None = None
+21 -3
View File
@@ -4,7 +4,7 @@
`public.py` 提供已验证主体,`service.py` 管理账号和可撤销会话,`repository.py` 封装持久化。
用户身份不能来自前端自报字段或模型参数。
本地管理命令为 `uv run --locked kf-accounts create <username>` / `disable <username>`。
本地管理命令为 `uv run --locked kf-accounts create <username>`。
创建口令由隐藏终端提示读取和确认,不接受命令行参数、环境中的默认口令或自助注册。
用户名为 3–24 个小写 ASCII 字母、数字或下划线;口令 12–128 字符,最多 512 UTF-8 字节。
密码摘要由新增的 `argon2-cffi` 依赖提供 Argon2id,固定 19 MiB/2 次/1 并行度。
@@ -20,8 +20,26 @@
平台账号仅可插入普通账号列、更新启用状态,不能写管理员标记或授权版本;管理员的启用状态
需要独立本地运维身份。`kf_oj_identity_operator` 与 `kf_oj_problem_import` 为无登录的独立权限组,
口令与登录身份由运维另行配置,平台不继承运维组。导入组目前没有表权限,后续导入迁移只授予
队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员;管理员 CLI 和管理 API
尚待接通。保留授权或撤销历史后拒绝回退这次迁移。
队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员。
保留授权或撤销历史后拒绝回退这次迁移。
`0006_authority_operations` 将运维组进一步收敛为固定函数的 EXECUTE 权限;不能直接读取
口令/会话摘要或修改账号/会话。函数由独立 NOLOGIN 角色持有,仅有授权所需列权限,固定
`search_path` 并逐项限定操作。所有账号写操作在行锁前更新同一个事务守卫;撤销/禁用/删除
最后一名启用管理员由数据库拒绝。重复授予/撤销不递增授权版本,实际变更仍原子撤销会话。
旧可重复读快照的并发变更产生序列化失败,调用方须结束整个事务后重新操作;不吞掉失败。
并发语义参见[PostgreSQL事务隔离](https://www.postgresql.org/docs/18/transaction-iso.html),
固定函数的权限与搜索路径参见[CREATE FUNCTION](https://www.postgresql.org/docs/18/sql-createfunction.html)。
在单独的本地运维环境配置 `KF_OJ_IDENTITY_OPERATOR_DATABASE_URL`,使用具有
`kf_oj_identity_operator` 权限组的普通 LOGIN,禁止超级用户、建库/建角色/复制/绕过 RLS
以及平台/协调器/导入组成员。API、Worker 和常规服务不配置此凭据,CLI 不回退平台身份。
先显式创建普通账号,再运行 `uv run --locked kf-accounts bootstrap-admin <username>`。
没有启用管理员时只有明确 bootstrap 可首次授权;不创建默认账号或口令。已有管理员后使用
`grant-admin` / `revoke-admin` / `disable` / `enable`。恢复已禁用管理员使用 `enable`;恢复失去
全部管理员的受控数据库备份时可再次显式 bootstrap。最后一名启用管理员不能被撤销或禁用,
应先授予另一名账号。操作的锁等待最多 2 秒、SQL 最多 5 秒,CLI 只输出账号/能力/授权版本和
是否变化;错误不输出连接串、SQL 参数或驱动诊断。网页管理 API 与题包导入仍待后续实现。
当前主体与登录响应附带版本为 1 的 `capabilities`,其中维护能力和十进制授权版本来自当前
有效会话关联的账号;浏览器只展示这些信息,不能提交它们来获得授权。模块公开端口
@@ -0,0 +1,83 @@
"""Local-only composition for fixed database authority operations."""
from dataclasses import dataclass
from typing import Literal
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.modules.identity.passwords import USERNAME
AuthorityOperation = Literal["bootstrap", "grant", "revoke", "enable", "disable"]
@dataclass(frozen=True)
class AuthorityResult:
account_id: UUID
username: str
enabled: bool
is_administrator: bool
authorization_version: int
changed: bool
async def manage_authority(
sessions: async_sessionmaker, username: str, operation: AuthorityOperation
) -> AuthorityResult:
if not USERNAME.fullmatch(username) or operation not in (
"bootstrap",
"grant",
"revoke",
"enable",
"disable",
):
raise ValueError("invalid authority operation")
try:
async with sessions.begin() as session:
await session.execute(text("SET LOCAL statement_timeout='5s'"))
await session.execute(text("SET LOCAL lock_timeout='2s'"))
# The CLI must use a dedicated, unprivileged local operator login.
# Web/platform, import and coordinator credentials are never reused.
safe = await session.scalar(
text("""
SELECT NOT (rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication
OR rolbypassrls)
AND rolcanlogin AND pg_has_role(current_user,'kf_oj_identity_operator','USAGE')
AND NOT pg_has_role(current_user,'kf_oj_platform','MEMBER')
AND NOT pg_has_role(current_user,'kf_oj_coordinator','MEMBER')
AND NOT pg_has_role(current_user,'kf_oj_problem_import','MEMBER')
FROM pg_roles WHERE rolname=current_user
""")
)
if not safe:
raise ValueError("a dedicated unprivileged identity operator login is required")
row = (
(
await session.execute(
text(
"SELECT * FROM public.kf_manage_account_authority(:username,:operation)"
),
{"username": username, "operation": operation},
)
)
.mappings()
.one()
)
return AuthorityResult(**row)
except DBAPIError as error:
code = getattr(error.orig, "sqlstate", None)
messages = {
"P0002": "account not found",
"23514": (
"authority change rejected; preserve an enabled administrator "
"and use explicit bootstrap"
),
"40001": "concurrent authority change rejected; retry the whole operation",
"55P03": "authority operation busy; retry later",
"57014": "authority operation timed out; verify current state before retrying",
"42501": "identity operator permissions unavailable",
}
# Never print a connection URL, SQL bind values or driver diagnostics.
raise ValueError(messages.get(code, "authority database operation unavailable")) from None
+36 -6
View File
@@ -8,28 +8,58 @@ from sqlalchemy.exc import IntegrityError
from kf_oj.core.settings import Settings
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.identity.authority import manage_authority
from kf_oj.modules.identity.service import IdentityService
async def run(command: str, username: str, password: str | None = None) -> None:
engine = build_engine(Settings())
settings = Settings()
authority_commands = {
"bootstrap-admin": "bootstrap",
"grant-admin": "grant",
"revoke-admin": "revoke",
"enable": "enable",
"disable": "disable",
}
if command != "create" and command not in authority_commands:
raise ValueError("unknown account command")
# Ordinary account creation uses the platform identity; authority management
# always requires a separately configured login and never falls back to it.
if command in authority_commands:
if settings.identity_operator_database_url is None:
raise ValueError("KF_OJ_IDENTITY_OPERATOR_DATABASE_URL is required")
if settings.identity_operator_database_url == settings.database_url:
raise ValueError("operator and platform database identities must be separate")
settings = settings.model_copy(
update={"database_url": settings.identity_operator_database_url}
)
engine = build_engine(settings)
try:
if command in authority_commands:
result = await manage_authority(
build_session_factory(engine), username, authority_commands[command]
)
print(
f"Account {result.username}: enabled={result.enabled}, "
f"administrator={result.is_administrator}, "
f"authorization_version={result.authorization_version}, changed={result.changed}."
)
return
service = IdentityService(build_session_factory(engine))
if command == "create":
assert password is not None
await service.create_account(username, password)
print("Account created.")
else:
if not await service.disable_account(username):
raise ValueError("account not found")
print("Account disabled; sessions revoked.")
finally:
await engine.dispose()
def main() -> None:
parser = argparse.ArgumentParser(description="Manage local OJ accounts")
parser.add_argument("command", choices=("create", "disable"))
parser.add_argument(
"command",
choices=("create", "disable", "enable", "bootstrap-admin", "grant-admin", "revoke-admin"),
)
parser.add_argument("username")
args = parser.parse_args()
password = None
@@ -10,6 +10,7 @@ from sqlalchemy import (
DateTime,
ForeignKey,
Index,
SmallInteger,
String,
Text,
text,
@@ -20,6 +21,19 @@ from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class AuthorityGuard(Base):
"""Migration-owned singleton; runtime roles have no access."""
__tablename__ = "identity_authority_guard"
__table_args__ = (
CheckConstraint("id = 1", name="singleton"),
CheckConstraint("generation >= 0", name="generation"),
)
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
generation: Mapped[int] = mapped_column(BigInteger, server_default="0")
class Account(CreatedAt, Base):
__tablename__ = "accounts"
__table_args__ = (
+2 -2
View File
@@ -3,6 +3,7 @@
import asyncio
import os
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
@@ -40,8 +41,7 @@ def postgres_url():
def migrate(url: str, command: str, target: str | None = None) -> None:
subprocess.run(
[str(Path(__file__).resolve().parents[1] / ".venv/bin/alembic"), command]
+ ([target] if target else []),
[sys.executable, "-m", "alembic", command] + ([target] if target else []),
cwd=Path(__file__).resolve().parents[1],
env=os.environ | {"KF_OJ_DATABASE_URL": url},
check=True,
+14 -5
View File
@@ -118,7 +118,7 @@ def test_authority_columns_and_import_role_are_not_runtime_write_permissions(pos
assert await connection.fetchval(
"SELECT has_column_privilege('kf_oj_platform','accounts','enabled','UPDATE')"
)
assert await connection.fetchval(
assert not await connection.fetchval(
"SELECT has_column_privilege('kf_oj_identity_operator',"
"'accounts','is_administrator','UPDATE')"
)
@@ -146,7 +146,7 @@ def test_authority_change_derives_version_and_revokes_old_session_atomically(pos
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
"SELECT * FROM public.kf_manage_account_authority('storage_user','bootstrap')"
)
assert (
await connection.fetchval(
@@ -159,7 +159,7 @@ def test_authority_change_derives_version_and_revokes_old_session_atomically(pos
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
"SELECT * FROM public.kf_manage_account_authority('storage_user','bootstrap')"
)
assert (
await connection.fetchval(
@@ -175,10 +175,19 @@ def test_authority_change_derives_version_and_revokes_old_session_atomically(pos
token_digest(fresh_csrf),
account,
)
await connection.execute(
"INSERT INTO accounts(id,username,password_hash) "
"VALUES(gen_random_uuid(),'storage_recovery',repeat('synthetic-only-',5))"
)
async with connection.transaction():
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
await connection.execute(
"SELECT * FROM public.kf_manage_account_authority('storage_recovery','grant')"
)
with pytest.raises(RollBackAuthorityChange):
async with connection.transaction():
await connection.execute(
"UPDATE accounts SET is_administrator=false WHERE id=$1", account
"SELECT * FROM public.kf_manage_account_authority('storage_user','revoke')"
)
assert (
await connection.fetchval(
@@ -200,7 +209,7 @@ def test_authority_change_derives_version_and_revokes_old_session_atomically(pos
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
"SELECT * FROM public.kf_manage_account_authority('storage_user','revoke')"
)
assert (
await connection.fetchval(
+219
View File
@@ -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())
+2 -1
View File
@@ -12,13 +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) == 14 # Thirteen business tables and Alembic history.
assert len(tables) == 15 # Fourteen domain/control 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",
"kf_oj_identity_operator",
"kf_oj_problem_import",
"kf_oj_identity_authority_owner",
}
assert all(
not any(
+2 -4
View File
@@ -219,7 +219,7 @@ def test_real_concurrent_logins_keep_bounded_active_sessions(postgres_url):
asyncio.run(run())
def test_account_cli_hidden_password_and_disable(postgres_url):
def test_account_cli_hidden_password_and_session_disable(postgres_url):
password = secrets.token_hex(16)
# The CLI prompts through a real private PTY; the password is never an argument/environment.
pid, descriptor = pty.fork()
@@ -264,9 +264,7 @@ def test_account_cli_hidden_password_and_disable(postgres_url):
try:
service = IdentityService(sessions)
issued = await service.login("cli_user", password, peer="cli-fixture")
from kf_oj.modules.identity.cli import run
await run("disable", "cli_user")
assert await service.disable_account("cli_user")
with pytest.raises(AuthenticationFailed):
await service.authenticate(issued.token)
finally:
+20 -1
View File
@@ -34,10 +34,27 @@ def test_capability_wire_version_and_default_denial():
async def set_administrator(connection, account_id, enabled):
username = await connection.fetchval("SELECT username FROM accounts WHERE id=$1", account_id)
active = await connection.fetchval(
"SELECT count(*) FROM accounts WHERE enabled AND is_administrator"
)
async with connection.transaction():
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
operation = ("grant" if active else "bootstrap") if enabled else "revoke"
await connection.execute(
"SELECT * FROM public.kf_manage_account_authority($1,$2)", username, operation
)
async def add_recovery_administrator(connection):
await connection.execute(
"INSERT INTO accounts(id,username,password_hash) "
"VALUES(gen_random_uuid(),'authority_recovery',repeat('synthetic-only-',5))"
)
async with connection.transaction():
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
await connection.execute(
"UPDATE accounts SET is_administrator=$1 WHERE id=$2", enabled, account_id
"SELECT * FROM public.kf_manage_account_authority('authority_recovery','grant')"
)
@@ -74,6 +91,7 @@ def test_live_http_capabilities_and_old_cookies_after_grant_and_revoke(postgres_
)
assert actual.json() == user
assert actual.headers["cache-control"] == "no-store"
await add_recovery_administrator(connection)
await set_administrator(connection, account.account_id, False)
assert (await client.get("/api/v1/auth/me")).status_code == 401
refused = await client.post(
@@ -105,6 +123,7 @@ def test_write_transaction_rechecks_session_and_serializes_authority_change(post
principal = await identity.authenticate(issued.token)
assert principal.can_maintain_problems and principal.authorization_version == 2
assert principal.session_token_hash not in repr(principal)
await add_recovery_administrator(connection)
async with sessions.begin() as session:
await identity.require_problem_maintenance(session, principal)
pending = asyncio.create_task(set_administrator(other, account.account_id, False))