feat(identity): protect administrator bootstrap and recovery
This commit is contained in:
@@ -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 $$
|
||||
""")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user