Author SHA1 Message Date
Kronecker f576756359 test(admin): isolate import recovery acceptance from candidate checks 2026-10-12 05:05:00 +08:00
Kronecker 445affb63d test(admin): verify cross-process import admission and recovery 2026-10-12 04:01:51 +08:00
Kronecker 3536ef1fbb test(admin): preserve baseline data through maintenance migrations 2026-10-12 03:48:26 +08:00
Kronecker 676781223d test(admin): verify bounded archive and private storage refusals 2026-10-12 03:28:41 +08:00
Kronecker 2f860a17c9 docs(admin): document bounded problem maintenance operations 2026-10-12 03:15:51 +08:00
Kronecker 835df6b7e7 feat(admin): connect authenticated problem maintenance console 2026-10-12 02:33:15 +08:00
Kronecker 5366418881 feat(problems): publish immutable candidates with audited maintenance 2026-10-11 23:29:21 +08:00
Kronecker 3152d59056 feat(problems): add bounded administrator package imports
Use live maintenance sessions, durable capacity reservations and streamed SHA-bound ZIP uploads. An ordinary capped import worker validates stored/deflated v1/v2 packages, persists immutable unpublished candidates and finite audit events, and recovers or releases capacity after interruption and expiry.

Validate actual PG/HTTP/worker/storage fault paths in an isolated capped VM (2 passed, 0 skipped), reuse unchanged actual migration roundtrip evidence, and pass affected backend/architecture checks plus the prior full engineering checks.
2026-10-11 22:19:38 +08:00
Kronecker 234ab23155 feat(identity): protect administrator bootstrap and recovery 2026-10-11 19:22:55 +08:00
Kronecker a7b2324a3e feat(identity): expose live maintenance capabilities and transaction authorization 2026-10-11 12:31:08 +08:00
Kronecker 3ed5b72459 feat(identity): persist administrator authority with scoped database roles 2026-10-11 12:20:10 +08:00
Kronecker 8143a7f665 fix(frontend): adapt submission summaries to workbench width 2026-10-11 11:17:06 +08:00
Kronecker 08f2db616f fix(frontend): separate submission time and version link 2026-10-11 11:10:46 +08:00
Kronecker c8cac766c7 test(submissions): verify exact performance cohort boundaries 2026-10-11 07:45:56 +08:00
Kronecker bc5cd12056 feat(frontend): share real submission details and performance in workbench 2026-10-11 03:55:23 +08:00
Kronecker 218fa38f8e feat(submissions): query bounded anonymous performance snapshots 2026-10-11 03:51:33 +08:00
Kronecker fce7522267 feat(submissions): bind final performance to trusted measurement epochs 2026-10-11 03:46:16 +08:00
Kronecker 3cc90e4573 feat(submissions): define bounded performance and scoped history contracts 2026-10-11 03:35:12 +08:00
Kronecker 5df3c370aa docs(frontend): document managed language submission 2026-10-10 04:09:41 +08:00
Kronecker 029585739c fix(worker): bound configurable control request deadlines 2026-10-10 02:15:52 +08:00
Kronecker 47f535ea79 docs(frontend): clarify connected judging services 2026-10-10 00:24:08 +08:00
Kronecker 67c3bc55ba chore(judge): document explicit managed runtime rollout 2026-10-09 23:29:23 +08:00
Kronecker d20721313e test(judge): verify managed worker faults and sealed programs 2026-10-09 22:49:57 +08:00
Kronecker 9d960c2a9b feat(frontend): enable problem-scoped Python and Java submissions 2026-10-09 22:32:38 +08:00
Kronecker 507b15b64a test(coordinator): fence stale managed reports and bound retries 2026-10-09 21:57:40 +08:00
Kronecker 643a8d342e fix(judge): bind managed inputs and confirm artifact cleanup 2026-10-09 21:45:21 +08:00
Kronecker 802e019699 feat(worker): judge versioned sealed runtime programs 2026-10-09 20:15:17 +08:00
Kronecker 2f1166954a feat(sandbox): route pinned runtimes through one bounded factory 2026-10-09 18:50:10 +08:00
Kronecker 38ee332a3e test(sandbox): verify managed file quotas and live wall deadlines 2026-10-09 18:12:37 +08:00
Kronecker 9394a914f8 feat(sandbox): bind managed requests to pinned runtime identities 2026-10-09 17:49:30 +08:00
Kronecker 1dd6d1f971 feat(sandbox): add immutable Python and Java execution profiles 2026-10-09 17:02:27 +08:00
Kronecker 53caafc157 feat(coordinator): dispatch versioned runtime leases and reports 2026-10-09 15:18:08 +08:00
Kronecker 4b8f255180 feat(judging): persist immutable per-language problem configurations 2026-10-09 14:53:09 +08:00
Kronecker a5a7e40308 feat(contracts): add versioned multi-runtime judging contracts 2026-10-09 14:15:24 +08:00
Kronecker 81f8dc3204 docs(platform): describe controlled judging startup and recovery 2026-10-09 10:06:17 +08:00
Kronecker 3e44779578 fix(frontend): ignore expired responses from prior sessions 2026-10-09 09:55:11 +08:00
Kronecker fb1b161c0d feat(platform): connect owned submissions and real browser flows 2026-10-09 09:30:12 +08:00
Kronecker 19a071573b feat(worker): consume durable jobs with fenced execution and recovery 2026-10-09 08:38:20 +08:00
Kronecker 76b565cce8 fix(contracts): reject unfulfillable output capture budgets 2026-10-09 08:25:39 +08:00
Kronecker b7e9111254 fix(messaging): suppress credential-bearing transport logs 2026-10-09 07:55:15 +08:00
Kronecker f838008c71 feat(coordinator): persist fenced execution authority and atomic results 2026-10-09 07:15:24 +08:00
Kronecker 38738122b3 feat(messaging): deliver durable outbox events with publisher confirms 2026-10-09 06:59:13 +08:00
Kronecker 75d74908e0 feat(submissions): create durable queued evaluations idempotently 2026-10-09 06:31:15 +08:00
Kronecker 8a01081cc9 feat(judge): compile once and run isolated points through helper factory 2026-10-09 04:12:22 +08:00
Kronecker 93fb41c608 feat(engine): judge immutable submissions through bounded execution ports 2026-10-09 03:34:04 +08:00
Kronecker 996dbc5144 feat(problems): publish immutable versions and fence artifact access 2026-10-09 01:51:13 +08:00
Kronecker ff60136f8d feat(identity): add bounded login and revocable sessions 2026-10-09 01:20:37 +08:00
Kronecker f4bf218081 feat(database): add judging schema and separate write roles 2026-10-09 01:09:34 +08:00
Kronecker df52446dac feat(contracts): validate fenced judging jobs and bounded results 2026-10-09 00:43:17 +08:00
381 changed files with 58964 additions and 849 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ format:
cd frontend && $(PNPM) format
api:
cd backend && $(UV) run --locked uvicorn kf_oj.api.app:create_app --factory --reload --host 127.0.0.1 --port 8000
cd backend && $(UV) run --locked uvicorn kf_oj.api.app:create_app --factory --reload --host 127.0.0.1 --port 8000 --no-proxy-headers --limit-concurrency 32 --backlog 64
frontend:
cd frontend && $(PNPM) dev
+26 -6
View File
@@ -3,14 +3,26 @@
以 Rust 自研判题与 Linux 沙箱、以 Python/FastAPI 提供平台服务的在线评测系统。
前端使用 Vue 3、TypeScript、Element Plus 和 Vite。
当前版本为 **0.0.1 工程骨架**:平台 API 和协调器提供存活接口,前端可检查实际 API 连接。
沙箱隔离执行、判题、账号、题库、提交与学习业务尚未实现。
当前版本为 **0.0.1**:沙箱已有受控隔离、C/C++ 编译及 native 执行;平台已实现真实身份、
不可变题目、工件与幂等提交创建。事务 outbox、持久化 Coordinator、普通 Worker 和受控
helper 工厂已接通 C17/C++20、固定 CPython 3.13 与 OpenJDK 21 的普通题评测;登录、题库、提交和个人成绩页面
使用真实接口。训练、比赛、社区等扩展页面仍为开发预览。
管理控制台使用同源 Cookie 和服务端维护权限,支持有界 ZIP 题包上传、候选预览、
不可变版本发布、题目下架/恢复及维护审计。初次授权与恢复走独立运维身份的本地 CLI;
导入 Worker 仅解析数据,不执行题包内容或学生代码。操作、限额和恢复步骤见
[题目维护运维说明](deploy/admin-problems.md)。
题目工作台支持当前版本的“本题我的提交”、右侧详情与完整个人详情。
性能展示读取受验证最终 AC 的 CPU 总和与内存峰值,只比较同版本、语言、精确工具链、
预算与可信测量环境的最近有效记录。缺测、旧环境无法验证或其他记录不足五条时明确显示
不可比较;不会填入示例成绩。口径、查询边界和权限见[提交模块说明](backend/src/kf_oj/modules/submissions/README.md)。
## 工程划分
| 目录 | 职责 |
| --- | --- |
| `backend/` | Python 模块化单体、平台 API、学习 Worker、待投递事件发布器及唯一数据库迁移入口 |
| `backend/` | Python 模块化单体、平台 API、题包导入 Worker、待投递事件发布器及唯一数据库迁移入口 |
| `judge/` | Rust workspace:协议、沙箱、判题编排、判题 Worker 和协调器 |
| `frontend/` | 页面、路由、请求生命周期与生成的 API 类型 |
| `contracts/` | 版本化 JSON Schema 与 OpenAPI 快照 |
@@ -25,7 +37,8 @@ Python 业务域分为 `identity`、`problems`、`submissions`、`community`、`
Rust 分为 `kf-contracts`、`kf-sandbox`、`kf-engine`、`kf-worker`、`kf-coordinator` 五个 crate。
协调器拥有执行状态写入权,判题 Worker 不直接写评测数据库。
沙箱尚未验证隔离能力,当前执行入口明确拒绝运行程序。
沙箱执行入口每次核验隔离与限额,任何未验证配置继续拒绝执行。
用户程序及隔离/超限验收只在独立、整体限额的无业务凭据测试 VM 内运行。
## 安装与检查
@@ -62,11 +75,18 @@ make sandbox-probe # 只读观察 Linux 环境,不创建隔离资源
```
本地数据库与队列的启动步骤见 [部署说明](deploy/README.md)。
业务表和数据库 revision 尚未建立;迁移统一由后端 Alembic 管理。
业务表和权限分工由后端 Alembic 唯一管理,见 [迁移与权限说明](backend/migrations/README.md)。
模型服务与第三方插件尚未接入。
开发中的规划文档仅在本地维护,不包含在远端仓库中。
后续逐步实现沙箱、单机判题、分布式恢复、并行优化和学习功能。
单机提交服务的配置、最小权限、停止恢复与独立 VM 验收要求见
[判题说明](judge/README.md#单机持久化协调与常驻-worker)和[部署说明](deploy/README.md#单机提交服务的启动与停止)。
当前普通题执行支持固定 GNU C17/C++20、CPython 3.13.16 和 OpenJDK 21.0.12.1+1-LTS,
使用精确字节比较。Python 固定单文件 main.py 与标准库;Java 固定默认包 Main.java/Main,
允许有界辅助/内部/匿名类。题目必须显式声明对应语言能力,旧题目不会自动获得新语言。
新版源码与普通 Worker 的沙箱执行已在独立 VM 验收;完整平台/浏览器及故障验收按工程阶段
分别记录,生产部署需另行进行。自助注册、自测、SPJ/交互题、Rust 用户程序、分布式调度
和学习能力仍待后续实现。
## 许可证
+11 -1
View File
@@ -1,5 +1,15 @@
KF_OJ_ENVIRONMENT=development
KF_OJ_LOG_LEVEL=INFO
# Optional until database-backed use cases are implemented.
# Dedicated platform login inherits only kf_oj_platform; replace development credentials.
KF_OJ_DATABASE_URL=postgresql+asyncpg://kf_oj:local-dev-only@127.0.0.1:5432/kf_oj
KF_OJ_AMQP_URL=amqp://kf_oj:local-dev-only@127.0.0.1:5672/kf_oj
KF_OJ_BROWSER_ORIGINS='["http://localhost:5173","http://localhost:8000","http://127.0.0.1:5173","http://127.0.0.1:8000"]'
KF_OJ_SESSION_LIFETIME_SECONDS=43200
# Required for submissions: private absolute artifact root and trusted JSON admission policy.
# KF_OJ_ARTIFACT_ROOT=/srv/kf-platform/artifacts
KF_OJ_ARTIFACT_MAX_BYTES=536870912
KF_OJ_ARTIFACT_MAX_FILES=4096
# KF_OJ_JUDGE_ADMISSION=<JSON matching the Coordinator and Worker policy>
# Cancellation requires both; token equals Coordinator platform_token, never a Worker token.
# KF_OJ_COORDINATOR_URL=http://127.0.0.1:8100
# KF_OJ_COORDINATOR_TOKEN=<privately generated 64 lowercase hexadecimal characters>
+26 -4
View File
@@ -15,11 +15,33 @@ make api
make worker-check
```
API 当前只有 `/api/v1/health/live`,不连接数据库或消息队列。
Worker 目前仅支持 `--check`,默认拒绝进入尚未实现的任务循环。
日志、配置和迁移入口已建立;业务表、登录、判题投递和模型接入尚未实现。
API 提供存活、真实登录/退出/当前用户、公开题目与有效 Worker 租约的清单/工件读取。
配置数据库后在 lifespan 内组合模块,配置缺失时业务端口明确不可用,存活接口仍可用。
学习 Worker 目前仅支持 `--check`;Rust 判题 Worker 使用单独的常驻消费入口。
提交创建 API 在持久化源码后,同一事务写入初始评测和 outbox;按用户与幂等键
防止重复创建,待评测总量和个人待评测数有固定上限。
待投递事件使用独立进程 `uv run --locked python -m kf_oj.infra.messaging.publisher`;
配置 `KF_OJ_DATABASE_URL` 为专用平台角色,`KF_OJ_AMQP_URL` 为内部 broker。
发布器一次只锁定一个到期事件;使用持久队列、消息及 mandatory publisher confirm,
确认后才提交投递标记。锁过期允许重投,消息不是执行权,Coordinator claim 才是。
队列最多 64 条、32 KiB,拒绝溢出,消息有效期 10 分钟;broker 不可用采用有界退避,
不阻塞提交事务。日志仅固定错误代码,不打印源码、答案或连接口令。
后台每次投递最多 12 秒,关闭连接最多 2 秒,SIGTERM 在当前有界操作结束后退出。
账号/题目/工件命令及限制见对应模块 README;模型能力尚未接入。
个人提交查询 `/api/v1/submissions` 使用 `(created_at,id)` 降序游标,最多100条;
`/{id}`、`/{id}/source` 仅允许提交本人读取,统一拒绝外来/不存在记录。
成绩投影不包含租约、工件摘要、隐藏用例 ID/输入/答案或程序输出;编译诊断至多64KiB,
缺失计量保留 null,未完成报告不展示成绩。身份/提交/内部响应均为 no-store。
`POST /{id}/cancel` 验证本人、Origin、会话和 CSRF,再调用 Coordinator 接受取消;
202仅表示已持久化意图,实际停止与资源回收由执行服务确认。
配置 `KF_OJ_COORDINATOR_URL` 为数值 loopback HTTP origin,
`KF_OJ_COORDINATOR_TOKEN` 与私有 Coordinator 的 `platform_token` 一致;
缺少配置或通信失败返回503,不直接修改执行状态。新增运行依赖 `httpx` 仅负责这个
3秒有界控制调用,禁用代理/重定向,不缓存或打印远端响应及内部凭据。
Python 虚拟环境位于 `backend/.venv`,可在 `backend/` 直接使用 `uv sync --locked` 和 `uv run --locked`。
数据库由 Alembic 唯一管理;业务模型落地后在 `migrations/env.py` 中显式登记元数据。
数据库由 Alembic 唯一管理;业务模型在 `migrations/env.py` 中显式登记元数据。
不要用 API 启动时的 `create_all` 代替迁移,也不要让 Rust 维护另一套迁移历史。
配置示例在 `.env.example`;执行迁移前复制为 `.env` 并设置实际数据库连接。
+39
View File
@@ -0,0 +1,39 @@
# 数据库所有权
Alembic 是唯一的 schema 入口。先对专用 PostgreSQL 18 数据库运行 `uv run --locked alembic
upgrade head`,API 不在启动时创建表。迁移账号拥有该数据库对象并需要 `CREATEROLE`;它不作为
任何运行服务账号。生产配置及登录角色由部署方提供,不在源码里设置默认数据库口令。
初始迁移建立两个受限的 `NOLOGIN` 权限组:`kf_oj_platform` 和 `kf_oj_coordinator`。
平台组创建 submission / queued evaluation / outbox,不能指定或更新执行状态、attempt 或成绩。
协调器组领取和更新评测、插入 attempt / 用例成绩及恢复通知,不能修改账号、题库或提交身份。
Worker 没有数据库角色,也不需要数据库连接配置。
后续迁移另建 `kf_oj_identity_operator` 与 `kf_oj_problem_import` 两个受限 `NOLOGIN`
权限组。前者只通过固定账号授权函数维护管理员能力/启用状态,不能读取口令哈希、
直接修改角色字段或绕过最后一名启用管理员保护;后者仅处理已授权导入、候选/工件与
追加审计,不能写评测状态、attempt、终态或表现指标。判题 Worker 继续无数据库身份。
平台写入权按列收敛;当前会话与授权版本在每次维护事务内重新核对。
可信表现指标由 Coordinator 在有效最终报告事务中派生,平台、导入器和普通 Worker
不得自行写入。候选与发布版本/用例不可变,成功维护回执及审计只追加;清理保留所有
已发布版本、合法旧提交和未结束候选的工件引用。
升级前停止新维护写入、备份专用数据库与私有工件,并用独立迁移账号执行升级和
`uv run --locked alembic check`。既有登录角色继续绑定各自最小权限组,迁移账号不进入
运行服务配置。降级不作为丢弃候选或回滚一次发布的手段;含新语言/已引用数据时的
降级拒绝须先核对原因,不能清空历史来通过检查。账号 bootstrap/恢复步骤见
[维护运维说明](../../deploy/admin-problems.md)。
服务使用各自独立的普通 `LOGIN` 角色,分别加入对应权限组;登录角色不能拥有数据库/表、
不能加入迁移账号或其他高权限组,且必须为 `NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS`。
初始迁移拒绝已有的高权限同名组和继承其他角色的同名组。应用启动时不运行 migration。
降级删除本项目业务表与触发器,保留权限组及操作方配置的登录账号,避免删除集群级身份。
发布是一次单向转换:先在事务内创建版本和连续测试点,再设置 `published_at`。
数据库触发器拒绝再次发布、修改已发布版本和追加/改写已发布测试点;评测通过复合外键
永久绑定 submission 的版本,当前 attempt 和用例成绩也由复合外键绑定同一评测及版本。
工件引用具有数据库生成的 `ready` 状态,引用存在时不能转为 `deleting` 或删除元数据。
真实数据库验收使用显式 `KF_OJ_TEST_POSTGRES` 管理连接。每个测试只创建和删除随机命名的
`kf_oj_test_*` 数据库,不清空已有业务库。例行无数据库测试会明确跳过这些验收;跳过不算通过。
+8 -1
View File
@@ -5,14 +5,21 @@ from alembic import context
from sqlalchemy import Connection
from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts import models as artifact_models # noqa: F401
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.session import build_engine
from kf_oj.infra.messaging import models as messaging_models # noqa: F401
from kf_oj.modules.identity import models as identity_models # noqa: F401
from kf_oj.modules.operations import models as operations_models # noqa: F401
from kf_oj.modules.problems import import_models, maintenance_models # noqa: F401
from kf_oj.modules.problems import models as problem_models # noqa: F401
from kf_oj.modules.submissions import models as submission_models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Register each business module's ORM models here when its schema is implemented.
# Explicit composition root. Infra and domains do not import foreign ORM models.
target_metadata = Base.metadata
@@ -0,0 +1,588 @@
"""judging foundation and role boundaries
Revision ID: 0001_judging_foundation
Revises:
Create Date: 2026-10-09 00:57:34.004943
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_judging_foundation"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def install_guards_and_permissions() -> None:
# Serialize case insertion against the one-way publication transition.
op.execute("""
CREATE FUNCTION public.kf_immutable_case() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
DECLARE published timestamptz;
BEGIN
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION 'test cases are immutable' USING ERRCODE = '23514';
END IF;
SELECT published_at INTO published FROM public.problem_versions
WHERE id = CASE WHEN TG_OP = 'DELETE' THEN OLD.version_id ELSE NEW.version_id END
FOR UPDATE;
IF published IS NOT NULL THEN
RAISE EXCEPTION 'published test cases are immutable' USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER kf_immutable_case BEFORE INSERT OR UPDATE OR DELETE ON public.problem_cases
FOR EACH ROW EXECUTE FUNCTION public.kf_immutable_case()
""")
op.execute("""
CREATE FUNCTION public.kf_immutable_version() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
DECLARE count_cases integer; last_position integer;
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.published_at IS NOT NULL THEN
RAISE EXCEPTION 'publish after inserting cases' USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
IF OLD.published_at IS NOT NULL THEN
RAISE EXCEPTION 'published version is immutable' USING ERRCODE = '23514';
END IF;
RETURN OLD;
END IF;
IF OLD.published_at IS NOT NULL OR NEW.published_at IS NULL
OR (to_jsonb(NEW) - 'published_at') != (to_jsonb(OLD) - 'published_at') THEN
RAISE EXCEPTION 'version may only be published once' USING ERRCODE = '23514';
END IF;
SELECT count(*), max(position) INTO count_cases, last_position
FROM public.problem_cases WHERE version_id = OLD.id;
IF count_cases NOT BETWEEN 1 AND 256 OR last_position != count_cases - 1 THEN
RAISE EXCEPTION 'publication requires contiguous ordered cases'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER kf_immutable_version BEFORE INSERT OR UPDATE OR DELETE
ON public.problem_versions
FOR EACH ROW EXECUTE FUNCTION public.kf_immutable_version()
""")
op.execute("REVOKE ALL ON FUNCTION public.kf_immutable_case() FROM PUBLIC")
op.execute("REVOKE ALL ON FUNCTION public.kf_immutable_version() FROM PUBLIC")
# Migration owner needs CREATEROLE; runtime logins are separate, unprivileged group members.
for role in ("kf_oj_platform", "kf_oj_coordinator"):
op.execute(f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}') THEN
CREATE ROLE {role} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
ELSIF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}' AND
(rolcanlogin OR rolsuper OR rolcreatedb OR rolcreaterole OR rolbypassrls))
OR EXISTS (SELECT 1 FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member
WHERE r.rolname='{role}') THEN
RAISE EXCEPTION 'unsafe existing runtime role';
END IF;
END $$
""")
tables = (
"accounts,sessions,login_throttles,artifacts,problems,problem_versions,problem_cases,"
"submissions,evaluations,judge_attempts,case_results,outbox"
)
op.execute(f"REVOKE ALL ON {tables} FROM PUBLIC, kf_oj_platform, kf_oj_coordinator")
op.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC")
op.execute("GRANT USAGE ON SCHEMA public TO kf_oj_platform, kf_oj_coordinator")
op.execute(f"GRANT SELECT ON {tables} TO kf_oj_platform")
op.execute("GRANT INSERT,UPDATE,DELETE ON accounts,sessions,login_throttles TO kf_oj_platform")
op.execute("GRANT INSERT,SELECT,DELETE ON artifacts TO kf_oj_platform")
op.execute("GRANT UPDATE(state) ON artifacts TO kf_oj_platform")
op.execute(
"GRANT INSERT ON problems,problem_versions,problem_cases,submissions,outbox "
"TO kf_oj_platform"
)
op.execute("GRANT UPDATE(active_version_id,enabled) ON problems TO kf_oj_platform")
op.execute("GRANT UPDATE(published_at) ON problem_versions TO kf_oj_platform")
op.execute("GRANT INSERT(id,submission_id,problem_version_id) ON evaluations TO kf_oj_platform")
op.execute(
"GRANT UPDATE(failures,next_attempt_at,locked_until,lock_owner,delivered_at,"
"last_error_code) ON outbox TO kf_oj_platform"
)
op.execute(
"GRANT SELECT ON problems,problem_versions,problem_cases,artifacts,submissions,"
"evaluations,judge_attempts,case_results,outbox TO kf_oj_coordinator"
)
op.execute(
"GRANT UPDATE(state,fencing_version,attempts_count,current_attempt_id,not_before,"
"cancel_requested_at,updated_at,verdict,failure_code,final_report,final_report_digest) "
"ON evaluations TO kf_oj_coordinator"
)
op.execute("GRANT INSERT ON judge_attempts,case_results TO kf_oj_coordinator")
op.execute(
"GRANT UPDATE(expires_at,revoked_at,sequence,stage,case_index,report,report_digest,"
"cleanup_complete) ON judge_attempts TO kf_oj_coordinator"
)
# Recovery may enqueue the same immutable notification, but cannot change identity fields.
op.execute("GRANT INSERT ON outbox TO kf_oj_coordinator")
op.execute("GRANT UPDATE(next_attempt_at,delivered_at) ON outbox TO kf_oj_coordinator")
def upgrade() -> None:
op.create_table(
"accounts",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sa.String(length=24), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"username ~ '^[a-z0-9_]{3,24}$'", name=op.f("ck_accounts_canonical_username")
),
sa.CheckConstraint(
"char_length(password_hash) BETWEEN 40 AND 256",
name=op.f("ck_accounts_password_hash_size"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_accounts")),
sa.UniqueConstraint("username", name=op.f("uq_accounts_username")),
)
op.create_table(
"artifacts",
sa.Column("digest", sa.String(length=64), nullable=False),
sa.Column("bytes", sa.BigInteger(), nullable=False),
sa.Column("state", sa.String(length=8), server_default="ready", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_artifacts_digest")),
sa.CheckConstraint("state IN ('ready', 'deleting')", name=op.f("ck_artifacts_state")),
sa.CheckConstraint("bytes BETWEEN 0 AND 67108864", name=op.f("ck_artifacts_bytes")),
sa.PrimaryKeyConstraint("digest", name=op.f("pk_artifacts")),
sa.UniqueConstraint("digest", "state", name=op.f("uq_artifacts_digest")),
)
op.create_table(
"login_throttles",
sa.Column("bucket_hash", sa.String(length=64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint(
"bucket_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_login_throttles_bucket_digest")
),
sa.CheckConstraint(
"attempts BETWEEN 1 AND 10", name=op.f("ck_login_throttles_attempt_budget")
),
sa.PrimaryKeyConstraint("bucket_hash", name=op.f("pk_login_throttles")),
)
op.create_index("ix_login_throttles_reset", "login_throttles", ["reset_at"], unique=False)
op.create_table(
"problems",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("slug", sa.String(length=64), nullable=False),
sa.Column("active_version_id", sa.Uuid(), nullable=True),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("slug ~ '^[a-z0-9][a-z0-9_-]{0,63}$'", name=op.f("ck_problems_slug")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_problems")),
sa.UniqueConstraint("slug", name=op.f("uq_problems_slug")),
)
op.create_table(
"problem_versions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("problem_id", sa.Uuid(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("statement", sa.Text(), nullable=False),
sa.Column("languages", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("toolchain", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("compile_limits", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("run_limits", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("job_wall_time_us", sa.BigInteger(), nullable=False),
sa.Column("comparison", sa.String(length=16), server_default="exact_bytes", nullable=False),
sa.Column("public_samples", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"comparison = 'exact_bytes'", name=op.f("ck_problem_versions_comparison")
),
sa.CheckConstraint(
"char_length(title) BETWEEN 1 AND 160", name=op.f("ck_problem_versions_title_size")
),
sa.CheckConstraint(
"job_wall_time_us BETWEEN 1000 AND 3600000000",
name=op.f("ck_problem_versions_job_wall"),
),
sa.CheckConstraint(
"octet_length(statement) BETWEEN 1 AND 262144",
name=op.f("ck_problem_versions_statement_bytes"),
),
sa.CheckConstraint("revision > 0", name=op.f("ck_problem_versions_revision")),
sa.ForeignKeyConstraint(
["problem_id"], ["problems.id"], name=op.f("fk_problem_versions_problem_id_problems")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_problem_versions")),
sa.UniqueConstraint("id", "problem_id", name=op.f("uq_problem_versions_id")),
sa.UniqueConstraint("problem_id", "revision", name=op.f("uq_problem_versions_problem_id")),
)
op.create_index(
"ix_problem_versions_public",
"problem_versions",
["problem_id", "published_at"],
unique=False,
)
op.create_table(
"sessions",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("account_id", sa.Uuid(), nullable=False),
sa.Column("csrf_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("csrf_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_sessions_csrf_digest")),
sa.CheckConstraint("token_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_sessions_token_digest")),
sa.CheckConstraint("expires_at > created_at", name=op.f("ck_sessions_expiry")),
sa.ForeignKeyConstraint(
["account_id"], ["accounts.id"], name=op.f("fk_sessions_account_id_accounts")
),
sa.PrimaryKeyConstraint("token_hash", name=op.f("pk_sessions")),
)
op.create_index(
"ix_sessions_account_expiry", "sessions", ["account_id", "expires_at"], unique=False
)
op.create_table(
"problem_cases",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("version_id", sa.Uuid(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("input_digest", sa.String(length=64), nullable=False),
sa.Column("expected_digest", sa.String(length=64), nullable=False),
sa.Column(
"artifact_state",
sa.String(length=8),
sa.Computed("'ready'", persisted=True),
nullable=False,
),
sa.CheckConstraint("position BETWEEN 0 AND 255", name=op.f("ck_problem_cases_position")),
sa.ForeignKeyConstraint(
["expected_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name="fk_problem_cases_expected_digest_artifacts",
),
sa.ForeignKeyConstraint(
["input_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name=op.f("fk_problem_cases_input_digest_artifacts"),
),
sa.ForeignKeyConstraint(
["version_id"],
["problem_versions.id"],
name=op.f("fk_problem_cases_version_id_problem_versions"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_problem_cases")),
sa.UniqueConstraint("id", "version_id", name=op.f("uq_problem_cases_id")),
sa.UniqueConstraint("version_id", "position", name=op.f("uq_problem_cases_version_id")),
)
op.create_table(
"submissions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("account_id", sa.Uuid(), nullable=False),
sa.Column("problem_id", sa.Uuid(), nullable=False),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("language", sa.String(length=8), nullable=False),
sa.Column("source_digest", sa.String(length=64), nullable=False),
sa.Column(
"artifact_state",
sa.String(length=8),
sa.Computed("'ready'", persisted=True),
nullable=False,
),
sa.Column("idempotency_key", sa.Uuid(), nullable=False),
sa.Column("request_digest", sa.String(length=64), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("language IN ('c17', 'cpp20')", name=op.f("ck_submissions_language")),
sa.CheckConstraint(
"request_digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_submissions_request_digest")
),
sa.ForeignKeyConstraint(
["account_id"], ["accounts.id"], name=op.f("fk_submissions_account_id_accounts")
),
sa.ForeignKeyConstraint(
["problem_version_id", "problem_id"],
["problem_versions.id", "problem_versions.problem_id"],
name=op.f("fk_submissions_problem_version_id_problem_versions"),
),
sa.ForeignKeyConstraint(
["source_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name=op.f("fk_submissions_source_digest_artifacts"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_submissions")),
sa.UniqueConstraint(
"account_id", "idempotency_key", name=op.f("uq_submissions_account_id")
),
sa.UniqueConstraint("id", "problem_version_id", name=op.f("uq_submissions_id")),
)
op.create_index(
"ix_submissions_account_history",
"submissions",
["account_id", "created_at", "id"],
unique=False,
)
op.create_table(
"evaluations",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("submission_id", sa.Uuid(), nullable=False),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("state", sa.String(length=16), server_default="queued", nullable=False),
sa.Column("fencing_version", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("attempts_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("max_attempts", sa.Integer(), server_default="3", nullable=False),
sa.Column("current_attempt_id", sa.Uuid(), nullable=True),
sa.Column(
"not_before",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("cancel_requested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("verdict", sa.String(length=32), nullable=True),
sa.Column("failure_code", sa.String(length=32), nullable=True),
sa.Column("final_report", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("final_report_digest", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"state IN ('queued','claimed','compiling','running','cancelling',"
"'finished','cancelled','failed')",
name=op.f("ck_evaluations_state"),
),
sa.CheckConstraint(
"state NOT IN ('claimed','compiling','running','cancelling') "
"OR current_attempt_id IS NOT NULL",
name=op.f("ck_evaluations_active_attempt"),
),
sa.CheckConstraint(
"attempts_count BETWEEN 0 AND max_attempts", name=op.f("ck_evaluations_attempts_count")
),
sa.CheckConstraint("fencing_version >= 0", name=op.f("ck_evaluations_fence")),
sa.CheckConstraint(
"max_attempts BETWEEN 1 AND 5", name=op.f("ck_evaluations_max_attempts")
),
sa.ForeignKeyConstraint(
["submission_id", "problem_version_id"],
["submissions.id", "submissions.problem_version_id"],
name=op.f("fk_evaluations_submission_id_submissions"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_evaluations")),
sa.UniqueConstraint("id", "problem_version_id", name=op.f("uq_evaluations_id")),
sa.UniqueConstraint("submission_id", name=op.f("uq_evaluations_submission_id")),
)
op.create_index(
"ix_evaluations_recovery", "evaluations", ["state", "not_before", "id"], unique=False
)
op.create_table(
"judge_attempts",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("evaluation_id", sa.Uuid(), nullable=False),
sa.Column("worker_id", sa.Uuid(), nullable=False),
sa.Column("number", sa.Integer(), nullable=False),
sa.Column("fencing_version", sa.BigInteger(), nullable=False),
sa.Column("lease_token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("manifest", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("manifest_digest", sa.String(length=64), nullable=False),
sa.Column("sequence", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("stage", sa.String(length=16), nullable=True),
sa.Column("case_index", sa.Integer(), nullable=True),
sa.Column("report", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("report_digest", sa.String(length=64), nullable=True),
sa.Column("cleanup_complete", sa.Boolean(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"lease_token_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_judge_attempts_token_digest")
),
sa.CheckConstraint(
"manifest_digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_judge_attempts_manifest_digest")
),
sa.CheckConstraint("fencing_version > 0", name=op.f("ck_judge_attempts_fence")),
sa.CheckConstraint("number BETWEEN 1 AND 5", name=op.f("ck_judge_attempts_number")),
sa.CheckConstraint("sequence >= 0", name=op.f("ck_judge_attempts_sequence")),
sa.ForeignKeyConstraint(
["evaluation_id"],
["evaluations.id"],
name=op.f("fk_judge_attempts_evaluation_id_evaluations"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_judge_attempts")),
sa.UniqueConstraint(
"evaluation_id", "fencing_version", name="uq_judge_attempts_evaluation_fence"
),
sa.UniqueConstraint("evaluation_id", "number", name="uq_judge_attempts_evaluation_number"),
sa.UniqueConstraint("id", "evaluation_id", name=op.f("uq_judge_attempts_id")),
)
op.create_index(
"ix_judge_attempts_expiry", "judge_attempts", ["expires_at", "evaluation_id"], unique=False
)
op.create_table(
"outbox",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("evaluation_id", sa.Uuid(), nullable=False),
sa.Column("kind", sa.String(length=32), server_default="judge.evaluation", nullable=False),
sa.Column("failures", sa.Integer(), server_default="0", nullable=False),
sa.Column(
"next_attempt_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("lock_owner", sa.Uuid(), nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_code", sa.String(length=32), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("kind = 'judge.evaluation'", name=op.f("ck_outbox_kind")),
sa.CheckConstraint("failures BETWEEN 0 AND 100", name=op.f("ck_outbox_failure_budget")),
sa.ForeignKeyConstraint(
["evaluation_id"], ["evaluations.id"], name=op.f("fk_outbox_evaluation_id_evaluations")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_outbox")),
sa.UniqueConstraint("evaluation_id", name=op.f("uq_outbox_evaluation_id")),
)
op.create_index(
"ix_outbox_delivery", "outbox", ["delivered_at", "next_attempt_at", "id"], unique=False
)
op.create_table(
"case_results",
sa.Column("attempt_id", sa.Uuid(), nullable=False),
sa.Column("case_id", sa.Uuid(), nullable=False),
sa.Column("evaluation_id", sa.Uuid(), nullable=False),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("verdict", sa.String(length=32), nullable=False),
sa.Column("cpu_time_us", sa.BigInteger(), nullable=True),
sa.Column("memory_peak_bytes", sa.BigInteger(), nullable=True),
sa.CheckConstraint(
"cpu_time_us IS NULL OR cpu_time_us >= 0", name=op.f("ck_case_results_cpu")
),
sa.CheckConstraint(
"memory_peak_bytes IS NULL OR memory_peak_bytes >= 0",
name=op.f("ck_case_results_memory"),
),
sa.CheckConstraint("position BETWEEN 0 AND 255", name=op.f("ck_case_results_position")),
sa.ForeignKeyConstraint(
["attempt_id", "evaluation_id"],
["judge_attempts.id", "judge_attempts.evaluation_id"],
name=op.f("fk_case_results_attempt_id_judge_attempts"),
),
sa.ForeignKeyConstraint(
["case_id", "problem_version_id"],
["problem_cases.id", "problem_cases.version_id"],
name=op.f("fk_case_results_case_id_problem_cases"),
),
sa.ForeignKeyConstraint(
["evaluation_id", "problem_version_id"],
["evaluations.id", "evaluations.problem_version_id"],
name=op.f("fk_case_results_evaluation_id_evaluations"),
),
sa.PrimaryKeyConstraint("attempt_id", "case_id", name=op.f("pk_case_results")),
sa.UniqueConstraint("attempt_id", "position", name=op.f("uq_case_results_attempt_id")),
)
op.create_foreign_key(
"fk_problems_active_version",
"problems",
"problem_versions",
["active_version_id", "id"],
["id", "problem_id"],
deferrable=True,
initially="DEFERRED",
)
op.create_foreign_key(
"fk_evaluations_current_attempt",
"evaluations",
"judge_attempts",
["current_attempt_id", "id"],
["id", "evaluation_id"],
deferrable=True,
initially="DEFERRED",
)
install_guards_and_permissions()
def downgrade() -> None:
op.drop_constraint("fk_evaluations_current_attempt", "evaluations", type_="foreignkey")
op.drop_constraint("fk_problems_active_version", "problems", type_="foreignkey")
op.drop_table("case_results")
op.drop_index("ix_outbox_delivery", table_name="outbox")
op.drop_table("outbox")
op.drop_index("ix_judge_attempts_expiry", table_name="judge_attempts")
op.drop_table("judge_attempts")
op.drop_index("ix_evaluations_recovery", table_name="evaluations")
op.drop_table("evaluations")
op.drop_index("ix_submissions_account_history", table_name="submissions")
op.drop_table("submissions")
op.drop_table("problem_cases")
op.drop_index("ix_sessions_account_expiry", table_name="sessions")
op.drop_table("sessions")
op.drop_index("ix_problem_versions_public", table_name="problem_versions")
op.drop_table("problem_versions")
op.drop_table("problems")
op.drop_index("ix_login_throttles_reset", table_name="login_throttles")
op.drop_table("login_throttles")
op.drop_table("artifacts")
op.drop_table("accounts")
op.execute("DROP FUNCTION public.kf_immutable_version()")
op.execute("DROP FUNCTION public.kf_immutable_case()")
# NOLOGIN groups are intentionally retained: a downgrade never deletes operator roles.
@@ -0,0 +1,247 @@
"""Immutable per-language judging configuration and extended submission language IDs.
Revision ID: 0002_multi_runtime
Revises: 0001_judging_foundation
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision = "0002_multi_runtime"
down_revision = "0001_judging_foundation"
branch_labels = None
depends_on = None
CONFIGURATION_CHECK = """
(judging_schema_version = 1 AND language_configurations IS NULL
AND toolchain IS NOT NULL AND compile_limits IS NOT NULL
AND run_limits IS NOT NULL AND job_wall_time_us IS NOT NULL)
OR
(judging_schema_version = 2 AND language_configurations IS NOT NULL
AND toolchain IS NULL AND compile_limits IS NULL
AND run_limits IS NULL AND job_wall_time_us IS NULL
AND public.kf_valid_language_configurations(languages, language_configurations))
"""
def upgrade() -> None:
# Metadata-only additions/widening; do not UPDATE any immutable published rows.
op.add_column(
"problem_versions",
sa.Column("judging_schema_version", sa.SmallInteger(), nullable=False, server_default="1"),
)
op.add_column(
"problem_versions",
sa.Column("language_configurations", JSONB(none_as_null=True), nullable=True),
)
for column, kind in (
("toolchain", JSONB()),
("compile_limits", JSONB()),
("run_limits", JSONB()),
("job_wall_time_us", sa.BigInteger()),
):
op.alter_column("problem_versions", column, existing_type=kind, nullable=True)
op.alter_column(
"submissions",
"language",
existing_type=sa.String(8),
type_=sa.String(16),
existing_nullable=False,
)
op.drop_constraint(op.f("ck_submissions_language"), "submissions", type_="check")
op.create_check_constraint(
op.f("ck_submissions_language"),
"submissions",
"language IN ('c17','cpp20','python313','java21')",
)
op.execute("""
CREATE FUNCTION public.kf_valid_runtime_limits(limits jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT
SET search_path = pg_catalog, public AS $$
DECLARE entry record;
BEGIN
IF jsonb_typeof(limits) != 'object' OR
(SELECT count(*) FROM jsonb_object_keys(limits)) != 10 THEN RETURN false; END IF;
FOR entry IN SELECT * FROM (VALUES
('cpu_time_us',1000::numeric,60000000::numeric),
('wall_time_us',1000,120000000), ('memory_bytes',1,2147483648),
('stack_bytes',1,268435456), ('processes',1,128), ('open_files',3,1024),
('stdout_bytes',1,16777216), ('stderr_bytes',1,16777216),
('file_bytes',1,67108864), ('workdir_bytes',1,268435456)
) AS bounds(name,lo,hi) LOOP
IF NOT limits ? entry.name OR jsonb_typeof(limits->entry.name) != 'number'
OR (limits->>entry.name) !~ '^(0|[1-9][0-9]*)$'
OR length(limits->>entry.name) > 19 THEN RETURN false; END IF;
IF (limits->>entry.name)::numeric NOT BETWEEN entry.lo AND entry.hi
THEN RETURN false; END IF;
END LOOP;
RETURN (limits->>'stack_bytes')::numeric <= (limits->>'memory_bytes')::numeric
AND (limits->>'file_bytes')::numeric <= (limits->>'workdir_bytes')::numeric
AND (limits->>'stdout_bytes')::numeric <= (limits->>'file_bytes')::numeric
AND (limits->>'stderr_bytes')::numeric <= (limits->>'file_bytes')::numeric
AND (limits->>'stdout_bytes')::numeric + (limits->>'stderr_bytes')::numeric
<= (limits->>'workdir_bytes')::numeric;
END $$
""")
op.execute("""
CREATE FUNCTION public.kf_valid_language_configurations(languages jsonb, configs jsonb)
RETURNS boolean LANGUAGE plpgsql IMMUTABLE STRICT
SET search_path = pg_catalog, public AS $$
DECLARE item record; cfg jsonb; runtime jsonb; lang text; seen text[] := ARRAY[]::text[];
phase jsonb; preparing boolean;
BEGIN
IF jsonb_typeof(languages) != 'array' OR jsonb_typeof(configs) != 'array'
THEN RETURN false; END IF;
IF jsonb_array_length(configs) NOT BETWEEN 1 AND 4 OR
jsonb_array_length(languages) != jsonb_array_length(configs)
THEN RETURN false; END IF;
FOR item IN SELECT value, ordinality FROM jsonb_array_elements(configs)
WITH ORDINALITY LOOP
cfg := item.value;
IF jsonb_typeof(cfg) != 'object' OR
(SELECT count(*) FROM jsonb_object_keys(cfg)) != 5 OR NOT cfg ?&
ARRAY['language','toolchain','compile_limits','run_limits','job_wall_time_us']
THEN RETURN false; END IF;
IF jsonb_typeof(cfg->'language') != 'string' THEN RETURN false; END IF;
lang := cfg->>'language';
IF lang NOT IN ('c17','cpp20','python313','java21') OR lang = ANY(seen)
OR languages->(item.ordinality::int-1) != cfg->'language'
THEN RETURN false; END IF;
seen := array_append(seen,lang); runtime := cfg->'toolchain';
IF jsonb_typeof(runtime) != 'object' OR
(SELECT count(*) FROM jsonb_object_keys(runtime)) != 5 OR NOT runtime ?&
ARRAY['toolchain_id','image_kind','image_revision','image_digest','runtime_version']
THEN RETURN false; END IF;
IF jsonb_typeof(runtime->'toolchain_id') != 'string' OR
jsonb_typeof(runtime->'image_kind') != 'string' OR
jsonb_typeof(runtime->'runtime_version') != 'string' OR
jsonb_typeof(runtime->'image_digest') != 'string' OR
(runtime->>'image_digest') !~ '^[0-9a-f]{64}$' OR
(runtime->>'runtime_version') !~ '^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$' OR
jsonb_typeof(runtime->'image_revision') != 'number' OR
(runtime->>'image_revision') !~ '^[1-9][0-9]{0,9}$'
THEN RETURN false; END IF;
IF (runtime->>'image_revision')::numeric > 4294967295 THEN RETURN false; END IF;
IF lang IN ('c17','cpp20') AND (runtime->>'toolchain_id' != 'gnu-cxx-v1' OR
runtime->>'image_kind' != 'linux_gnu_cxx_x86_64_v1') THEN RETURN false; END IF;
IF lang = 'python313' AND (runtime->>'toolchain_id' != 'cpython313-v1' OR
runtime->>'image_kind' != 'linux_cpython313_x86_64_v1' OR
runtime->>'runtime_version' != '3.13.16') THEN RETURN false; END IF;
IF lang = 'java21' AND (runtime->>'toolchain_id' != 'openjdk21-v1' OR
runtime->>'image_kind' != 'linux_openjdk21_x86_64_v1' OR
(runtime->>'runtime_version') !~
'^21[.][0-9]+[.][0-9]+([.][0-9]+)?[+][1-9][0-9]*(-LTS)?$')
THEN RETURN false; END IF;
IF NOT public.kf_valid_runtime_limits(cfg->'compile_limits') OR
NOT public.kf_valid_runtime_limits(cfg->'run_limits') THEN RETURN false; END IF;
IF jsonb_typeof(cfg->'job_wall_time_us') != 'number' OR
(cfg->>'job_wall_time_us') !~ '^[1-9][0-9]{0,9}$' THEN RETURN false; END IF;
IF (cfg->>'job_wall_time_us')::numeric NOT BETWEEN 1000 AND 3600000000 OR
(cfg->'compile_limits'->>'wall_time_us')::numeric +
(cfg->'run_limits'->>'wall_time_us')::numeric >
(cfg->>'job_wall_time_us')::numeric
THEN RETURN false; END IF;
FOR preparing, phase IN SELECT true,cfg->'compile_limits' UNION ALL
SELECT false,cfg->'run_limits' LOOP
IF lang = 'python313' AND ((phase->>'cpu_time_us')::numeric < 10000 OR
(phase->>'wall_time_us')::numeric < 50000 OR
(phase->>'memory_bytes')::numeric < 33554432 OR
(phase->>'open_files')::numeric < 16) THEN RETURN false; END IF;
IF lang = 'java21' AND ((phase->>'cpu_time_us')::numeric <
CASE WHEN preparing THEN 1000000 ELSE 200000 END OR
(phase->>'wall_time_us')::numeric <
CASE WHEN preparing THEN 3000000 ELSE 1000000 END OR
(phase->>'memory_bytes')::numeric <
CASE WHEN preparing THEN 402653184 ELSE 268435456 END OR
(phase->>'processes')::numeric < 16 OR
(phase->>'open_files')::numeric < 32 OR
(phase->>'stack_bytes')::numeric < 262144 OR
(phase->>'memory_bytes')::numeric - 167772160 -
(phase->>'processes')::numeric * 262144 < 33554432)
THEN RETURN false; END IF;
END LOOP;
END LOOP;
RETURN true;
END $$
""")
op.execute("REVOKE ALL ON FUNCTION public.kf_valid_runtime_limits(jsonb) FROM PUBLIC")
op.execute(
"REVOKE ALL ON FUNCTION public.kf_valid_language_configurations(jsonb,jsonb) FROM PUBLIC"
)
op.execute("GRANT EXECUTE ON FUNCTION public.kf_valid_runtime_limits(jsonb) TO kf_oj_platform")
op.execute(
"GRANT EXECUTE ON FUNCTION public.kf_valid_language_configurations(jsonb,jsonb) "
"TO kf_oj_platform"
)
op.create_check_constraint(
op.f("ck_problem_versions_judging_schema"),
"problem_versions",
"judging_schema_version IN (1,2)",
)
op.create_check_constraint(
op.f("ck_problem_versions_language_configurations"), "problem_versions", CONFIGURATION_CHECK
)
op.execute("""
CREATE FUNCTION public.kf_submission_language() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
DECLARE supported jsonb;
BEGIN
SELECT languages INTO supported FROM public.problem_versions
WHERE id=NEW.problem_version_id;
IF FOUND AND NOT supported @> jsonb_build_array(NEW.language) THEN
RAISE EXCEPTION 'submission language not supported by version'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$
""")
op.execute("REVOKE ALL ON FUNCTION public.kf_submission_language() FROM PUBLIC")
op.execute("""
CREATE TRIGGER kf_submission_language BEFORE INSERT OR UPDATE OF
language,problem_version_id ON public.submissions
FOR EACH ROW EXECUTE FUNCTION public.kf_submission_language()
""")
def downgrade() -> None:
# Losing a runtime configuration is not a rollback strategy. Preserve all user data.
op.execute("""
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM problem_versions WHERE judging_schema_version = 2) OR
EXISTS (SELECT 1 FROM submissions WHERE language NOT IN ('c17','cpp20')) THEN
RAISE EXCEPTION 'cannot downgrade while multi-runtime data exists'
USING ERRCODE = '23514';
END IF;
END $$
""")
op.drop_constraint(
op.f("ck_problem_versions_language_configurations"), "problem_versions", type_="check"
)
op.drop_constraint(
op.f("ck_problem_versions_judging_schema"), "problem_versions", type_="check"
)
op.execute("DROP TRIGGER kf_submission_language ON public.submissions")
op.execute("DROP FUNCTION public.kf_submission_language()")
op.execute("DROP FUNCTION public.kf_valid_language_configurations(jsonb,jsonb)")
op.execute("DROP FUNCTION public.kf_valid_runtime_limits(jsonb)")
op.drop_column("problem_versions", "language_configurations")
op.drop_column("problem_versions", "judging_schema_version")
for column, kind in (
("toolchain", JSONB()),
("compile_limits", JSONB()),
("run_limits", JSONB()),
("job_wall_time_us", sa.BigInteger()),
):
op.alter_column("problem_versions", column, existing_type=kind, nullable=False)
op.drop_constraint(op.f("ck_submissions_language"), "submissions", type_="check")
op.create_check_constraint(
op.f("ck_submissions_language"), "submissions", "language IN ('c17','cpp20')"
)
op.alter_column(
"submissions",
"language",
existing_type=sa.String(16),
type_=sa.String(8),
existing_nullable=False,
)
@@ -0,0 +1,119 @@
"""Trusted final performance projection; no re-encoding or promotion of old reports."""
import sqlalchemy as sa
from alembic import op
revision = "0003_submission_performance"
down_revision = "0002_multi_runtime"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("judge_attempts", sa.Column("measurement_epoch", sa.String(64), nullable=True))
op.create_check_constraint(
op.f("ck_judge_attempts_measurement_epoch"),
"judge_attempts",
"measurement_epoch IS NULL OR measurement_epoch ~ '^[0-9a-f]{64}$'",
)
op.create_table(
"evaluation_performance",
sa.Column("evaluation_id", sa.Uuid(), primary_key=True),
sa.Column("submission_id", sa.Uuid(), nullable=False, unique=True),
sa.Column("attempt_id", sa.Uuid(), nullable=False, unique=True),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("language", sa.String(16), nullable=False),
sa.Column("comparison_group", sa.String(64), nullable=False),
sa.Column("measurement_epoch", sa.String(64), nullable=False),
sa.Column("report_digest", sa.String(64), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("cpu_time_us", sa.BigInteger(), nullable=True),
sa.Column("memory_peak_bytes", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(
["evaluation_id", "problem_version_id"],
["evaluations.id", "evaluations.problem_version_id"],
),
sa.ForeignKeyConstraint(
["submission_id", "problem_version_id"],
["submissions.id", "submissions.problem_version_id"],
),
sa.ForeignKeyConstraint(
["attempt_id", "evaluation_id"], ["judge_attempts.id", "judge_attempts.evaluation_id"]
),
sa.CheckConstraint("language IN ('c17','cpp20','python313','java21')", name="language"),
sa.CheckConstraint("comparison_group ~ '^[0-9a-f]{64}$'", name="comparison_group"),
sa.CheckConstraint("measurement_epoch ~ '^[0-9a-f]{64}$'", name="measurement_epoch"),
sa.CheckConstraint("report_digest ~ '^[0-9a-f]{64}$'", name="report_digest"),
sa.CheckConstraint("cpu_time_us IS NULL OR cpu_time_us >= 0", name="cpu"),
sa.CheckConstraint("memory_peak_bytes IS NULL OR memory_peak_bytes >= 0", name="memory"),
)
for metric in ("cpu_time_us", "memory_peak_bytes"):
op.create_index(
f"ix_performance_{metric}_cohort",
"evaluation_performance",
["comparison_group", sa.text("finished_at DESC"), sa.text("evaluation_id DESC")],
postgresql_where=sa.text(f"{metric} IS NOT NULL"),
)
op.create_index(
"ix_submissions_owned_problem_history",
"submissions",
["account_id", "problem_version_id", sa.text("created_at DESC"), sa.text("id DESC")],
)
op.execute("""
CREATE FUNCTION public.kf_trusted_performance() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
BEGIN
IF TG_OP != 'INSERT' THEN
RAISE EXCEPTION 'final performance is immutable' USING ERRCODE = '23514';
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.evaluations e
JOIN public.judge_attempts a ON a.id=e.current_attempt_id AND a.evaluation_id=e.id
JOIN public.submissions s ON s.id=e.submission_id
WHERE e.id=NEW.evaluation_id AND e.state='finished' AND e.verdict='accepted'
AND e.failure_code IS NULL AND a.cleanup_complete IS TRUE
AND e.current_attempt_id=NEW.attempt_id AND s.id=NEW.submission_id
AND s.problem_version_id=NEW.problem_version_id AND s.language=NEW.language
AND a.measurement_epoch=NEW.measurement_epoch
AND e.final_report_digest=NEW.report_digest AND a.report_digest=NEW.report_digest
AND e.updated_at=NEW.finished_at
AND e.final_report->>'cleanup_complete'='true'
AND (e.final_report->>'run_cpu_time_us')::bigint
IS NOT DISTINCT FROM NEW.cpu_time_us
AND (e.final_report->>'run_memory_peak_bytes')::bigint
IS NOT DISTINCT FROM NEW.memory_peak_bytes
) THEN
RAISE EXCEPTION 'performance requires a bound trusted final AC'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$
""")
op.execute("REVOKE ALL ON FUNCTION public.kf_trusted_performance() FROM PUBLIC")
op.execute("""
CREATE TRIGGER kf_trusted_performance BEFORE INSERT OR UPDATE OR DELETE
ON public.evaluation_performance FOR EACH ROW
EXECUTE FUNCTION public.kf_trusted_performance()
""")
op.execute("REVOKE ALL ON evaluation_performance FROM PUBLIC,kf_oj_platform,kf_oj_coordinator")
op.execute("GRANT SELECT ON evaluation_performance TO kf_oj_platform,kf_oj_coordinator")
op.execute("GRANT INSERT ON evaluation_performance TO kf_oj_coordinator")
# Existing attempt INSERT permission covers the new column. No UPDATE epoch permission:
# an unbound historical attempt must never be promoted by changing current configuration.
def downgrade() -> None:
op.execute("""
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM evaluation_performance) OR
EXISTS(SELECT 1 FROM judge_attempts WHERE measurement_epoch IS NOT NULL) THEN
RAISE EXCEPTION 'cannot discard trusted measurement bindings'
USING ERRCODE = '23514';
END IF;
END $$
""")
op.drop_table("evaluation_performance")
op.execute("DROP FUNCTION public.kf_trusted_performance()")
op.drop_index("ix_submissions_owned_problem_history", table_name="submissions")
op.drop_constraint(op.f("ck_judge_attempts_measurement_epoch"), "judge_attempts", type_="check")
op.drop_column("judge_attempts", "measurement_epoch")
@@ -0,0 +1,21 @@
"""Indexed group watermark for bounded aggregate cache invalidation."""
import sqlalchemy as sa
from alembic import op
revision = "0004_performance_watermark"
down_revision = "0003_submission_performance"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_performance_group_watermark",
"evaluation_performance",
["comparison_group", sa.text("finished_at DESC"), sa.text("evaluation_id DESC")],
)
def downgrade() -> None:
op.drop_index("ix_performance_group_watermark", table_name="evaluation_performance")
@@ -0,0 +1,150 @@
"""Default-denied administrator authority and separate local operator/import roles."""
import sqlalchemy as sa
from alembic import op
revision = "0005_identity_authority"
down_revision = "0004_performance_watermark"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"accounts",
sa.Column("is_administrator", sa.Boolean(), server_default="false", nullable=False),
)
op.add_column(
"accounts",
sa.Column("authorization_version", sa.BigInteger(), server_default="1", nullable=False),
)
op.create_check_constraint(
op.f("ck_accounts_authorization_version"), "accounts", "authorization_version >= 1"
)
op.create_index(
"ix_accounts_active_administrators",
"accounts",
["id"],
postgresql_where=sa.text("is_administrator AND enabled"),
)
# Logins and secrets are provisioned separately by an operator. Neither group
# may inherit an existing privileged role or quietly reuse a privileged login.
for role in ("kf_oj_identity_operator", "kf_oj_problem_import"):
op.execute(f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='{role}') THEN
CREATE ROLE {role} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE
NOREPLICATION NOBYPASSRLS;
ELSIF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='{role}' 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='{role}') THEN
RAISE EXCEPTION 'unsafe existing authority or import role';
END IF;
END $$
""")
op.execute(
"REVOKE ALL ON ALL TABLES IN SCHEMA public "
"FROM kf_oj_identity_operator,kf_oj_problem_import"
)
# Table REVOKE does not remove explicit column grants. Remove those too,
# including grants left by an earlier downgrade or manual role provisioning.
op.execute("""
DO $$ DECLARE t record; BEGIN
FOR t IN SELECT c.relname, string_agg(quote_ident(a.attname),',' ORDER BY a.attnum) cols
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_attribute a ON a.attrelid=c.oid
WHERE n.nspname='public' AND c.relkind IN ('r','p','v','f')
AND a.attnum>0 AND NOT a.attisdropped
GROUP BY c.oid,c.relname LOOP
EXECUTE format('REVOKE SELECT (%s),INSERT (%s),UPDATE (%s),REFERENCES (%s) '
'ON TABLE public.%I FROM kf_oj_identity_operator,kf_oj_problem_import',
t.cols,t.cols,t.cols,t.cols,t.relname);
END LOOP;
END $$
""")
op.execute("GRANT USAGE ON SCHEMA public TO kf_oj_identity_operator,kf_oj_problem_import")
op.execute("REVOKE INSERT,UPDATE,DELETE ON accounts FROM kf_oj_platform")
op.execute(
"REVOKE INSERT(id,username,password_hash,enabled,created_at,"
"is_administrator,authorization_version),"
"UPDATE(id,username,password_hash,enabled,created_at,"
"is_administrator,authorization_version) "
"ON accounts FROM kf_oj_platform"
)
op.execute(
"GRANT INSERT(id,username,password_hash,enabled,created_at) ON accounts TO kf_oj_platform"
)
op.execute("GRANT UPDATE(enabled) ON accounts TO kf_oj_platform")
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")
op.execute("REVOKE ALL ON accounts,sessions FROM kf_oj_coordinator,kf_oj_problem_import")
# No import queue exists yet. Its role deliberately has no table privileges
# until the import migration grants only the queue/staging tables it needs.
op.execute("""
CREATE 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 $$
""")
op.execute("""
CREATE FUNCTION public.kf_revoke_authority_sessions() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
BEGIN
IF NEW.authorization_version != OLD.authorization_version THEN
UPDATE public.sessions SET revoked_at=coalesce(revoked_at,clock_timestamp())
WHERE account_id=NEW.id AND revoked_at IS NULL;
END IF;
RETURN NEW;
END $$
""")
for function in ("kf_account_authority_version", "kf_revoke_authority_sessions"):
op.execute(f"REVOKE ALL ON FUNCTION public.{function}() FROM PUBLIC")
op.execute("""
CREATE TRIGGER kf_account_authority_version BEFORE UPDATE ON public.accounts
FOR EACH ROW EXECUTE FUNCTION public.kf_account_authority_version()
""")
op.execute("""
CREATE TRIGGER kf_revoke_authority_sessions AFTER UPDATE ON public.accounts
FOR EACH ROW EXECUTE FUNCTION public.kf_revoke_authority_sessions()
""")
def downgrade() -> None:
op.execute("""
DO $$ BEGIN
IF EXISTS(SELECT 1 FROM accounts
WHERE is_administrator OR authorization_version != 1) THEN
RAISE EXCEPTION 'cannot discard authority or revocation history'
USING ERRCODE='23514';
END IF;
END $$
""")
for name in ("kf_revoke_authority_sessions", "kf_account_authority_version"):
op.execute(f"DROP TRIGGER {name} ON public.accounts")
op.execute(f"DROP FUNCTION public.{name}()")
op.execute("REVOKE ALL ON accounts,sessions FROM kf_oj_identity_operator")
op.execute("REVOKE UPDATE(is_administrator,enabled) ON accounts FROM kf_oj_identity_operator")
op.execute("REVOKE UPDATE(revoked_at) ON sessions FROM kf_oj_identity_operator")
op.drop_index("ix_accounts_active_administrators", table_name="accounts")
op.drop_constraint(op.f("ck_accounts_authorization_version"), "accounts", type_="check")
op.drop_column("accounts", "authorization_version")
op.drop_column("accounts", "is_administrator")
op.execute("GRANT INSERT,UPDATE,DELETE ON accounts TO kf_oj_platform")
# Shared NOLOGIN groups survive downgrade, as in the foundation migration;
# removing a cluster role could break another database using the same groups.
@@ -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 $$
""")
@@ -0,0 +1,212 @@
"""Durable imports/candidates, append-only events and accepted-authority worker port."""
import sqlalchemy as sa
from alembic import op
from kf_oj.modules.operations.models import MaintenanceEvent
from kf_oj.modules.problems.import_models import (
CandidateCase,
ImportPool,
ProblemCandidate,
ProblemImportOperation,
)
revision = "0007_problem_imports"
down_revision = "0006_authority_operations"
branch_labels = depends_on = None
def sql(script):
# asyncpg prepares one statement at a time; dollar-quoted bodies stay intact.
buffer, quote, dollar, i = [], False, False, 0
while i < len(script):
if not quote and script[i : i + 2] == "$$":
dollar = not dollar
buffer.append("$$")
i += 2
continue
character = script[i]
if not dollar and character == "'":
if quote and script[i : i + 2] == "''":
buffer.append("''")
i += 2
continue
quote = not quote
if character == ";" and not dollar and not quote:
if "".join(buffer).strip():
op.execute("".join(buffer))
buffer = []
else:
buffer.append(character)
i += 1
if "".join(buffer).strip():
op.execute("".join(buffer))
def upgrade():
connection = op.get_bind()
# Cyclic candidate/operation link is installed only after both tables exist.
for model in (
ImportPool,
ProblemImportOperation,
ProblemCandidate,
CandidateCase,
):
model.__table__.create(connection)
# Freeze the historical shape. A later operations model must not cause a
# fresh database to skip ahead and conflict with a subsequent ALTER migration.
op.create_table(
"problem_maintenance_events",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("actor_id", sa.Uuid(), sa.ForeignKey("accounts.id"), nullable=False),
sa.Column("authorization_version", sa.BigInteger(), nullable=False),
sa.Column("operation_id", sa.Uuid(), nullable=False),
sa.Column("action", sa.String(24), nullable=False),
sa.Column("target_id", sa.Uuid()),
sa.CheckConstraint("authorization_version > 0", name="authority"),
sa.CheckConstraint(
"action IN ('upload_created','upload_received','import_ready','import_failed',"
"'import_discarded','import_expired','published','disabled','restored')",
name="action",
),
)
op.create_index(
"ix_problem_maintenance_events_page", "problem_maintenance_events", ["created_at", "id"]
)
op.create_foreign_key(
"fk_problem_import_candidate",
"problem_import_operations",
"problem_candidates",
["candidate_id"],
["id"],
)
op.execute("INSERT INTO problem_import_pool(id,reserved_bytes) VALUES(1,0)")
sql("""
CREATE FUNCTION public.kf_lock_reserved_maintenance(actor uuid, epoch bigint)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
DECLARE permitted boolean;
BEGIN
SELECT enabled AND is_administrator AND authorization_version=epoch
INTO permitted FROM public.accounts WHERE id=actor FOR SHARE;
IF permitted IS DISTINCT FROM true THEN
RAISE EXCEPTION 'problem maintenance required' USING ERRCODE='42501';
END IF;
END $$;
GRANT CREATE ON SCHEMA public TO kf_oj_identity_authority_owner;
ALTER FUNCTION public.kf_lock_reserved_maintenance(uuid,bigint)
OWNER TO kf_oj_identity_authority_owner;
REVOKE CREATE ON SCHEMA public FROM kf_oj_identity_authority_owner;
REVOKE ALL ON FUNCTION public.kf_lock_reserved_maintenance(uuid,bigint) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.kf_lock_reserved_maintenance(uuid,bigint)
TO kf_oj_problem_import;
GRANT SELECT,INSERT,UPDATE ON problem_import_operations TO kf_oj_platform;
GRANT SELECT,UPDATE ON problem_import_pool TO kf_oj_platform,kf_oj_problem_import;
GRANT SELECT ON problem_candidates,problem_candidate_cases TO kf_oj_platform;
GRANT SELECT ON problem_import_operations TO kf_oj_problem_import;
GRANT UPDATE(state,updated_at,lease_until,attempts,fence,
reserved_bytes,failure_code,candidate_id)
ON problem_import_operations TO kf_oj_problem_import;
GRANT SELECT,INSERT,DELETE ON problem_candidates,problem_candidate_cases
TO kf_oj_problem_import;
GRANT SELECT,INSERT ON artifacts TO kf_oj_problem_import;
GRANT INSERT ON problem_maintenance_events TO kf_oj_platform,kf_oj_problem_import;
GRANT SELECT ON problem_maintenance_events TO kf_oj_platform;
""")
sql("""
CREATE FUNCTION public.kf_immutable_import_candidate() RETURNS trigger
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$
DECLARE candidate uuid; current_state text;
BEGIN
IF TG_TABLE_NAME='problem_candidates' THEN candidate=OLD.id;
ELSE candidate=OLD.candidate_id; END IF;
SELECT o.state INTO current_state FROM public.problem_candidates c
JOIN public.problem_import_operations o ON o.id=c.operation_id WHERE c.id=candidate;
IF TG_OP='UPDATE' OR current_state NOT IN ('discarded','expired','failed') THEN
RAISE EXCEPTION 'immutable candidate' USING ERRCODE='23514';
END IF;
RETURN OLD;
END $$;
CREATE TRIGGER immutable_candidate BEFORE UPDATE OR DELETE ON problem_candidates
FOR EACH ROW EXECUTE FUNCTION public.kf_immutable_import_candidate();
CREATE TRIGGER immutable_candidate_case BEFORE UPDATE OR DELETE ON problem_candidate_cases
FOR EACH ROW EXECUTE FUNCTION public.kf_immutable_import_candidate();
CREATE FUNCTION public.kf_insert_import_candidate() RETURNS trigger
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$
DECLARE operation uuid; current_state text;
BEGIN
IF TG_TABLE_NAME='problem_candidates' THEN operation=NEW.operation_id;
ELSE SELECT operation_id INTO operation FROM public.problem_candidates
WHERE id=NEW.candidate_id; END IF;
SELECT state INTO current_state FROM public.problem_import_operations
WHERE id=operation FOR SHARE;
IF current_state IS DISTINCT FROM 'validating' THEN
RAISE EXCEPTION 'candidate is not being validated' USING ERRCODE='23514';
END IF; RETURN NEW;
END $$;
CREATE TRIGGER insert_candidate BEFORE INSERT ON problem_candidates
FOR EACH ROW EXECUTE FUNCTION public.kf_insert_import_candidate();
CREATE TRIGGER insert_candidate_case BEFORE INSERT ON problem_candidate_cases
FOR EACH ROW EXECUTE FUNCTION public.kf_insert_import_candidate();
CREATE FUNCTION public.kf_fixed_import_receipt() RETURNS trigger
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.owner_id IS DISTINCT FROM OLD.owner_id
OR NEW.authorization_version IS DISTINCT FROM OLD.authorization_version
OR NEW.archive_digest IS DISTINCT FROM OLD.archive_digest
OR NEW.archive_bytes IS DISTINCT FROM OLD.archive_bytes
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR NEW.expires_at IS DISTINCT FROM OLD.expires_at THEN
RAISE EXCEPTION 'immutable import receipt' USING ERRCODE='23514';
END IF;
IF OLD.state IN ('ready','published') AND NEW.state IN ('ready','published')
AND NEW.candidate_id IS DISTINCT FROM OLD.candidate_id THEN
RAISE EXCEPTION 'immutable completed candidate receipt' USING ERRCODE='23514';
END IF;
IF OLD.state='published' AND (NEW.state IS DISTINCT FROM OLD.state
OR NEW.published_version_id IS DISTINCT FROM OLD.published_version_id) THEN
RAISE EXCEPTION 'immutable published import receipt' USING ERRCODE='23514';
END IF;
IF NEW.state='ready' THEN
IF (OLD.state IS DISTINCT FROM 'ready' AND
(NOT pg_has_role(current_user,'kf_oj_problem_import','USAGE')
OR OLD.state!='validating')) OR NEW.candidate_id IS NULL
OR NOT EXISTS(SELECT 1 FROM public.problem_candidates c
WHERE c.id=NEW.candidate_id AND c.operation_id=NEW.id
AND c.case_count=(SELECT count(*) FROM public.problem_candidate_cases b
WHERE b.candidate_id=c.id)
AND c.case_count-1=(SELECT max(position) FROM public.problem_candidate_cases b
WHERE b.candidate_id=c.id)) THEN
RAISE EXCEPTION 'invalid candidate completion' USING ERRCODE='23514';
END IF;
END IF; RETURN NEW;
END $$;
CREATE TRIGGER fixed_import_receipt BEFORE UPDATE ON problem_import_operations
FOR EACH ROW EXECUTE FUNCTION public.kf_fixed_import_receipt();
""")
def downgrade():
connection = op.get_bind()
if connection.exec_driver_sql(
"SELECT EXISTS(SELECT 1 FROM problem_import_operations)"
).scalar():
raise RuntimeError("cannot discard durable import history")
op.execute("DROP FUNCTION public.kf_lock_reserved_maintenance(uuid,bigint)")
op.drop_constraint(
"fk_problem_import_candidate", "problem_import_operations", type_="foreignkey"
)
for model in (
MaintenanceEvent,
CandidateCase,
ProblemCandidate,
ProblemImportOperation,
ImportPool,
):
model.__table__.drop(connection)
sql(
"DROP FUNCTION public.kf_immutable_import_candidate(); "
"DROP FUNCTION public.kf_fixed_import_receipt(); "
"DROP FUNCTION public.kf_insert_import_candidate()"
)
@@ -0,0 +1,117 @@
"""Immutable maintenance receipts, explicit operator audit and read-only history."""
import sqlalchemy as sa
from alembic import op
from kf_oj.modules.problems.maintenance_models import ProblemMaintenanceOperation
revision = "0008_problem_maintenance"
down_revision = "0007_problem_imports"
branch_labels = depends_on = None
OWNER = "kf_oj_identity_authority_owner"
def upgrade():
ProblemMaintenanceOperation.__table__.create(op.get_bind())
op.execute("GRANT SELECT,INSERT ON problem_maintenance_operations TO kf_oj_platform")
op.add_column(
"problem_maintenance_events",
sa.Column("actor_kind", sa.String(8), nullable=False, server_default=sa.text("'account'")),
)
op.add_column("problem_maintenance_events", sa.Column("operator_name", sa.String(63)))
op.alter_column("problem_maintenance_events", "actor_id", nullable=True)
op.drop_constraint(op.f("ck_problem_maintenance_events_action"), "problem_maintenance_events")
op.create_check_constraint(
"action",
"problem_maintenance_events",
"action IN ('upload_created','upload_received','import_ready','import_failed',"
"'import_discarded','import_expired','published','disabled','restored',"
"'authority_bootstrap','authority_grant','authority_revoke','authority_enable','authority_disable')",
)
op.create_check_constraint(
"actor",
"problem_maintenance_events",
"(actor_kind='account' AND actor_id IS NOT NULL AND operator_name IS NULL) OR "
"(actor_kind='operator' AND actor_id IS NULL AND operator_name IS NOT NULL)",
)
op.execute("""CREATE FUNCTION public.kf_append_only_maintenance() RETURNS trigger
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN
RAISE EXCEPTION 'append-only maintenance history' USING ERRCODE='23514'; END $$""")
for table in ("problem_maintenance_operations", "problem_maintenance_events"):
op.execute(f"""CREATE TRIGGER append_only_maintenance BEFORE UPDATE OR DELETE ON {table}
FOR EACH ROW EXECUTE FUNCTION public.kf_append_only_maintenance()""")
# Preserve the already-verified authority implementation; its only entry
# point becomes a fixed audited wrapper owned by the same narrow NOLOGIN role.
op.execute(
"ALTER FUNCTION public.kf_manage_account_authority(text,text) "
"RENAME TO kf_manage_account_authority_unlogged"
)
op.execute(
"REVOKE ALL ON FUNCTION public.kf_manage_account_authority_unlogged(text,text) "
"FROM PUBLIC,kf_oj_identity_operator"
)
op.execute(f"GRANT INSERT ON problem_maintenance_events TO {OWNER}")
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 result record; BEGIN
SELECT * INTO STRICT result FROM
public.kf_manage_account_authority_unlogged(p_username,p_operation);
IF result.changed THEN
INSERT INTO public.problem_maintenance_events
(id,created_at,actor_id,actor_kind,operator_name,authorization_version,
operation_id,action,target_id)
VALUES (gen_random_uuid(),clock_timestamp(),NULL,'operator',session_user,
result.authorization_version,gen_random_uuid(),'authority_' || p_operation,
result.account_id);
END IF;
RETURN QUERY SELECT result.account_id,result.username,result.enabled,
result.is_administrator,result.authorization_version,result.changed;
END $$""")
op.execute(f"GRANT CREATE ON SCHEMA public TO {OWNER}")
op.execute(f"ALTER FUNCTION public.kf_manage_account_authority(text,text) OWNER TO {OWNER}")
op.execute(f"REVOKE CREATE ON SCHEMA public FROM {OWNER}")
op.execute("REVOKE ALL ON FUNCTION public.kf_manage_account_authority(text,text) FROM PUBLIC")
op.execute(
"GRANT EXECUTE ON FUNCTION public.kf_manage_account_authority(text,text) "
"TO kf_oj_identity_operator"
)
def downgrade():
if (
op.get_bind()
.exec_driver_sql(
"SELECT EXISTS(SELECT 1 FROM problem_maintenance_operations) OR "
"EXISTS(SELECT 1 FROM problem_maintenance_events WHERE actor_kind='operator')"
)
.scalar()
):
raise RuntimeError("cannot discard committed maintenance history")
op.execute("DROP FUNCTION public.kf_manage_account_authority(text,text)")
op.execute(
"ALTER FUNCTION public.kf_manage_account_authority_unlogged(text,text) "
"RENAME TO kf_manage_account_authority"
)
op.execute(
"GRANT EXECUTE ON FUNCTION public.kf_manage_account_authority(text,text) "
"TO kf_oj_identity_operator"
)
op.execute(f"REVOKE INSERT ON problem_maintenance_events FROM {OWNER}")
for table in ("problem_maintenance_operations", "problem_maintenance_events"):
op.execute(f"DROP TRIGGER append_only_maintenance ON {table}")
op.execute("DROP FUNCTION public.kf_append_only_maintenance()")
ProblemMaintenanceOperation.__table__.drop(op.get_bind())
op.drop_constraint(op.f("ck_problem_maintenance_events_actor"), "problem_maintenance_events")
op.drop_constraint(op.f("ck_problem_maintenance_events_action"), "problem_maintenance_events")
op.create_check_constraint(
"action",
"problem_maintenance_events",
"action IN ('upload_created','upload_received','import_ready','import_failed',"
"'import_discarded','import_expired','published','disabled','restored')",
)
op.alter_column("problem_maintenance_events", "actor_id", nullable=False)
op.drop_column("problem_maintenance_events", "operator_name")
op.drop_column("problem_maintenance_events", "actor_kind")
+6 -1
View File
@@ -17,9 +17,15 @@ dependencies = [
"asyncpg>=0.30,<1",
"alembic>=1.15,<2",
"aio-pika>=9.5,<10",
"argon2-cffi>=25.1,<26",
"httpx>=0.28,<1",
]
[project.scripts]
kf-problem-import-worker = "kf_oj.workers.problem_import:main"
kf-accounts = "kf_oj.modules.identity.cli:main"
kf-problems = "kf_oj.modules.problems.cli:main"
kf-artifacts = "kf_oj.infra.artifacts.cli:main"
kf-learning-worker = "kf_oj.workers.learning:main"
kf-outbox-publisher = "kf_oj.workers.outbox:main"
@@ -27,7 +33,6 @@ kf-outbox-publisher = "kf_oj.workers.outbox:main"
dev = [
"ruff>=0.11,<1",
"pytest>=8.3,<10",
"httpx>=0.28,<1",
"jsonschema>=4.23,<5",
]
@@ -0,0 +1,26 @@
"""Uniform live session gates; domains still lock/recheck authority in write transactions."""
from typing import Annotated
from fastapi import Depends, HTTPException
from kf_oj.api.dependencies import current_principal, unsafe_principal
from kf_oj.modules.identity.public import Principal
async def maintenance_reader(
principal: Annotated[Principal, Depends(current_principal)],
) -> Principal:
if not principal.can_maintain_problems:
raise HTTPException(403, "problem maintenance required")
return principal
async def maintenance_writer(
principal: Annotated[Principal, Depends(unsafe_principal)],
) -> Principal:
# unsafe_principal already checks an explicit Origin and the live session's
# bound CSRF token. This capability check cannot replace the domain's port.
if not principal.can_maintain_problems:
raise HTTPException(403, "problem maintenance required")
return principal
+121 -1
View File
@@ -2,11 +2,35 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from starlette.responses import JSONResponse
from kf_oj import __version__
from kf_oj.api.body_limits import BodyLimitMiddleware
from kf_oj.api.v1.admin_imports import router as import_router
from kf_oj.api.v1.admin_problems import router as maintenance_router
from kf_oj.api.v1.identity import router as identity_router
from kf_oj.api.v1.problems import router as problems_router
from kf_oj.api.v1.router import router
from kf_oj.api.v1.submissions import router as submission_router
from kf_oj.api.v1.worker_artifacts import router as worker_router
from kf_oj.core.logging import configure_logging
from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.staging import ImportStaging
from kf_oj.infra.artifacts.store import ArtifactStore
from kf_oj.infra.coordinator import CoordinatorControl
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.identity.service import IdentityService
from kf_oj.modules.operations.queries import MaintenanceAuditQueries
from kf_oj.modules.operations.service import MaintenanceAudit
from kf_oj.modules.problems.imports import ProblemImports
from kf_oj.modules.problems.maintenance import ProblemMaintenance
from kf_oj.modules.problems.service import ProblemService
from kf_oj.modules.submissions.performance import SubmissionPerformanceQueries
from kf_oj.modules.submissions.queries import SubmissionQueries
from kf_oj.modules.submissions.service import SubmissionService
from kf_oj.modules.submissions.worker_access import WorkerAccess
def create_app(settings: Settings | None = None) -> FastAPI:
@@ -17,20 +41,116 @@ def create_app(settings: Settings | None = None) -> FastAPI:
configure_logging(settings.log_level)
logger = logging.getLogger("kf_oj.api")
logger.info("Platform API started")
engine = None
store = None
staging = None
try:
if settings.database_url is not None:
engine = build_engine(settings)
app.state.sessions = build_session_factory(engine)
app.state.identity = IdentityService(
app.state.sessions, lifetime_seconds=settings.session_lifetime_seconds
)
app.state.submission_performance = SubmissionPerformanceQueries(app.state.sessions)
artifacts = None
if settings.artifact_root is not None:
store = ArtifactStore(
settings.artifact_root,
max_bytes=settings.artifact_max_bytes,
max_files=settings.artifact_max_files,
)
artifacts = ArtifactRepository(store)
if (
settings.import_staging_root is not None
and settings.judge_admission is not None
):
staging = ImportStaging(settings.import_staging_root)
app.state.problem_imports = ProblemImports(
app.state.sessions,
staging,
app.state.identity,
MaintenanceAudit(),
settings.judge_admission,
)
app.state.problem_maintenance = ProblemMaintenance(
app.state.sessions,
app.state.identity,
MaintenanceAudit(),
artifacts,
settings.judge_admission,
)
app.state.maintenance_audit = MaintenanceAuditQueries(
app.state.sessions, app.state.identity
)
app.state.problems = ProblemService(
app.state.sessions, artifacts, settings.judge_admission
)
if artifacts is not None:
app.state.submissions = SubmissionService(
app.state.sessions, app.state.problems, artifacts, settings.judge_admission
)
app.state.worker_access = WorkerAccess(
app.state.sessions, app.state.problems, artifacts
)
cancellation = (
CoordinatorControl(
settings.coordinator_url, settings.coordinator_token.get_secret_value()
)
if settings.coordinator_url and settings.coordinator_token
else None
)
app.state.submission_queries = SubmissionQueries(
app.state.sessions, artifacts, cancellation
)
yield
finally:
app.state.identity = None
app.state.problems = None
app.state.worker_access = None
app.state.submissions = None
app.state.submission_queries = None
app.state.submission_performance = None
app.state.problem_imports = None
app.state.problem_maintenance = None
app.state.maintenance_audit = None
if engine is not None:
await engine.dispose()
if store is not None:
store.close()
if staging is not None:
staging.close()
logger.info("Platform API stopped")
app = FastAPI(
title="KF-OnlineJudge Platform API",
version=__version__,
description="工程骨架。当前只提供进程存活检查,业务接口逐项实现。",
description="平台 API。身份使用真实可撤销会话,业务接口按模块逐项接入。",
docs_url="/api/docs" if settings.environment != "production" else None,
redoc_url=None,
openapi_url="/api/openapi.json" if settings.environment != "production" else None,
lifespan=lifespan,
)
app.state.settings = settings
app.add_middleware(BodyLimitMiddleware)
@app.middleware("http")
async def private_responses(request, call_next):
response = await call_next(request)
if request.url.path.startswith(
("/api/v1/auth", "/api/v1/submissions", "/api/v1/internal", "/api/v1/admin")
):
response.headers["Cache-Control"] = "no-store"
return response
@app.exception_handler(RequestValidationError)
async def invalid_request(_, __):
return JSONResponse({"detail": "invalid request"}, status_code=422)
app.include_router(router, prefix="/api/v1")
app.include_router(identity_router, prefix="/api/v1")
app.include_router(problems_router, prefix="/api/v1")
app.include_router(worker_router, prefix="/api/v1")
app.include_router(submission_router, prefix="/api/v1")
app.include_router(import_router, prefix="/api/v1")
app.include_router(maintenance_router, prefix="/api/v1")
return app
+75
View File
@@ -0,0 +1,75 @@
"""Bound request bytes and upload time before schema parsing can allocate large inputs."""
import asyncio
import re
from starlette.responses import JSONResponse
class BodyLimitMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope["method"] in {"GET", "HEAD", "OPTIONS"}:
await self.app(scope, receive, send)
return
if scope["method"] == "PUT" and re.fullmatch(
r"/api/v1/admin/problem-imports/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/archive",
scope["path"],
):
# Only this exact endpoint can stream. Its live administrator gate runs
# before receive(), then its service enforces actual bytes/chunks/deadline.
await self.app(scope, receive, send)
return
limit = 8192 if scope["path"] == "/api/v1/auth/login" else 1024 * 1024
if scope["path"] == "/api/v1/submissions":
# Max256KiB UTF-8 source, worst-case JSON escaping plus bounded metadata.
limit = 6 * 256 * 1024 + 8192
headers = dict(scope.get("headers", []))
length = headers.get(b"content-length")
if length is not None:
try:
size = int(length)
if size < 0 or size > limit:
raise ValueError
except ValueError:
await JSONResponse({"detail": "request body outside byte policy"}, 413)(
scope, receive, send
)
return
# Buffer a finite body so errors can be returned before response headers are sent.
chunks, size, received_chunks = [], 0, 0
try:
async with asyncio.timeout(10):
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
body = message.get("body", b"")
size += len(body)
received_chunks += 1
if size > limit or received_chunks > 1024:
await JSONResponse({"detail": "request body outside byte policy"}, 413)(
scope, receive, send
)
return
chunks.append(body)
if not message.get("more_body", False):
break
except TimeoutError:
await JSONResponse({"detail": "request body deadline exceeded"}, 408)(
scope, receive, send
)
return
body = b"".join(chunks)
delivered = False
async def bounded_receive():
nonlocal delivered
if not delivered:
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return await receive()
await self.app(scope, bounded_receive, send)
+50
View File
@@ -0,0 +1,50 @@
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from kf_oj.core.settings import Settings
from kf_oj.modules.identity.public import AuthenticationFailed, Principal
from kf_oj.modules.identity.service import IdentityService
SESSION_COOKIE = "kf_session"
def identity_service(request: Request) -> IdentityService:
service = getattr(request.app.state, "identity", None)
if service is None:
raise HTTPException(503, "identity service unavailable")
return service
def trusted_origin(request: Request) -> None:
settings: Settings = request.app.state.settings
if request.headers.get("origin") not in settings.browser_origins:
raise HTTPException(403, "request origin rejected")
if request.headers.get("sec-fetch-site") == "cross-site":
raise HTTPException(403, "cross-site request rejected")
async def current_principal(
request: Request, identity: Annotated[IdentityService, Depends(identity_service)]
) -> Principal:
try:
return await identity.authenticate(request.cookies.get(SESSION_COOKIE))
except AuthenticationFailed:
raise HTTPException(401, "authentication required") from None
async def unsafe_principal(
request: Request, identity: Annotated[IdentityService, Depends(identity_service)]
) -> Principal:
trusted_origin(request)
try:
await identity.authenticate(request.cookies.get(SESSION_COOKIE))
except AuthenticationFailed:
raise HTTPException(401, "authentication required") from None
csrf = request.headers.get("x-csrf-token")
if csrf is None:
raise HTTPException(403, "request token required")
try:
return await identity.authenticate(request.cookies.get(SESSION_COOKIE), csrf_token=csrf)
except AuthenticationFailed:
raise HTTPException(403, "request authentication rejected") from None
+139
View File
@@ -0,0 +1,139 @@
"""Administrator import contract; configured service availability is explicit."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.exc import DBAPIError
from starlette.requests import ClientDisconnect
from kf_oj.api.admin_dependencies import maintenance_reader, maintenance_writer
from kf_oj.contracts.admin_problems import (
CreateProblemImport,
ProblemImportCapabilities,
ProblemImportDetail,
ProblemImportPage,
ProblemImportSummary,
)
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import MaintenanceDenied, Principal
from kf_oj.modules.problems.imports import ImportConflict, ImportMissing, ImportUnavailable
router = APIRouter(prefix="/admin/problem-imports", tags=["problem-maintenance"])
@router.get("/capabilities", response_model=ProblemImportCapabilities)
async def capabilities(
request: Request, _: Annotated[Principal, Depends(maintenance_reader)]
) -> ProblemImportCapabilities:
service = getattr(request.app.state, "problem_imports", None)
if service is None:
return ProblemImportCapabilities(uploads_available=False, validation_available=False)
return await service.capabilities()
def service(request):
result = getattr(request.app.state, "problem_imports", None)
if result is None:
raise HTTPException(503, "problem imports unavailable")
return result
async def operation(awaitable):
try:
return await awaitable
except MaintenanceDenied:
raise HTTPException(403, "problem maintenance required") from None
except ImportConflict:
raise HTTPException(409, "import request conflict") from None
except ImportMissing:
raise HTTPException(404, "import unavailable") from None
except TimeoutError:
raise HTTPException(408, "upload deadline exceeded") from None
except (ImportUnavailable, ArtifactError, OSError, DBAPIError):
raise HTTPException(503, "problem imports unavailable") from None
except ClientDisconnect:
raise HTTPException(400, "upload interrupted") from None
@router.post("", status_code=201, response_model=ProblemImportSummary)
async def create(
body: CreateProblemImport,
request: Request,
principal: Annotated[Principal, Depends(maintenance_writer)],
):
if request.headers.get("content-type", "").split(";", 1)[0] != "application/json":
raise HTTPException(415, "JSON request required")
return await operation(service(request).create(principal, body))
@router.get("", response_model=ProblemImportPage)
async def page(
request: Request,
principal: Annotated[Principal, Depends(maintenance_reader)],
after: UUID | None = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
return await operation(service(request).list(principal, after, limit))
@router.get("/{operation_id}", response_model=ProblemImportDetail)
async def detail(
operation_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(maintenance_reader)],
):
return await operation(service(request).detail(principal, operation_id))
@router.put(
"/{operation_id}/archive",
response_model=ProblemImportSummary,
openapi_extra={
"requestBody": {
"required": True,
"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}},
}
},
)
async def upload(
operation_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(maintenance_writer)],
):
if request.url.path != f"/api/v1/admin/problem-imports/{operation_id}/archive":
raise HTTPException(422, "canonical operation UUID required")
if request.headers.get("content-type", "").split(";", 1)[0] != "application/zip":
raise HTTPException(415, "ZIP request required")
if request.headers.get("content-encoding") not in (None, "identity"):
raise HTTPException(415, "encoded upload unavailable")
length = request.headers.get("content-length")
if length is not None:
try:
if not length.isascii() or not length.isdecimal() or len(length) > 12:
raise ValueError
length = int(length)
except ValueError:
raise HTTPException(413, "invalid archive length") from None
async def chunks():
while True:
message = await request.receive()
if message["type"] == "http.disconnect":
raise ClientDisconnect()
if message["type"] != "http.request":
raise ImportConflict("invalid upload frame")
yield message.get("body", b"")
if not message.get("more_body", False):
return
return await operation(service(request).upload(principal, operation_id, chunks(), length))
@router.post("/{operation_id}/discard", response_model=ProblemImportSummary)
async def discard(
operation_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(maintenance_writer)],
):
return await operation(service(request).discard(principal, operation_id))
+150
View File
@@ -0,0 +1,150 @@
"""Live administrator projections and actor-bound immutable mutations."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.exc import DBAPIError
from kf_oj.api.admin_dependencies import maintenance_reader, maintenance_writer
from kf_oj.contracts.problem_maintenance import (
AuditAction,
ChangeProblemAvailability,
MaintainedProblem,
MaintainedProblemPage,
MaintainedVersionPage,
MaintenanceAuditPage,
MaintenanceReceipt,
PublishCandidate,
)
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import MaintenanceDenied, Principal
from kf_oj.modules.problems.imports import ImportConflict, ImportMissing, ImportUnavailable
from kf_oj.modules.problems.public import ProblemUnavailable
router = APIRouter(prefix="/admin", tags=["problem-maintenance"])
def service(request, name="problem_maintenance"):
result = getattr(request.app.state, name, None)
if result is None:
raise HTTPException(503, "problem maintenance unavailable")
return result
async def operation(awaitable):
try:
return await awaitable
except MaintenanceDenied:
raise HTTPException(403, "problem maintenance required") from None
except ImportConflict:
raise HTTPException(409, "maintenance precondition conflict") from None
except ImportMissing:
raise HTTPException(404, "maintenance object unavailable") from None
except (ImportUnavailable, ArtifactError, ProblemUnavailable, OSError, DBAPIError):
raise HTTPException(503, "problem maintenance unavailable") from None
except ValueError:
raise HTTPException(422, "invalid maintenance request") from None
@router.get("/problems", response_model=MaintainedProblemPage)
async def page(
request: Request,
principal: Annotated[Principal, Depends(maintenance_reader)],
after: Annotated[str | None, Query(pattern=r"^[a-z0-9][a-z0-9_.-]{0,63}$")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
enabled: bool | None = None,
):
return await operation(
service(request).page(principal, after=after, limit=limit, enabled=enabled)
)
@router.get("/problem-slugs/{slug}", response_model=MaintainedProblem)
async def by_slug(
slug: str, request: Request, principal: Annotated[Principal, Depends(maintenance_reader)]
):
from pydantic import TypeAdapter
from kf_oj.contracts.judging import Label
try:
TypeAdapter(Label).validate_python(slug)
except ValueError:
raise HTTPException(422, "invalid problem slug") from None
return await operation(service(request).by_slug(principal, slug))
@router.get("/problems/{problem_id}", response_model=MaintainedProblem)
async def detail(
problem_id: UUID, request: Request, principal: Annotated[Principal, Depends(maintenance_reader)]
):
return await operation(service(request).detail(principal, problem_id))
@router.get("/problems/{problem_id}/versions", response_model=MaintainedVersionPage)
async def versions(
problem_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(maintenance_reader)],
before: Annotated[int | None, Query(ge=1)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
return await operation(
service(request).versions(principal, problem_id, before=before, limit=limit)
)
@router.get("/problem-maintenance/{operation_id}", response_model=MaintenanceReceipt)
async def receipt(
operation_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(maintenance_reader)],
):
return await operation(service(request).receipt(principal, operation_id))
@router.post("/problem-publications", response_model=MaintenanceReceipt, status_code=201)
async def publish(
body: PublishCandidate,
request: Request,
principal: Annotated[Principal, Depends(maintenance_writer)],
):
return await operation(service(request).publish(principal, body))
@router.post("/problems/{problem_id}/disable", response_model=MaintenanceReceipt)
async def disable(
problem_id: UUID,
body: ChangeProblemAvailability,
request: Request,
principal: Annotated[Principal, Depends(maintenance_writer)],
):
return await operation(
service(request).availability(principal, problem_id, body, enabled=False)
)
@router.post("/problems/{problem_id}/restore", response_model=MaintenanceReceipt)
async def restore(
problem_id: UUID,
body: ChangeProblemAvailability,
request: Request,
principal: Annotated[Principal, Depends(maintenance_writer)],
):
return await operation(service(request).availability(principal, problem_id, body, enabled=True))
@router.get("/maintenance-audit", response_model=MaintenanceAuditPage)
async def audit(
request: Request,
principal: Annotated[Principal, Depends(maintenance_reader)],
before: Annotated[str | None, Query(max_length=100)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
action: AuditAction | None = None,
):
return await operation(
service(request, "maintenance_audit").page(
principal, before=before, limit=limit, action=action
)
)
+106
View File
@@ -0,0 +1,106 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from kf_oj.api.dependencies import (
SESSION_COOKIE,
current_principal,
identity_service,
trusted_origin,
unsafe_principal,
)
from kf_oj.contracts.identity import AccountCapabilities, CurrentUser, LoginRequest, LoginResponse
from kf_oj.modules.identity.public import AuthenticationFailed, LoginThrottled, Principal
from kf_oj.modules.identity.service import IdentityService
router = APIRouter(prefix="/auth", tags=["identity"])
def current_user(principal: Principal) -> CurrentUser:
return CurrentUser(
id=principal.account_id,
username=principal.username,
capabilities=AccountCapabilities(
problem_maintenance=principal.can_maintain_problems,
authorization_version=str(principal.authorization_version),
),
)
@router.post("/login", response_model=LoginResponse, dependencies=[Depends(trusted_origin)])
async def login(
body: LoginRequest,
request: Request,
response: Response,
identity: Annotated[IdentityService, Depends(identity_service)],
) -> LoginResponse:
if request.headers.get("content-type", "").split(";", 1)[0] != "application/json":
raise HTTPException(415, "JSON request required")
try:
issued = await identity.login(
body.username,
body.password.get_secret_value(),
peer=request.client.host if request.client else "",
)
except LoginThrottled:
raise HTTPException(
429, "login temporarily limited", headers={"Retry-After": "900"}
) from None
except (AuthenticationFailed, ValueError):
raise HTTPException(401, "invalid credentials") from None
settings = request.app.state.settings
response.set_cookie(
SESSION_COOKIE,
issued.token,
max_age=settings.session_lifetime_seconds,
httponly=True,
secure=settings.environment == "production",
samesite="strict",
path="/api/v1",
)
response.set_cookie(
"kf_csrf",
issued.csrf_token,
max_age=settings.session_lifetime_seconds,
httponly=False,
secure=settings.environment == "production",
samesite="strict",
path="/",
)
response.headers["Cache-Control"] = "no-store"
return LoginResponse(
user=current_user(issued.principal),
expires_at=issued.expires_at,
csrf_token=issued.csrf_token,
)
@router.get("/me", response_model=CurrentUser)
async def me(response: Response, principal: Annotated[Principal, Depends(current_principal)]):
response.headers["Cache-Control"] = "no-store"
return current_user(principal)
@router.post("/logout", status_code=204, dependencies=[Depends(unsafe_principal)])
async def logout(
request: Request, identity: Annotated[IdentityService, Depends(identity_service)]
) -> Response:
try:
await identity.logout(request.cookies[SESSION_COOKIE], request.headers["x-csrf-token"])
except AuthenticationFailed:
raise HTTPException(401, "authentication required") from None
response = Response(status_code=204, headers={"Cache-Control": "no-store"})
response.delete_cookie(
SESSION_COOKIE,
path="/api/v1",
httponly=True,
secure=request.app.state.settings.environment == "production",
samesite="strict",
)
response.delete_cookie(
"kf_csrf",
path="/",
secure=request.app.state.settings.environment == "production",
samesite="strict",
)
return response
+34
View File
@@ -0,0 +1,34 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Request
from kf_oj.contracts.problems import ProblemDetail, ProblemPage
from kf_oj.modules.problems.public import ProblemUnavailable
from kf_oj.modules.problems.service import ProblemService
router = APIRouter(prefix="/problems", tags=["problems"])
def service(request: Request) -> ProblemService:
component = getattr(request.app.state, "problems", None)
if component is None:
raise HTTPException(503, "problem service unavailable")
return component
@router.get("", response_model=ProblemPage)
async def list_problems(
request: Request,
after: Annotated[str | None, Query(pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> ProblemPage:
return await service(request).list(after=after, limit=limit)
@router.get("/versions/{version_id}", response_model=ProblemDetail)
async def problem_version(version_id: UUID, request: Request) -> ProblemDetail:
try:
return await service(request).detail(version_id)
except ProblemUnavailable:
raise HTTPException(404, "problem version unavailable") from None
+140
View File
@@ -0,0 +1,140 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from kf_oj.api.dependencies import current_principal, unsafe_principal
from kf_oj.contracts.judging_v2 import Language
from kf_oj.contracts.submission_performance import SubmissionPerformance
from kf_oj.contracts.submissions import (
CancellationAccepted,
CreateSubmission,
SubmissionCreated,
SubmissionDetail,
SubmissionPage,
SubmissionSource,
)
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import Principal
from kf_oj.modules.submissions.public import (
CancelUnavailable,
IdempotencyConflict,
SubmissionBusy,
SubmissionUnavailable,
)
router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.post("", status_code=201, response_model=SubmissionCreated)
async def create(
body: CreateSubmission,
request: Request,
response: Response,
principal: Annotated[Principal, Depends(unsafe_principal)],
):
if request.headers.get("content-type", "").split(";", 1)[0] != "application/json":
raise HTTPException(415, "JSON request required")
service = getattr(request.app.state, "submissions", None)
if service is None:
raise HTTPException(503, "submission service unavailable")
try:
result = await service.create(principal, body)
except IdempotencyConflict:
raise HTTPException(409, "idempotency request conflict") from None
except SubmissionBusy:
raise HTTPException(429, "pending submission capacity exhausted") from None
except SubmissionUnavailable:
raise HTTPException(404, "submission capability unavailable") from None
except ArtifactError:
raise HTTPException(503, "source storage unavailable") from None
except ValueError:
raise HTTPException(422, "submission outside admission policy") from None
response.headers["Cache-Control"] = "no-store"
return result
def queries(request: Request):
service = getattr(request.app.state, "submission_queries", None)
if service is None:
raise HTTPException(503, "submission service unavailable")
return service
@router.get("", response_model=SubmissionPage)
async def history(
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
after: Annotated[str | None, Query(max_length=128)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
problem_version_id: UUID | None = None,
language: Language | None = None,
):
try:
return await queries(request).page(principal, after, limit, problem_version_id, language)
except ValueError:
raise HTTPException(422, "invalid history request") from None
@router.get("/{submission_id}", response_model=SubmissionDetail)
async def detail(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
):
try:
return await queries(request).detail(principal, submission_id)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except ValueError:
raise HTTPException(503, "stored result unavailable") from None
@router.get("/{submission_id}/performance", response_model=SubmissionPerformance)
async def performance(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
):
service = getattr(request.app.state, "submission_performance", None)
if service is None:
raise HTTPException(503, "performance service unavailable")
try:
return await service.detail(principal, submission_id)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except ValueError:
raise HTTPException(503, "performance unavailable") from None
@router.get("/{submission_id}/source", response_model=SubmissionSource)
async def source(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(current_principal)],
):
service = queries(request)
try:
result = await service.detail(principal, submission_id)
data = await service.owned_source(principal, submission_id)
return SubmissionSource(
submission_id=submission_id, language=result.language, source=data.decode("utf-8")
)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except ValueError:
raise HTTPException(503, "stored source unavailable") from None
@router.post("/{submission_id}/cancel", status_code=202, response_model=CancellationAccepted)
async def cancel(
submission_id: UUID,
request: Request,
principal: Annotated[Principal, Depends(unsafe_principal)],
):
try:
return await queries(request).cancel(principal, submission_id)
except SubmissionUnavailable:
raise HTTPException(404, "submission unavailable") from None
except CancelUnavailable:
raise HTTPException(503, "cancellation service unavailable") from None
@@ -0,0 +1,96 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Header, HTTPException, Request, Response, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from kf_oj.contracts.judging import JobManifest, LeaseBinding
from kf_oj.modules.submissions.public import WorkerAccessDenied, WorkerReadBusy
from kf_oj.modules.submissions.worker_access import WorkerAccess
router = APIRouter(prefix="/internal/evaluations", tags=["worker artifacts"])
bearer = HTTPBearer(auto_error=False, scheme_name="WorkerLease")
def grant(
evaluation_id: UUID,
attempt_id: UUID,
worker: UUID,
fence: int,
credentials: HTTPAuthorizationCredentials | None,
) -> tuple[LeaseBinding, str]:
if (
credentials is None
or credentials.scheme.lower() != "bearer"
or len(credentials.credentials) != 64
):
raise HTTPException(401, "worker lease required")
try:
return LeaseBinding(
evaluation_id=evaluation_id,
attempt_id=attempt_id,
worker_id=worker,
fencing_version=fence,
), credentials.credentials
except ValueError:
raise HTTPException(404, "worker grant rejected") from None
def component(request: Request) -> WorkerAccess:
access = getattr(request.app.state, "worker_access", None)
if access is None:
raise HTTPException(503, "worker artifact service unavailable")
return access
@router.get("/{evaluation_id}/attempts/{attempt_id}/manifest", response_model=JobManifest)
async def manifest(
evaluation_id: UUID,
attempt_id: UUID,
request: Request,
worker: Annotated[UUID, Header(alias="X-Worker-ID")],
fence: Annotated[int, Header(alias="X-Fencing-Version", ge=1)],
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer)],
):
binding, token = grant(evaluation_id, attempt_id, worker, fence, credentials)
try:
payload = await component(request).manifest(binding, token)
except WorkerAccessDenied:
raise HTTPException(404, "worker grant rejected") from None
except WorkerReadBusy:
raise HTTPException(429, "worker reads temporarily limited") from None
return Response(payload, media_type="application/json", headers={"Cache-Control": "no-store"})
@router.get(
"/{evaluation_id}/attempts/{attempt_id}/artifacts/{digest}",
response_class=Response,
responses={
200: {
"content": {
"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}
}
}
},
)
async def artifact(
evaluation_id: UUID,
attempt_id: UUID,
digest: str,
request: Request,
worker: Annotated[UUID, Header(alias="X-Worker-ID")],
fence: Annotated[int, Header(alias="X-Fencing-Version", ge=1)],
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer)],
):
binding, token = grant(evaluation_id, attempt_id, worker, fence, credentials)
try:
payload = await component(request).artifact(binding, token, digest)
except WorkerAccessDenied:
raise HTTPException(404, "worker grant rejected") from None
except WorkerReadBusy:
raise HTTPException(429, "worker reads temporarily limited") from None
return Response(
payload,
media_type="application/octet-stream",
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
)
@@ -0,0 +1,180 @@
"""Versioned problem-import DTOs; hidden bytes, paths and toolchain selection stay private."""
from datetime import datetime
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import Field, StrictBool, model_validator
from kf_oj.contracts.judging import Digest, Identifier, Label, RunLimits, Version, WireModel
from kf_oj.contracts.judging_v2 import Language
from kf_oj.contracts.problems import PublicLanguageConfiguration, PublicSample
MAX_ARCHIVE_BYTES = 64 * 1024**2
MAX_UNPACKED_BYTES = 64 * 1024**2
MAX_ARCHIVE_FILES = 534
MAX_ARCHIVE_NAME_BYTES = 128
MAX_UPLOAD_CHUNKS = 4096
MAX_UPLOAD_CHUNK_BYTES = 1024**2
UPLOAD_DEADLINE_SECONDS = 30
VALIDATION_DEADLINE_SECONDS = 60
MAX_IMPORT_ATTEMPTS = 2
IMPORT_LIFETIME_SECONDS = 86400
MAX_OWNER_CANDIDATES = 10
MAX_TOTAL_CANDIDATES = 32
STAGING_MAX_BYTES = 256 * 1024**2
STAGING_RESERVATION_BYTES = MAX_ARCHIVE_BYTES + MAX_UNPACKED_BYTES
class ImportState(StrEnum):
UPLOADING = "uploading"
UPLOADED = "uploaded"
VALIDATING = "validating"
READY = "ready"
FAILED = "failed"
PUBLISHED = "published"
DISCARDED = "discarded"
EXPIRED = "expired"
class ImportFailure(StrEnum):
INVALID_ARCHIVE = "invalid_archive"
INVALID_MANIFEST = "invalid_manifest"
INVALID_DATA = "invalid_data"
DIGEST_MISMATCH = "digest_mismatch"
POLICY_REJECTED = "policy_rejected"
UPLOAD_INTERRUPTED = "upload_interrupted"
UPLOAD_DEADLINE = "upload_deadline"
VALIDATION_DEADLINE = "validation_deadline"
VALIDATION_UNAVAILABLE = "validation_unavailable"
STORAGE_UNAVAILABLE = "storage_unavailable"
AUTHORITY_REVOKED = "authority_revoked"
EXPIRED = "expired"
class ProblemImportLimits(WireModel):
schema_version: Version = 1
archive_format: Literal["single_level_zip"] = "single_level_zip"
manifest_name: Literal["problem.json"] = "problem.json"
manifest_schema_versions: tuple[Literal[1, 2], ...] = (1, 2)
languages: tuple[Language, ...] = tuple(Language)
archive_bytes: int = MAX_ARCHIVE_BYTES
unpacked_bytes: int = MAX_UNPACKED_BYTES
files: int = MAX_ARCHIVE_FILES
filename_ascii_bytes: int = MAX_ARCHIVE_NAME_BYTES
upload_chunks: int = MAX_UPLOAD_CHUNKS
chunk_bytes: int = MAX_UPLOAD_CHUNK_BYTES
upload_deadline_seconds: int = UPLOAD_DEADLINE_SECONDS
validation_deadline_seconds: int = VALIDATION_DEADLINE_SECONDS
global_concurrency: Literal[1] = 1
owner_concurrency: Literal[1] = 1
owner_candidates: int = MAX_OWNER_CANDIDATES
total_candidates: int = MAX_TOTAL_CANDIDATES
staging_bytes: int = STAGING_MAX_BYTES
candidate_lifetime_seconds: int = IMPORT_LIFETIME_SECONDS
class ProblemImportCapabilities(WireModel):
schema_version: Version = 1
limits: ProblemImportLimits = ProblemImportLimits()
uploads_available: StrictBool
validation_available: StrictBool
class CreateProblemImport(WireModel):
schema_version: Version = 1
operation_id: Identifier
archive_digest: Digest
archive_bytes: Annotated[int, Field(strict=True, ge=1, le=MAX_ARCHIVE_BYTES)]
class LegacyImportBudget(WireModel):
compile_limits: RunLimits
run_limits: RunLimits
job_wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=3600000000)]
class CandidatePreview(WireModel):
candidate_id: Identifier
slug: Label
title: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
statement: Annotated[str, Field(strict=True, min_length=1, max_length=262144)]
samples: Annotated[tuple[PublicSample, ...], Field(max_length=10)]
judging_schema_version: Annotated[int, Field(strict=True, ge=1, le=2)]
languages: Annotated[tuple[Language, ...], Field(min_length=1, max_length=4)]
legacy_budget: LegacyImportBudget | None
language_configurations: Annotated[
tuple[PublicLanguageConfiguration, ...], Field(max_length=4)
] = ()
case_count: Annotated[int, Field(strict=True, ge=1, le=256)]
@model_validator(mode="after")
def bounded_text(self):
if len(self.statement.encode("utf-8")) > 262144 or "\x00" in self.statement + self.title:
raise ValueError("invalid public text")
languages = [c.language for c in self.language_configurations]
if len(set(languages)) != len(languages):
raise ValueError("duplicate language configuration")
if len(set(self.languages)) != len(self.languages):
raise ValueError("duplicate language")
if self.judging_schema_version == 1:
if (
self.language_configurations
or self.legacy_budget is None
or any(
language not in (Language.C17, Language.CPP20) for language in self.languages
)
):
raise ValueError("invalid legacy candidate")
elif tuple(languages) != self.languages or self.legacy_budget is not None:
raise ValueError("invalid multi-runtime candidate")
return self
class ProblemImportSummary(WireModel):
schema_version: Version = 1
operation_id: Identifier
state: ImportState
expected_archive_digest: Digest
archive_bytes: Annotated[int, Field(strict=True, ge=1, le=MAX_ARCHIVE_BYTES)]
created_at: datetime
expires_at: datetime
updated_at: datetime
failure_code: ImportFailure | None
candidate_id: Identifier | None
published_version_id: Identifier | None
@model_validator(mode="after")
def consistent_state(self):
if self.expires_at <= self.created_at or self.updated_at < self.created_at:
raise ValueError("invalid import time range")
if (self.state in (ImportState.READY, ImportState.PUBLISHED)) != (
self.candidate_id is not None
):
raise ValueError("candidate state mismatch")
if (self.state == ImportState.PUBLISHED) != (self.published_version_id is not None):
raise ValueError("published state mismatch")
if self.state in (ImportState.FAILED, ImportState.EXPIRED):
if self.failure_code is None:
raise ValueError("failure code required")
elif self.failure_code is not None:
raise ValueError("unexpected failure code")
return self
class ProblemImportDetail(ProblemImportSummary):
candidate: CandidatePreview | None
@model_validator(mode="after")
def candidate_matches(self):
if (self.candidate_id is None) != (self.candidate is None):
raise ValueError("candidate preview missing")
if self.candidate and self.candidate.candidate_id != self.candidate_id:
raise ValueError("candidate preview mismatch")
return self
class ProblemImportPage(WireModel):
schema_version: Version = 1
items: Annotated[tuple[ProblemImportSummary, ...], Field(max_length=100)]
next_after: Identifier | None
+53
View File
@@ -5,7 +5,21 @@ import json
from pathlib import Path
from kf_oj.api.app import create_app
from kf_oj.contracts.admin_problems import (
CreateProblemImport,
ProblemImportCapabilities,
ProblemImportDetail,
)
from kf_oj.contracts.judge import JudgeTaskNotification
from kf_oj.contracts.judging import WIRE_MODELS
from kf_oj.contracts.judging_v2 import WIRE_MODELS as WIRE_MODELS_V2
from kf_oj.contracts.problem_maintenance import (
ChangeProblemAvailability,
MaintenanceAuditPage,
MaintenanceReceipt,
PublishCandidate,
)
from kf_oj.contracts.submission_performance import SubmissionPerformance
from kf_oj.core.settings import Settings
@@ -23,6 +37,45 @@ def main() -> None:
Settings(_env_file=None, environment="test")
).openapi(),
}
for name, model in WIRE_MODELS.items():
document = model.model_json_schema()
document["$schema"] = "https://json-schema.org/draft/2020-12/schema"
document["$id"] = f"urn:kf-oj:{name}:v1"
document["$comment"] = (
"Structural schema; also run versioned model validation for cross-field "
"budgets, result consistency, UUID canonical form and UTF-8 byte limits."
)
outputs[root / f"contracts/schemas/{name}.v1.schema.json"] = document
performance = SubmissionPerformance.model_json_schema()
performance["$schema"] = "https://json-schema.org/draft/2020-12/schema"
performance["$id"] = "urn:kf-oj:submission-performance:v1"
performance["$comment"] = (
"Anonymous bounded aggregates, decimal raw units and strict same-snapshot "
"counts; also run model validation and server-side ownership/environment checks."
)
outputs[root / "contracts/schemas/submission-performance.v1.schema.json"] = performance
for name, model in {
"problem-publish": PublishCandidate,
"problem-availability": ChangeProblemAvailability,
"problem-maintenance": MaintenanceReceipt,
"problem-maintenance-audit": MaintenanceAuditPage,
"problem-import-create": CreateProblemImport,
"problem-import": ProblemImportDetail,
"problem-import-capabilities": ProblemImportCapabilities,
}.items():
document = model.model_json_schema()
document["$schema"] = "https://json-schema.org/draft/2020-12/schema"
document["$id"] = f"urn:kf-oj:{name}:v1"
outputs[root / f"contracts/schemas/{name}.v1.schema.json"] = document
for name, model in WIRE_MODELS_V2.items():
document = model.model_json_schema()
document["$schema"] = "https://json-schema.org/draft/2020-12/schema"
document["$id"] = f"urn:kf-oj:{name}:v2"
document["$comment"] = (
"Multi-runtime protocol. Also validate runtime/source/attempt bindings, startup "
"and node budgets, canonical bounded class sets and result consistency."
)
outputs[root / f"contracts/schemas/{name}.v2.schema.json"] = document
stale = []
for path, document in outputs.items():
content = json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import Field, SecretStr
from kf_oj.contracts.judging import Digest, Identifier, WireModel
class LoginRequest(WireModel):
username: Annotated[str, Field(strict=True, pattern=r"^[a-z0-9_]{3,24}$")]
password: Annotated[SecretStr, Field(min_length=12, max_length=128)]
class AccountCapabilities(WireModel):
schema_version: Literal[1] = 1
problem_maintenance: bool = False
authorization_version: Annotated[str, Field(pattern=r"^[1-9][0-9]{0,18}$")] = "1"
class CurrentUser(WireModel):
id: Identifier
username: str
capabilities: AccountCapabilities = Field(default_factory=AccountCapabilities)
class LoginResponse(WireModel):
user: CurrentUser
expires_at: datetime
csrf_token: Digest = Field(repr=False)
+5 -3
View File
@@ -1,7 +1,9 @@
from typing import Literal
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, BeforeValidator, ConfigDict
from kf_oj.contracts.judging import integer_version
class JudgeTaskNotification(BaseModel):
@@ -9,6 +11,6 @@ class JudgeTaskNotification(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
schema_version: Literal[1]
schema_version: Annotated[Literal[1], BeforeValidator(integer_version)]
kind: Literal["judge.evaluation"]
evaluation_id: UUID
@@ -0,0 +1,48 @@
"""Pure transition/fencing rules; callers still need a locked database transaction."""
from enum import StrEnum
from kf_oj.contracts.judging import LeaseBinding
class EvaluationState(StrEnum):
QUEUED = "queued"
CLAIMED = "claimed"
COMPILING = "compiling"
RUNNING = "running"
CANCELLING = "cancelling"
FINISHED = "finished"
CANCELLED = "cancelled"
FAILED = "failed"
def allows(self, target: "EvaluationState") -> bool:
transitions = {
self.QUEUED: {self.CLAIMED, self.CANCELLED},
self.CLAIMED: {self.COMPILING, self.CANCELLING, self.FAILED, self.QUEUED},
self.COMPILING: {
self.RUNNING,
self.FINISHED,
self.CANCELLING,
self.FAILED,
self.QUEUED,
},
self.RUNNING: {self.FINISHED, self.CANCELLING, self.FAILED, self.QUEUED},
self.CANCELLING: {self.CANCELLED, self.FAILED, self.QUEUED},
}
return target in transitions.get(self, set())
def require_current_lease(
expected: LeaseBinding,
supplied: LeaseBinding,
expires_at_unix_ms: int,
now_unix_ms: int,
) -> None:
"""Identity/version equality and a strict deadline; never trust client time."""
if expected != supplied or now_unix_ms < 0 or now_unix_ms >= expires_at_unix_ms:
raise ValueError("stale or foreign execution authority")
def require_transition(current: EvaluationState, target: EvaluationState) -> None:
if not current.allows(target):
raise ValueError("invalid evaluation transition")
+403
View File
@@ -0,0 +1,403 @@
"""Versioned judge wire models. Authorization and persistence belong to services."""
import json
from enum import StrEnum
from typing import Annotated, Literal, Self
from uuid import UUID
from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
StrictBool,
model_validator,
)
MIB = 1024 * 1024
I64_MAX = (1 << 63) - 1
MAX_SOURCE_BYTES = 256 * 1024
MAX_PACKAGE_BYTES = 64 * MIB
MAX_CASES = 256
MAX_JOB_WALL_US = 3_600_000_000
MAX_WIRE_BYTES = 256 * 1024
def integer_version(value: object) -> object:
if type(value) is not int:
raise ValueError("schema version must be an integer")
return value
def canonical_id(value: object) -> UUID:
try:
parsed = UUID(str(value))
except (ValueError, TypeError, AttributeError) as error:
raise ValueError("invalid identifier") from error
if parsed.int == 0 or str(value) != str(parsed):
raise ValueError("identifier must be non-nil and canonical")
return parsed
Version = Annotated[Literal[1], BeforeValidator(integer_version)]
Identifier = Annotated[UUID, BeforeValidator(canonical_id)]
Digest = Annotated[str, Field(strict=True, min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")]
Label = Annotated[str, Field(strict=True, pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$")]
Counter = Annotated[int, Field(strict=True, ge=1, le=I64_MAX)]
Measurement = Annotated[int, Field(strict=True, ge=0, le=I64_MAX)]
class WireModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class Language(StrEnum):
C17 = "c17"
CPP20 = "cpp20"
class Verdict(StrEnum):
ACCEPTED = "accepted"
WRONG_ANSWER = "wrong_answer"
COMPILATION_ERROR = "compilation_error"
RUNTIME_ERROR = "runtime_error"
TIME_LIMIT = "time_limit_exceeded"
MEMORY_LIMIT = "memory_limit_exceeded"
OUTPUT_LIMIT = "output_limit_exceeded"
CANCELLED = "cancelled"
SYSTEM_ERROR = "system_error"
class CompilationStatus(StrEnum):
SUCCEEDED = "succeeded"
COMPILATION_ERROR = "compilation_error"
COMPILATION_LIMIT = "compilation_limit"
CANCELLED = "cancelled"
SYSTEM_ERROR = "system_error"
class FailureCode(StrEnum):
ARTIFACT_UNAVAILABLE = "artifact_unavailable"
SANDBOX_UNAVAILABLE = "sandbox_unavailable"
CLEANUP_FAILED = "cleanup_failed"
LEASE_LOST = "lease_lost"
JOB_DEADLINE = "job_deadline"
class ArtifactReference(WireModel):
digest: Digest
bytes: Annotated[int, Field(strict=True, ge=0, le=MAX_PACKAGE_BYTES)]
class Toolchain(WireModel):
toolchain_id: Label
image_kind: Literal["linux_gnu_cxx_x86_64_v1"]
image_revision: Annotated[int, Field(strict=True, ge=1, le=(1 << 32) - 1)]
class RunLimits(WireModel):
cpu_time_us: Annotated[int, Field(strict=True, ge=1000, le=60_000_000)]
wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=120_000_000)]
memory_bytes: Annotated[int, Field(strict=True, ge=1, le=2048 * MIB)]
stack_bytes: Annotated[int, Field(strict=True, ge=1, le=256 * MIB)]
processes: Annotated[int, Field(strict=True, ge=1, le=128)]
open_files: Annotated[int, Field(strict=True, ge=3, le=1024)]
stdout_bytes: Annotated[int, Field(strict=True, ge=1, le=16 * MIB)]
stderr_bytes: Annotated[int, Field(strict=True, ge=1, le=16 * MIB)]
file_bytes: Annotated[int, Field(strict=True, ge=1, le=64 * MIB)]
workdir_bytes: Annotated[int, Field(strict=True, ge=1, le=256 * MIB)]
@model_validator(mode="after")
def consistent(self) -> Self:
if (
self.stack_bytes > self.memory_bytes
or self.file_bytes > self.workdir_bytes
or self.stdout_bytes > self.file_bytes
or self.stderr_bytes > self.file_bytes
or self.stdout_bytes + self.stderr_bytes > self.workdir_bytes
):
raise ValueError("inconsistent resource limits")
return self
def fits(self, ceiling: "RunLimits") -> bool:
return all(
getattr(self, name) <= getattr(ceiling, name) for name in type(self).model_fields
)
class TestCase(WireModel):
case_id: Identifier
input: ArtifactReference
expected: ArtifactReference
class JobManifest(WireModel):
schema_version: Version
kind: Literal["judge.manifest"]
evaluation_id: Identifier
submission_id: Identifier
problem_version_id: Identifier
language: Language
toolchain: Toolchain
source: ArtifactReference
cases: Annotated[tuple[TestCase, ...], Field(min_length=1, max_length=MAX_CASES)]
comparison: Literal["exact_bytes"]
compile_limits: RunLimits
run_limits: RunLimits
job_wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=MAX_JOB_WALL_US)]
def package_bytes(self) -> int:
# Count references, including duplicates: this bounds actual staged/read bytes.
return self.source.bytes + sum(c.input.bytes + c.expected.bytes for c in self.cases)
@model_validator(mode="after")
def consistent(self) -> Self:
if not 1 <= self.source.bytes <= MAX_SOURCE_BYTES:
raise ValueError("source size outside policy")
if len({c.case_id for c in self.cases}) != len(self.cases):
raise ValueError("duplicate testcase identifiers")
if self.package_bytes() > MAX_PACKAGE_BYTES:
raise ValueError("test package outside policy")
if any(c.expected.bytes > self.run_limits.stdout_bytes for c in self.cases):
raise ValueError("expected output exceeds output budget")
required_wall = (
self.compile_limits.wall_time_us + len(self.cases) * self.run_limits.wall_time_us
)
if required_wall > self.job_wall_time_us:
raise ValueError("job deadline cannot cover execution budgets")
return self
def manifest_bytes(manifest: JobManifest) -> bytes:
"""Stable UTF-8 bytes for persisted snapshots and their SHA-256 identity."""
return json.dumps(
manifest.model_dump(mode="json"),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
allow_nan=False,
).encode("utf-8")
class LeaseBinding(WireModel):
evaluation_id: Identifier
attempt_id: Identifier
worker_id: Identifier
fencing_version: Counter
class LeaseGrant(WireModel):
schema_version: Version
kind: Literal["judge.lease"]
binding: LeaseBinding
lease_token: Digest = Field(repr=False)
attempt_number: Annotated[int, Field(strict=True, ge=1, le=5)]
expires_at_unix_ms: Counter
manifest_digest: Digest
class StageReport(WireModel):
schema_version: Version
kind: Literal["judge.stage"]
binding: LeaseBinding
sequence: Counter
stage: Literal["compiling", "running"]
case_index: Annotated[int, Field(strict=True, ge=0, lt=MAX_CASES)] | None
@model_validator(mode="after")
def consistent(self) -> Self:
if (self.stage == "running") != (self.case_index is not None):
raise ValueError("testcase index only belongs to running phase")
return self
class CompilationReport(WireModel):
status: CompilationStatus
program: ArtifactReference | None
diagnostics: Annotated[str, Field(strict=True, max_length=65536)] = Field(repr=False)
diagnostics_truncated: StrictBool
cpu_time_us: Measurement | None
memory_peak_bytes: Measurement | None
@model_validator(mode="after")
def consistent(self) -> Self:
if len(self.diagnostics.encode("utf-8")) > 65536:
raise ValueError("compiler diagnostics exceed byte budget")
if (self.status == CompilationStatus.SUCCEEDED) != (self.program is not None):
raise ValueError("compiled artifact only belongs to successful compilation")
if self.program is not None and self.program.bytes == 0:
raise ValueError("compiled artifact cannot be empty")
return self
class CaseReport(WireModel):
case_id: Identifier
verdict: Verdict
cpu_time_us: Measurement | None
memory_peak_bytes: Measurement | None
@model_validator(mode="after")
def consistent(self) -> Self:
if self.verdict == Verdict.COMPILATION_ERROR:
raise ValueError("compilation failure is not a testcase outcome")
return self
class JudgementReport(WireModel):
schema_version: Version
kind: Literal["judge.result"]
binding: LeaseBinding
manifest_digest: Digest
compilation: CompilationReport
cases: Annotated[tuple[CaseReport, ...], Field(max_length=MAX_CASES)]
verdict: Verdict
cleanup_complete: StrictBool
failure_code: FailureCode | None
run_cpu_time_us: Measurement | None
run_memory_peak_bytes: Measurement | None
@model_validator(mode="after")
def consistent(self) -> Self:
if len({c.case_id for c in self.cases}) != len(self.cases):
raise ValueError("duplicate testcase results")
if not self.cleanup_complete and self.failure_code != FailureCode.CLEANUP_FAILED:
raise ValueError("incomplete cleanup requires its own failure evidence")
if self.cleanup_complete and self.failure_code == FailureCode.CLEANUP_FAILED:
raise ValueError("cleanup failure cannot claim completed cleanup")
if (self.verdict == Verdict.SYSTEM_ERROR) != (self.failure_code is not None):
raise ValueError("infrastructure failure and verdict must agree")
terminal = next(
(
i
for i, c in enumerate(self.cases)
if c.verdict in (Verdict.SYSTEM_ERROR, Verdict.CANCELLED)
),
None,
)
if terminal is not None and terminal != len(self.cases) - 1:
raise ValueError("results after fatal testcase outcome")
if self.compilation.status != CompilationStatus.SUCCEEDED:
mapped = {
CompilationStatus.COMPILATION_ERROR: Verdict.COMPILATION_ERROR,
CompilationStatus.COMPILATION_LIMIT: Verdict.COMPILATION_ERROR,
CompilationStatus.CANCELLED: Verdict.CANCELLED,
CompilationStatus.SYSTEM_ERROR: Verdict.SYSTEM_ERROR,
}[self.compilation.status]
if self.cases or self.verdict != mapped:
raise ValueError("failed compilation cannot have runtime results")
elif self.failure_code is None:
if any(c.verdict == Verdict.SYSTEM_ERROR for c in self.cases):
raise ValueError("infrastructure failure requires failure code")
if not self.cases and self.verdict != Verdict.CANCELLED:
raise ValueError("missing testcase results cannot be a completed judgement")
if self.cases and self.verdict != Verdict.CANCELLED:
failures = (Verdict.SYSTEM_ERROR, Verdict.CANCELLED)
summary = next(
(c.verdict for c in self.cases if c.verdict in failures),
next(
(c.verdict for c in self.cases if c.verdict != Verdict.ACCEPTED),
Verdict.ACCEPTED,
),
)
if self.verdict != summary:
raise ValueError("testcase summary disagrees")
cpu = (
sum(c.cpu_time_us for c in self.cases if c.cpu_time_us is not None)
if self.cases and all(c.cpu_time_us is not None for c in self.cases)
else None
)
memory = (
max(c.memory_peak_bytes for c in self.cases if c.memory_peak_bytes is not None)
if self.cases and all(c.memory_peak_bytes is not None for c in self.cases)
else None
)
if self.run_cpu_time_us != cpu or self.run_memory_peak_bytes != memory:
raise ValueError("runtime measurement summary disagrees")
return self
def validate_for_manifest(self, manifest: JobManifest, digest: str) -> None:
if self.binding.evaluation_id != manifest.evaluation_id or self.manifest_digest != digest:
raise ValueError("report does not belong to authorized manifest")
if [c.case_id for c in self.cases] != [
c.case_id for c in manifest.cases[: len(self.cases)]
]:
raise ValueError("testcase results differ from authorized order")
if self.verdict not in (Verdict.COMPILATION_ERROR, Verdict.CANCELLED, Verdict.SYSTEM_ERROR):
if len(self.cases) != len(manifest.cases):
raise ValueError("incomplete ordinary judgement")
class AdmissionPolicy(WireModel):
"""Trusted node policy. A manifest never supplies or replaces this policy."""
max_concurrent_jobs: Annotated[int, Field(strict=True, ge=1, le=64)] = 1
prefetch: Annotated[int, Field(strict=True, ge=1, le=64)] = 1
max_retries: Annotated[int, Field(strict=True, ge=0, le=4)] = 2
max_job_wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=MAX_JOB_WALL_US)]
max_package_bytes: Annotated[int, Field(strict=True, ge=1, le=MAX_PACKAGE_BYTES)]
toolchain: Toolchain
compile_ceiling: RunLimits
run_ceiling: RunLimits
@model_validator(mode="after")
def consistent(self) -> Self:
if self.prefetch > self.max_concurrent_jobs:
raise ValueError("prefetch exceeds available job slots")
return self
def admit(self, manifest: JobManifest, attempt_number: int) -> None:
if type(attempt_number) is not int or not 1 <= attempt_number <= self.max_retries + 1:
raise ValueError("attempt retry budget exhausted")
if (
manifest.toolchain != self.toolchain
or not manifest.compile_limits.fits(self.compile_ceiling)
or not manifest.run_limits.fits(self.run_ceiling)
or manifest.job_wall_time_us > self.max_job_wall_time_us
or manifest.package_bytes() > self.max_package_bytes
):
raise ValueError("job exceeds trusted node budget")
class StoppedReport(WireModel):
schema_version: Version
kind: Literal["judge.stopped"]
binding: LeaseBinding
cleanup_complete: StrictBool
WIRE_MODELS = {
"judge-manifest": JobManifest,
"judge-lease": LeaseGrant,
"judge-stage": StageReport,
"judge-result": JudgementReport,
"judge-admission": AdmissionPolicy,
"judge-stopped": StoppedReport,
}
def decode_wire[T: BaseModel](model: type[T], payload: bytes) -> T:
"""UTF-8, bounded size and no ambiguous duplicate members before model validation."""
if len(payload) > MAX_WIRE_BYTES:
raise ValueError("wire payload exceeds byte budget")
def unique_members(pairs: list[tuple[str, object]]) -> dict[str, object]:
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON member")
result[key] = value
return result
def invalid_constant(_: str) -> None:
raise ValueError("invalid JSON constant")
try:
value = json.loads(
payload.decode("utf-8"),
object_pairs_hook=unique_members,
parse_constant=invalid_constant,
)
return model.model_validate(value)
except (ValueError, TypeError):
# Errors at the transport boundary must not echo credentials or hidden inputs.
raise ValueError("invalid wire payload") from None
+358
View File
@@ -0,0 +1,358 @@
"""Explicit multi-runtime protocol; existing v1 snapshots remain byte-for-byte stable."""
import re
from enum import StrEnum
from typing import Annotated, Literal, Self
from pydantic import BeforeValidator, Field, model_validator
from kf_oj.contracts import judging as v1
Version = Annotated[Literal[2], BeforeValidator(v1.integer_version)]
MAX_CLASS_FILES = 256
JVM_NATIVE_RESERVE_BYTES = 160 * v1.MIB
JVM_THREAD_STACK_BYTES = 256 * 1024
class Language(StrEnum):
C17 = "c17"
CPP20 = "cpp20"
PYTHON313 = "python313"
JAVA21 = "java21"
class PreparationKind(StrEnum):
COMPILE = "compile"
SYNTAX_CHECK = "syntax_check"
class ProgramKind(StrEnum):
NATIVE = "native_executable"
PYTHON = "python_source"
JAVA = "java_classes"
class RuntimeIdentity(v1.WireModel):
"""An exact identity matched against trusted configuration, never a host path."""
toolchain_id: v1.Label
image_kind: Literal[
"linux_gnu_cxx_x86_64_v1", "linux_cpython313_x86_64_v1", "linux_openjdk21_x86_64_v1"
]
image_revision: Annotated[int, Field(strict=True, ge=1, le=(1 << 32) - 1)]
image_digest: v1.Digest
runtime_version: Annotated[
str, Field(strict=True, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$")
]
def runtime_contract(language: Language) -> tuple[str, str, str, ProgramKind, PreparationKind]:
"""Fixed source/profile names. Runtime callers cannot introduce argv or paths."""
return {
Language.C17: (
"main.c",
"compile_c",
"native",
ProgramKind.NATIVE,
PreparationKind.COMPILE,
),
Language.CPP20: (
"main.cpp",
"compile_cpp",
"native",
ProgramKind.NATIVE,
PreparationKind.COMPILE,
),
Language.PYTHON313: (
"main.py",
"python_check",
"python_run",
ProgramKind.PYTHON,
PreparationKind.SYNTAX_CHECK,
),
Language.JAVA21: (
"Main.java",
"java_compile",
"java_run",
ProgramKind.JAVA,
PreparationKind.COMPILE,
),
}[language]
def validate_runtime(language: Language, identity: RuntimeIdentity) -> None:
expected = {
Language.C17: ("gnu-cxx-v1", "linux_gnu_cxx_x86_64_v1"),
Language.CPP20: ("gnu-cxx-v1", "linux_gnu_cxx_x86_64_v1"),
Language.PYTHON313: ("cpython313-v1", "linux_cpython313_x86_64_v1"),
Language.JAVA21: ("openjdk21-v1", "linux_openjdk21_x86_64_v1"),
}[language]
if (identity.toolchain_id, identity.image_kind) != expected:
raise ValueError("runtime family differs from language contract")
if language == Language.PYTHON313 and identity.runtime_version != "3.13.16":
raise ValueError("unsupported CPython version")
if language == Language.JAVA21 and not re.fullmatch(
r"21\.[0-9]+\.[0-9]+(?:\.[0-9]+)?\+[1-9][0-9]*(?:-LTS)?", identity.runtime_version
):
raise ValueError("JDK version must identify a fixed Java 21 build")
def validate_startup_budget(language: Language, limits: v1.RunLimits, *, preparing: bool) -> None:
"""Admission floors, not multipliers on problem limits; real execution still required."""
if language == Language.PYTHON313:
floor = (10_000, 50_000, 32 * v1.MIB, 1, 16)
elif language == Language.JAVA21:
floor = (
(1_000_000, 3_000_000, 384 * v1.MIB, 16, 32)
if preparing
else (200_000, 1_000_000, 256 * v1.MIB, 16, 32)
)
else:
return
actual = (
limits.cpu_time_us,
limits.wall_time_us,
limits.memory_bytes,
limits.processes,
limits.open_files,
)
if any(value < minimum for value, minimum in zip(actual, floor, strict=True)):
raise ValueError("budget below runtime startup policy")
if language == Language.JAVA21:
jvm_heap_bytes(limits)
def jvm_heap_bytes(limits: v1.RunLimits) -> int:
"""Reserve capped metaspace/code/native and every allowed thread stack inside cgroup."""
reserve = JVM_NATIVE_RESERVE_BYTES + limits.processes * JVM_THREAD_STACK_BYTES
heap = (limits.memory_bytes - reserve) // v1.MIB * v1.MIB
if heap < 32 * v1.MIB or limits.stack_bytes < JVM_THREAD_STACK_BYTES:
raise ValueError("budget cannot cover JVM heap and explicit non-heap reserve")
return heap
class LanguageConfiguration(v1.WireModel):
language: Language
toolchain: RuntimeIdentity
compile_limits: v1.RunLimits
run_limits: v1.RunLimits
job_wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=v1.MAX_JOB_WALL_US)]
@model_validator(mode="after")
def runtime_and_budget(self) -> Self:
validate_runtime(self.language, self.toolchain)
validate_startup_budget(self.language, self.compile_limits, preparing=True)
validate_startup_budget(self.language, self.run_limits, preparing=False)
if self.compile_limits.wall_time_us + self.run_limits.wall_time_us > self.job_wall_time_us:
raise ValueError("job deadline cannot cover preparation and execution")
return self
class JobManifest(v1.JobManifest):
schema_version: Version
language: Language
toolchain: RuntimeIdentity
@model_validator(mode="after")
def runtime_and_budget(self) -> Self:
validate_runtime(self.language, self.toolchain)
validate_startup_budget(self.language, self.compile_limits, preparing=True)
validate_startup_budget(self.language, self.run_limits, preparing=False)
return self
class ProgramFile(v1.WireModel):
path: Annotated[str, Field(strict=True, min_length=1, max_length=128)]
artifact: v1.ArtifactReference
class CompiledProgram(v1.WireModel):
"""Sealed file identities bound to a particular source, runtime and lease attempt."""
kind: ProgramKind
binding: v1.LeaseBinding
source: v1.ArtifactReference
toolchain: RuntimeIdentity
files: Annotated[tuple[ProgramFile, ...], Field(min_length=1, max_length=MAX_CLASS_FILES)]
@property
def bytes(self) -> int:
return sum(file.artifact.bytes for file in self.files)
@model_validator(mode="after")
def bounded_files(self) -> Self:
names = [file.path for file in self.files]
if names != sorted(set(names)):
raise ValueError("program files must have unique canonical order")
if not 1 <= self.source.bytes <= v1.MAX_SOURCE_BYTES:
raise ValueError("program source outside policy")
if self.bytes > v1.MAX_PACKAGE_BYTES or any(
file.artifact.bytes == 0 for file in self.files
):
raise ValueError("program files empty or outside byte policy")
if self.kind == ProgramKind.NATIVE:
validate_runtime(Language.C17, self.toolchain)
if names != ["main"] or self.toolchain.image_kind != "linux_gnu_cxx_x86_64_v1":
raise ValueError("native program requires its fixed executable and runtime")
elif self.kind == ProgramKind.PYTHON:
validate_runtime(Language.PYTHON313, self.toolchain)
if names != ["main.py"] or self.files[0].artifact != self.source:
raise ValueError("Python program must be the exact checked source")
else:
validate_runtime(Language.JAVA21, self.toolchain)
if "Main.class" not in names or any(
not re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*\.class", name) for name in names
):
raise ValueError("Java requires a bounded default-package Main class set")
return self
class CompilationReport(v1.CompilationReport):
program: CompiledProgram | None
class JudgementReport(v1.JudgementReport):
schema_version: Version
compilation: CompilationReport
@model_validator(mode="after")
def program_attempt(self) -> Self:
if (
self.compilation.program is not None
and self.compilation.program.binding != self.binding
):
raise ValueError("program belongs to another attempt")
return self
def validate_for_manifest(self, manifest: JobManifest, digest: str) -> None:
super().validate_for_manifest(manifest, digest)
program = self.compilation.program
if program is not None and (
program.source != manifest.source
or program.toolchain != manifest.toolchain
or program.kind != runtime_contract(manifest.language)[3]
):
raise ValueError("program differs from authorized source or runtime")
class LeaseGrant(v1.LeaseGrant):
schema_version: Version
class StageReport(v1.StageReport):
schema_version: Version
preparation_kind: PreparationKind
def validate_for_manifest(self, manifest: JobManifest) -> None:
if self.binding.evaluation_id != manifest.evaluation_id or (
self.preparation_kind != runtime_contract(manifest.language)[4]
):
raise ValueError("stage differs from authorized language preparation")
class StoppedReport(v1.StoppedReport):
schema_version: Version
class RuntimeCapability(v1.WireModel):
language: Language
toolchain: RuntimeIdentity
compile_ceiling: v1.RunLimits
run_ceiling: v1.RunLimits
@model_validator(mode="after")
def runtime_and_budget(self) -> Self:
validate_runtime(self.language, self.toolchain)
validate_startup_budget(self.language, self.compile_ceiling, preparing=True)
validate_startup_budget(self.language, self.run_ceiling, preparing=False)
return self
class AdmissionPolicy(v1.WireModel):
schema_version: Version
max_concurrent_jobs: Annotated[int, Field(strict=True, ge=1, le=64)] = 1
prefetch: Annotated[int, Field(strict=True, ge=1, le=64)] = 1
max_retries: Annotated[int, Field(strict=True, ge=0, le=4)] = 2
max_job_wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=v1.MAX_JOB_WALL_US)]
max_package_bytes: Annotated[int, Field(strict=True, ge=1, le=v1.MAX_PACKAGE_BYTES)]
capabilities: Annotated[tuple[RuntimeCapability, ...], Field(min_length=1, max_length=4)]
@model_validator(mode="after")
def consistent(self) -> Self:
if self.prefetch > self.max_concurrent_jobs:
raise ValueError("prefetch exceeds available job slots")
if len({cap.language for cap in self.capabilities}) != len(self.capabilities):
raise ValueError("duplicate language capability")
return self
def admit(self, manifest: JobManifest, attempt_number: int) -> None:
# Revalidate constructed/copied values as well as transport-decoded models.
manifest = JobManifest.model_validate(manifest.model_dump(mode="json"))
AdmissionPolicy.model_validate(self.model_dump(mode="json"))
if type(attempt_number) is not int or not 1 <= attempt_number <= self.max_retries + 1:
raise ValueError("attempt retry budget exhausted")
cap = next((cap for cap in self.capabilities if cap.language == manifest.language), None)
if cap is None or (
cap.toolchain != manifest.toolchain
or not manifest.compile_limits.fits(cap.compile_ceiling)
or not manifest.run_limits.fits(cap.run_ceiling)
or manifest.job_wall_time_us > self.max_job_wall_time_us
or manifest.package_bytes() > self.max_package_bytes
):
raise ValueError("job exceeds trusted runtime capability or budget")
class AdmissionRegistry(v1.WireModel):
"""Explicit operator opt-in for both protocol generations; no silent conversion."""
schema_version: Version
legacy: v1.AdmissionPolicy | None
multi_runtime: AdmissionPolicy | None
@model_validator(mode="after")
def at_least_one_protocol(self) -> Self:
if self.legacy is None and self.multi_runtime is None:
raise ValueError("node must advertise at least one explicit protocol capability")
if self.legacy is not None and self.multi_runtime is not None:
for field in ("max_concurrent_jobs", "prefetch", "max_retries"):
if getattr(self.legacy, field) != getattr(self.multi_runtime, field):
raise ValueError("protocol policies disagree on node capacity or retries")
return self
def admit(self, manifest: v1.JobManifest | JobManifest, attempt_number: int) -> None:
AdmissionRegistry.model_validate(self.model_dump(mode="json"))
policy = self.legacy if manifest.schema_version == 1 else self.multi_runtime
admit_manifest(policy, manifest, attempt_number)
NodeAdmission = v1.AdmissionPolicy | AdmissionPolicy | AdmissionRegistry
def admit_manifest(
policy: NodeAdmission | None, manifest: v1.JobManifest | JobManifest, attempt_number: int
) -> None:
if type(manifest) not in (v1.JobManifest, JobManifest):
raise ValueError("unsupported manifest model")
# Copied/constructed values must pass the original strict protocol validators too.
manifest = type(manifest).model_validate(manifest.model_dump(mode="json"))
if isinstance(policy, AdmissionRegistry):
policy.admit(manifest, attempt_number)
elif manifest.schema_version == 1 and type(policy) is v1.AdmissionPolicy:
v1.AdmissionPolicy.model_validate(policy.model_dump(mode="json")).admit(
manifest, attempt_number
)
elif manifest.schema_version == 2 and type(policy) is AdmissionPolicy:
policy.admit(manifest, attempt_number)
else:
raise ValueError("node does not advertise this manifest protocol")
WIRE_MODELS = {
"judge-manifest": JobManifest,
"judge-lease": LeaseGrant,
"judge-stage": StageReport,
"judge-result": JudgementReport,
"judge-admission": AdmissionPolicy,
"judge-stopped": StoppedReport,
"judge-registry": AdmissionRegistry,
}
@@ -0,0 +1,35 @@
"""Version dispatch at storage/transport boundaries; never rewrite legacy snapshots."""
from pydantic import ConfigDict, RootModel
from kf_oj.contracts import judging as v1
from kf_oj.contracts import judging_v2 as v2
Manifest = v1.JobManifest | v2.JobManifest
Report = v1.JudgementReport | v2.JudgementReport
class ManifestEnvelope(RootModel[Manifest]):
model_config = ConfigDict(frozen=True)
class ReportEnvelope(RootModel[Report]):
model_config = ConfigDict(frozen=True)
def decode_manifest(payload: bytes) -> Manifest:
return v1.decode_wire(ManifestEnvelope, payload).root
def decode_report(payload: bytes) -> Report:
return v1.decode_wire(ReportEnvelope, payload).root
def manifest_bytes(manifest: Manifest) -> bytes:
return v1.manifest_bytes(manifest)
def validate_report(report: Report, manifest: Manifest, digest: str) -> None:
if report.schema_version != manifest.schema_version:
raise ValueError("report protocol differs from authorized manifest")
report.validate_for_manifest(manifest, digest)
@@ -0,0 +1,103 @@
"""Bounded administrator projections and immutable operation receipts, separate from judging."""
from datetime import datetime
from typing import Annotated, Literal
from pydantic import Field, StrictBool
from kf_oj.contracts.admin_problems import CandidatePreview
from kf_oj.contracts.judging import Identifier, Label, Version, WireModel
from kf_oj.contracts.problems import ProblemSummary
MaintenanceAction = Literal["publish", "disable", "restore"]
AuditAction = Literal[
"upload_created",
"upload_received",
"import_ready",
"import_failed",
"import_discarded",
"import_expired",
"published",
"disabled",
"restored",
"authority_bootstrap",
"authority_grant",
"authority_revoke",
"authority_enable",
"authority_disable",
]
class MaintainedProblem(WireModel):
id: Identifier
slug: Label
enabled: StrictBool
active_version_id: Identifier | None
revision: Annotated[int, Field(strict=True, ge=0)]
title: Annotated[str | None, Field(max_length=160)]
created_at: datetime
class MaintainedProblemPage(WireModel):
schema_version: Version = 1
items: Annotated[tuple[MaintainedProblem, ...], Field(max_length=100)]
next_after: Label | None
class MaintainedVersion(ProblemSummary):
published_at: datetime
case_count: Annotated[int, Field(strict=True, ge=1, le=256)]
class MaintainedVersionPage(WireModel):
schema_version: Version = 1
items: Annotated[tuple[MaintainedVersion, ...], Field(max_length=100)]
next_before: Annotated[int | None, Field(strict=True, ge=1)]
class PublishCandidate(WireModel):
schema_version: Version = 1
operation_id: Identifier
import_operation_id: Identifier
candidate_id: Identifier
expected_active_version_id: Identifier | None
class ChangeProblemAvailability(WireModel):
schema_version: Version = 1
operation_id: Identifier
expected_active_version_id: Identifier
expected_enabled: StrictBool
class MaintenanceReceipt(WireModel):
schema_version: Version = 1
operation_id: Identifier
action: MaintenanceAction
completed_at: datetime
problem: MaintainedProblem
published_version_id: Identifier | None = None
class MaintenanceAuditEvent(WireModel):
id: Identifier
created_at: datetime
actor_kind: Literal["account", "operator"]
actor_id: Identifier | None
operator_name: Annotated[str | None, Field(max_length=63)]
authorization_version: Annotated[str, Field(pattern=r"^[1-9][0-9]{0,18}$")]
operation_id: Identifier
action: AuditAction
target_id: Identifier | None
result: Literal["committed"] = "committed"
class MaintenanceAuditPage(WireModel):
schema_version: Version = 1
items: Annotated[tuple[MaintenanceAuditEvent, ...], Field(max_length=100)]
next_before: Annotated[str | None, Field(max_length=100)]
# This alias deliberately uses the existing sanitized candidate contract. No new
# endpoint exposes the private manifest, artifact digest or hidden case names.
PublicCandidate = CandidatePreview
+58
View File
@@ -0,0 +1,58 @@
from typing import Annotated, Literal
from pydantic import Field
from kf_oj.contracts.judging import Identifier, Label, RunLimits, WireModel
from kf_oj.contracts.judging_v2 import Language, PreparationKind, runtime_contract
class PublicSample(WireModel):
encoding: Literal["base64"] = "base64"
input: Annotated[str, Field(max_length=87384)]
output: Annotated[str, Field(max_length=87384)]
class ProblemSummary(WireModel):
id: Identifier
slug: Label
version_id: Identifier
revision: int
title: str
languages: tuple[Language, ...]
class PublicLanguageConfiguration(WireModel):
language: Language
runtime_version: str
source_name: str
entrypoint: str
preparation_kind: PreparationKind
compile_limits: RunLimits
run_limits: RunLimits
job_wall_time_us: int
@classmethod
def from_configuration(cls, configuration):
source, _, _, _, preparation = runtime_contract(configuration.language)
return cls(
language=configuration.language,
runtime_version=configuration.toolchain.runtime_version,
source_name=source,
entrypoint="Main" if configuration.language == Language.JAVA21 else source,
preparation_kind=preparation,
compile_limits=configuration.compile_limits,
run_limits=configuration.run_limits,
job_wall_time_us=configuration.job_wall_time_us,
)
class ProblemDetail(ProblemSummary):
statement: str
samples: tuple[PublicSample, ...]
run_limits: RunLimits | None
language_configurations: tuple[PublicLanguageConfiguration, ...] = ()
class ProblemPage(WireModel):
items: tuple[ProblemSummary, ...]
next_after: Label | None
@@ -0,0 +1,126 @@
"""Bounded anonymous aggregates; raw measurements never pass through JS floats."""
from datetime import datetime
from typing import Annotated, Literal, Self
from pydantic import AfterValidator, Field, StrictBool, model_validator
from kf_oj.contracts.judging import I64_MAX, Digest, Identifier, Version, WireModel
from kf_oj.contracts.judging_v2 import Language
MAX_PERFORMANCE_SAMPLES = 10_000
MIN_PERFORMANCE_SAMPLES = 5
MAX_PERFORMANCE_BUCKETS = 64
def bounded_decimal(value: str) -> str:
if int(value) > I64_MAX:
raise ValueError("measurement exceeds signed storage range")
return value
DecimalMeasurement = Annotated[
str,
Field(strict=True, min_length=1, max_length=19, pattern=r"^(0|[1-9][0-9]*)$"),
AfterValidator(bounded_decimal),
]
SampleCount = Annotated[int, Field(strict=True, ge=0, le=MAX_PERFORMANCE_SAMPLES)]
UnavailableReason = Literal[
"not_accepted", "measurement_missing", "environment_unverified", "insufficient_samples"
]
class PerformanceBucket(WireModel):
start: DecimalMeasurement
end: DecimalMeasurement
upper_inclusive: StrictBool
count: SampleCount
@model_validator(mode="after")
def interval(self) -> Self:
if int(self.end) <= int(self.start):
raise ValueError("empty or reversed interval")
return self
class PerformanceMetric(WireModel):
unit: Literal["cpu_microseconds", "bytes"]
current: DecimalMeasurement | None
available: StrictBool
reason: UnavailableReason | None
sample_count: SampleCount
window_truncated: StrictBool
oldest_sample_at: datetime | None
# These counts are computed against the original integers, never rounded bins.
strictly_slower_count: SampleCount | None
tied_count: SampleCount | None
buckets: Annotated[tuple[PerformanceBucket, ...], Field(max_length=MAX_PERFORMANCE_BUCKETS)]
@model_validator(mode="after")
def aggregate(self) -> Self:
if self.oldest_sample_at is not None and self.oldest_sample_at.utcoffset() is None:
raise ValueError("sample timestamp must have a timezone")
if self.available:
if (
self.reason is not None
or self.current is None
or self.sample_count < MIN_PERFORMANCE_SAMPLES
or self.strictly_slower_count is None
or self.tied_count is None
or self.oldest_sample_at is None
or not self.buckets
):
raise ValueError("incomplete available aggregate")
if self.strictly_slower_count + self.tied_count > self.sample_count:
raise ValueError("comparison counts exceed the cohort")
if sum(b.count for b in self.buckets) != self.sample_count:
raise ValueError("histogram loses samples")
if int(self.buckets[0].start) != 0:
raise ValueError("histogram must include zero")
for index, bucket in enumerate(self.buckets):
if bucket.upper_inclusive != (index == len(self.buckets) - 1):
raise ValueError("only the last interval includes its upper endpoint")
if index and self.buckets[index - 1].end != bucket.start:
raise ValueError("histogram intervals must be contiguous")
elif (
self.reason is None
or self.buckets
or self.strictly_slower_count is not None
or self.tied_count is not None
):
raise ValueError("unavailable aggregate cannot expose comparison data")
if self.reason == "measurement_missing" and self.current is not None:
raise ValueError("missing measurement cannot claim a value")
if self.reason == "insufficient_samples" and (
self.sample_count >= MIN_PERFORMANCE_SAMPLES or self.current is None
):
raise ValueError("inconsistent insufficient cohort")
return self
class SubmissionPerformance(WireModel):
schema_version: Version = 1
kind: Literal["judge.performance"] = "judge.performance"
submission_id: Identifier
problem_version_id: Identifier
language: Language
as_of: datetime
# Opaque trusted comparison identity, not a worker name, path or credential.
comparison_group: Digest | None
window_limit: Literal[10_000] = MAX_PERFORMANCE_SAMPLES
minimum_samples: Literal[5] = MIN_PERFORMANCE_SAMPLES
cpu: PerformanceMetric
memory: PerformanceMetric
@model_validator(mode="after")
def snapshot(self) -> Self:
if self.as_of.utcoffset() is None:
raise ValueError("snapshot timestamp must have a timezone")
if self.cpu.unit != "cpu_microseconds" or self.memory.unit != "bytes":
raise ValueError("metric unit mismatch")
for metric in (self.cpu, self.memory):
if metric.available and self.comparison_group is None:
raise ValueError("comparison requires trusted environment binding")
if metric.oldest_sample_at is not None and metric.oldest_sample_at > self.as_of:
raise ValueError("sample is newer than the snapshot")
return self
@@ -0,0 +1,92 @@
from datetime import datetime
from typing import Annotated, Literal
from pydantic import Field, model_validator
from kf_oj.contracts.judging import (
MAX_SOURCE_BYTES,
CompilationStatus,
FailureCode,
Identifier,
Measurement,
Verdict,
WireModel,
)
from kf_oj.contracts.judging_v2 import Language
EvaluationState = Literal[
"queued", "claimed", "compiling", "running", "cancelling", "finished", "cancelled", "failed"
]
class CreateSubmission(WireModel):
problem_version_id: Identifier
language: Language
idempotency_key: Identifier
source: Annotated[
str, Field(strict=True, min_length=1, max_length=MAX_SOURCE_BYTES, repr=False)
]
@model_validator(mode="after")
def bounded_source(self):
try:
if len(self.source.encode("utf-8")) > MAX_SOURCE_BYTES:
raise ValueError("source exceeds byte policy")
except UnicodeError:
raise ValueError("source must be UTF-8") from None
return self
class SubmissionCreated(WireModel):
submission_id: Identifier
evaluation_id: Identifier
problem_version_id: Identifier
language: Language
state: EvaluationState
created_at: datetime
class SubmissionSummary(SubmissionCreated):
updated_at: datetime
attempts: int
verdict: Verdict | None
failure_code: FailureCode | None
class PublicCaseResult(WireModel):
position: int
verdict: Verdict
cpu_time_us: Measurement | None
memory_peak_bytes: Measurement | None
class PublicJudgement(WireModel):
compilation_status: CompilationStatus
diagnostics: Annotated[str, Field(max_length=65536)]
diagnostics_truncated: bool
cpu_time_us: Measurement | None
memory_peak_bytes: Measurement | None
cleanup_complete: bool
cases: tuple[PublicCaseResult, ...]
class SubmissionDetail(SubmissionSummary):
result: PublicJudgement | None
class SubmissionPage(WireModel):
items: tuple[SubmissionSummary, ...]
next_after: str | None
class SubmissionSource(WireModel):
submission_id: Identifier
language: Language
source: Annotated[str, Field(max_length=MAX_SOURCE_BYTES, repr=False)]
class CancellationAccepted(WireModel):
submission_id: Identifier
evaluation_id: Identifier
# Acceptance means Coordinator durably accepted intent, not that processes have stopped.
accepted: Literal[True] = True
+69 -1
View File
@@ -1,8 +1,11 @@
from pathlib import Path
from typing import Literal
from pydantic import SecretStr
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from kf_oj.contracts.judging_v2 import NodeAdmission
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="KF_OJ_", env_file=".env", extra="ignore")
@@ -10,4 +13,69 @@ 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",
"http://localhost:8000",
"http://127.0.0.1:5173",
"http://127.0.0.1:8000",
)
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
coordinator_url: str | None = None
coordinator_token: SecretStr | None = None
@model_validator(mode="after")
def secure_origins(self):
from ipaddress import ip_address
from urllib.parse import urlsplit
if (self.coordinator_url is None) != (self.coordinator_token is None):
raise ValueError("both coordinator URL and token required")
if self.coordinator_url is not None:
parts = urlsplit(self.coordinator_url)
try:
valid = parts.hostname is not None and ip_address(parts.hostname).is_loopback
port = parts.port
except ValueError:
valid, port = False, None
if (
not valid
or parts.scheme != "http"
or port is None
or parts.path not in {"", "/"}
or parts.username
or parts.password
or parts.query
or parts.fragment
):
raise ValueError("coordinator must be an explicit numeric loopback HTTP origin")
import re
if not re.fullmatch(r"[0-9a-f]{64}", self.coordinator_token.get_secret_value()):
raise ValueError("invalid coordinator service credential")
if not self.browser_origins or len(self.browser_origins) > 8:
raise ValueError("one to eight browser origins required")
for origin in self.browser_origins:
parts = urlsplit(origin)
if (
parts.scheme not in {"http", "https"}
or not parts.hostname
or parts.username
or parts.password
or parts.path
or parts.query
or parts.fragment
or (self.environment == "production" and parts.scheme != "https")
):
raise ValueError(
"browser origins must be explicit origins; production requires HTTPS"
)
return self
@@ -0,0 +1,20 @@
# 不可变工件
存储根配置于 `KF_OJ_ARTIFACT_ROOT`,由服务账号持有,目录 0700、文件 0600。
单件至多 64 MiB;默认总字节 512 MiB / 文件 4096,可在有界配置中调整。
发布采用摘要名称、临时文件、文件/目录 fsync 和跨进程锁;已有工件必须通过长度/摘要核验,
拒绝静默覆盖/修复,普通读取不跟随链接。锁等待最多五秒。临时文件也计入容量。
适配器执行已经授权的操作,不自行识别用户、租约或题库。公开样例由 problems 的公开 DTO
提供;个人源码由 submissions 的已验证主体与归属端口读取;隐藏数据只经有效当前租约取得。
没有匿名工件下载或由文件路径选择的读取接口。
发布事务先取得 digest 锁,再持久化字节与元数据,业务引用最后提交;事务回滚最多留下
无元数据的耐久孤立文件,不留下数据库引用指向未发布字节。回收使用同一 digest 锁,
业务引用的数据库生成 ready 状态及复合外键阻止被引用元数据转为 deleting。
`uv run --locked kf-artifacts --limit 100 [--after <digest>]` 执行有限回收批次:恢复 deleting、
标记超过一天的未引用元数据、删除耐久字节/元数据及超过一天的无元数据文件/临时文件。
每次扫描至多 100 行;显示游标,后续调用接续,避免固定前页上的保留工件阻塞扫描。
引用工件不能被回收。文件删除后事务失败仍保持 deleting,下一批可恢复;新引用无法加入。
需要已迁移数据库和有限平台权限;回收不遍历业务域 ORM,也不删除不可变题目版本。
@@ -1 +1 @@
"""Reserved for authenticated artifact access and digest verification."""
"""Digest-addressed immutable storage; business ports enforce authorization."""
@@ -0,0 +1,340 @@
"""Bounded single-level ZIP reader; no extractall, metadata restoration or content execution."""
import binascii
import hashlib
import os
import re
import stat
import struct
import time
import zlib
from collections.abc import Iterator
from dataclasses import dataclass
from kf_oj.contracts.admin_problems import (
MAX_ARCHIVE_BYTES,
MAX_ARCHIVE_FILES,
MAX_UNPACKED_BYTES,
)
NAME = re.compile(rb"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\Z")
CHUNK = 65536
LOCAL = struct.Struct("<4s5H3I2H")
CENTRAL = struct.Struct("<4s6H3I5H2I")
END = struct.Struct("<4s4H2IH")
class ArchiveError(ValueError):
"""A fixed sanitized failure; never echo an archive name, data or local path."""
@dataclass(frozen=True)
class ArchiveMember:
name: str
method: int
crc32: int
compressed_bytes: int
bytes: int
offset: int
flags: int
class ArchiveReader:
def __init__(self, descriptor: int, expected_bytes: int, *, deadline: float):
self.descriptor, self.deadline = descriptor, deadline
self.before = os.fstat(descriptor)
if (
not stat.S_ISREG(self.before.st_mode)
or self.before.st_uid != os.getuid()
or self.before.st_nlink != 1
or stat.S_IMODE(self.before.st_mode) != 0o600
or not 22 <= expected_bytes <= MAX_ARCHIVE_BYTES
or self.before.st_size != expected_bytes
):
raise ArchiveError("invalid_archive")
self.size = expected_bytes
self.unpacked_bytes, self.steps = 0, 0
self.read_members: set[str] = set()
self.members = self._members()
def _tick(self) -> None:
self.steps += 1
if self.steps > 8192 or time.monotonic() >= self.deadline:
raise ArchiveError("archive_deadline")
def _read(self, offset: int, size: int) -> bytes:
self._tick()
if not 0 <= size <= 1024**2 or offset < 0 or offset + size > self.size:
raise ArchiveError("invalid_archive")
data = os.pread(self.descriptor, size, offset)
if len(data) != size:
raise ArchiveError("invalid_archive")
return data
@staticmethod
def _extra(data: bytes) -> None:
offset, fields = 0, 0
# These bounded timestamps/UID attributes are ignored, never restored.
# ZIP64, Unicode aliases and all other extension semantics are unsupported.
while offset < len(data):
if offset + 4 > len(data) or fields >= 32:
raise ArchiveError("invalid_archive")
kind, size = struct.unpack_from("<HH", data, offset)
offset += 4
if kind not in (0x5455, 0x7875, 0x5855, 0x7855, 0x000A) or offset + size > len(data):
raise ArchiveError("invalid_archive")
offset += size
fields += 1
def _members(self) -> tuple[ArchiveMember, ...]:
tail_offset = max(0, self.size - 1046)
tail = self._read(tail_offset, self.size - tail_offset)
position = tail.rfind(b"PK\x05\x06")
if position < 0 or position + END.size > len(tail):
raise ArchiveError("invalid_archive")
sig, disk, cd_disk, disk_count, count, cd_bytes, cd_offset, comment = END.unpack_from(
tail, position
)
end_offset = tail_offset + position
if (
sig != b"PK\x05\x06"
or disk != 0
or cd_disk != 0
or disk_count != count
or not 1 <= count <= MAX_ARCHIVE_FILES
or comment > 1024
or end_offset + END.size + comment != self.size
or not 46 <= cd_bytes <= 1024**2
or cd_offset + cd_bytes != end_offset
):
raise ArchiveError("invalid_archive")
directory = self._read(cd_offset, cd_bytes)
offset, records, names = 0, [], set()
for _ in range(count):
if offset + CENTRAL.size > len(directory):
raise ArchiveError("invalid_archive")
values = CENTRAL.unpack_from(directory, offset)
(
sig,
_,
needed,
flags,
method,
_,
_,
crc,
compressed,
size,
name_size,
extra_size,
comment_size,
disk,
_,
attributes,
local_offset,
) = values
allowed_flags = 0x0808 | (0x0006 if method == 8 else 0)
if (
sig != b"PK\x01\x02"
or needed not in (10, 20)
or method not in (0, 8)
or (method == 8 and needed != 20)
or flags & ~allowed_flags
or disk != 0
or not 1 <= name_size <= 128
or extra_size > 1024
or comment_size > 256
or size > MAX_UNPACKED_BYTES
or compressed > MAX_ARCHIVE_BYTES
or local_offset + LOCAL.size > cd_offset
or attributes & 0x10
or stat.S_IFMT(attributes >> 16) not in (0, stat.S_IFREG)
):
raise ArchiveError("invalid_archive")
start = offset + CENTRAL.size
finish = start + name_size + extra_size + comment_size
if finish > len(directory):
raise ArchiveError("invalid_archive")
name_bytes = directory[start : start + name_size]
if not NAME.fullmatch(name_bytes):
raise ArchiveError("invalid_archive")
name = name_bytes.decode("ascii")
if name.lower() in names:
raise ArchiveError("invalid_archive")
names.add(name.lower())
self._extra(directory[start + name_size : start + name_size + extra_size])
records.append(ArchiveMember(name, method, crc, compressed, size, local_offset, flags))
offset = finish
if offset != len(directory) or sum(m.bytes for m in records) > MAX_UNPACKED_BYTES:
raise ArchiveError("invalid_archive")
cursor, members = 0, []
for member in sorted(records, key=lambda m: m.offset):
if member.offset != cursor:
raise ArchiveError("invalid_archive")
sig, needed, flags, method, _, _, crc, compressed, size, name_size, extra_size = (
LOCAL.unpack(self._read(cursor, LOCAL.size))
)
if (
sig != b"PK\x03\x04"
or needed not in (10, 20)
or flags != member.flags
or method != member.method
or (method == 8 and needed != 20)
or name_size != len(member.name)
or extra_size > 1024
):
raise ArchiveError("invalid_archive")
metadata = self._read(cursor + LOCAL.size, name_size + extra_size)
if metadata[:name_size] != member.name.encode("ascii"):
raise ArchiveError("invalid_archive")
self._extra(metadata[name_size:])
data_offset = cursor + LOCAL.size + name_size + extra_size
cursor = data_offset + member.compressed_bytes
if cursor > cd_offset or (method == 0 and member.compressed_bytes != member.bytes):
raise ArchiveError("invalid_archive")
if flags & 8:
if (crc, compressed, size) not in (
(0, 0, 0),
(member.crc32, member.compressed_bytes, member.bytes),
):
raise ArchiveError("invalid_archive")
marker = self._read(cursor, 4)
descriptor_size = 16 if marker == b"PK\x07\x08" else 12
descriptor = self._read(cursor, descriptor_size)
described = struct.unpack("<III", descriptor[-12:])
if described != (member.crc32, member.compressed_bytes, member.bytes):
raise ArchiveError("invalid_archive")
cursor += descriptor_size
elif (crc, compressed, size) != (member.crc32, member.compressed_bytes, member.bytes):
raise ArchiveError("invalid_archive")
members.append(
ArchiveMember(
member.name,
method,
member.crc32,
member.compressed_bytes,
member.bytes,
data_offset,
flags,
)
)
if cursor != cd_offset:
raise ArchiveError("invalid_archive")
return tuple(members)
def verify_digest(self, expected: str) -> None:
digest = hashlib.sha256()
for offset in range(0, self.size, CHUNK):
digest.update(self._read(offset, min(CHUNK, self.size - offset)))
if digest.hexdigest() != expected:
raise ArchiveError("archive_digest_mismatch")
def verify_unchanged(self) -> None:
after = os.fstat(self.descriptor)
fields = (
"st_dev",
"st_ino",
"st_nlink",
"st_mode",
"st_uid",
"st_size",
"st_mtime_ns",
"st_ctime_ns",
)
if any(getattr(self.before, f) != getattr(after, f) for f in fields):
raise ArchiveError("archive_changed")
@staticmethod
def _nested(prefix: bytes) -> bool:
return prefix.startswith(
(
b"PK\x03\x04",
b"PK\x05\x06",
b"PK\x07\x08",
b"\x1f\x8b",
b"BZh",
b"\xfd7zXZ\x00",
b"Rar!\x1a\x07",
b"7z\xbc\xaf\x27\x1c",
)
) or (len(prefix) >= 262 and prefix[257:262] == b"ustar")
def chunks(self, name: str, maximum: int) -> Iterator[bytes]:
member = next((m for m in self.members if m.name == name), None)
if member is None or name in self.read_members or member.bytes > maximum:
raise ArchiveError("invalid_archive")
self.read_members.add(name)
used, crc, prefix = 0, 0, bytearray()
inflater = zlib.decompressobj(-15) if member.method == 8 else None
try:
for offset in range(0, member.compressed_bytes, CHUNK):
pending = self._read(
member.offset + offset, min(CHUNK, member.compressed_bytes - offset)
)
while pending:
self._tick()
if inflater:
data = inflater.decompress(pending, min(CHUNK, member.bytes - used + 1))
pending = inflater.unconsumed_tail
if inflater.unused_data:
raise ArchiveError("invalid_archive")
else:
data, pending = pending, b""
used += len(data)
self.unpacked_bytes += len(data)
if (
used > member.bytes
or used > maximum
or self.unpacked_bytes > MAX_UNPACKED_BYTES
):
raise ArchiveError("invalid_archive")
prefix.extend(data[: max(0, 512 - len(prefix))])
if self._nested(bytes(prefix)):
raise ArchiveError("invalid_archive")
crc = binascii.crc32(data, crc)
if data:
yield data
if (inflater and not inflater.eof) or used != member.bytes or crc != member.crc32:
raise ArchiveError("invalid_archive")
except zlib.error:
raise ArchiveError("invalid_archive") from None
def read(self, name: str, maximum: int) -> bytes:
return b"".join(self.chunks(name, maximum))
def extract_remaining(self, directory: int) -> dict[str, tuple[str, int]]:
info = os.fstat(directory)
if (
not stat.S_ISDIR(info.st_mode)
or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o700
):
raise ArchiveError("invalid_extraction_directory")
references = {}
for member in self.members:
if member.name in self.read_members:
continue
fd = os.open(
member.name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
0o600,
dir_fd=directory,
)
try:
digest, size = hashlib.sha256(), 0
for chunk in self.chunks(member.name, member.bytes):
digest.update(chunk)
size += len(chunk)
remaining = memoryview(chunk)
while remaining:
written = os.write(fd, remaining)
if written <= 0:
raise ArchiveError("extraction_unavailable")
remaining = remaining[written:]
os.fsync(fd)
references[member.name] = (digest.hexdigest(), size)
finally:
os.close(fd)
os.fsync(directory)
self.verify_unchanged()
return references
+48
View File
@@ -0,0 +1,48 @@
import argparse
import asyncio
from sqlalchemy.exc import SQLAlchemyError
from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactStore
from kf_oj.infra.db.session import build_engine, build_session_factory
async def run(limit: int, after: str | None) -> None:
settings = Settings()
if settings.artifact_root is None:
raise ValueError("artifact root required")
store = ArtifactStore(
settings.artifact_root,
max_bytes=settings.artifact_max_bytes,
max_files=settings.artifact_max_files,
)
engine = build_engine(settings)
try:
sessions = build_session_factory(engine)
repository = ArtifactRepository(store)
recovered = await repository.finish_deleting(sessions, limit=limit)
marked, cursor = await repository.collect_metadata(sessions, limit=limit, after=after)
removed = await repository.finish_deleting(sessions, limit=limit)
orphans = await repository.collect_orphans(sessions, limit=limit)
print(f"Recovered {recovered}; marked {marked}; removed {removed}; orphans {orphans}.")
print(f"Next metadata scan cursor: {cursor or 'complete'}")
finally:
await engine.dispose()
store.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Collect bounded old unreferenced artifacts")
parser.add_argument("--limit", type=int, default=100, choices=range(1, 101))
parser.add_argument("--after")
args = parser.parse_args()
try:
asyncio.run(run(args.limit, args.after))
except (ValueError, OSError, SQLAlchemyError):
parser.exit(1, "Artifact collection rejected; check storage and database configuration.\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,21 @@
"""Metadata ownership and lifecycle for immutable, digest-addressed bytes."""
from sqlalchemy import BigInteger, CheckConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Artifact(CreatedAt, Base):
__tablename__ = "artifacts"
__table_args__ = (
CheckConstraint("digest ~ '^[0-9a-f]{64}$'", name="digest"),
CheckConstraint("bytes BETWEEN 0 AND 67108864", name="bytes"),
CheckConstraint("state IN ('ready', 'deleting')", name="state"),
UniqueConstraint("digest", "state"),
)
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
bytes: Mapped[int] = mapped_column(BigInteger)
state: Mapped[str] = mapped_column(String(8), server_default="ready")
@@ -0,0 +1,166 @@
"""Byte durability precedes metadata. Digest locks span caller reference transactions."""
import asyncio
from datetime import timedelta
from sqlalchemy import func, select, text, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from kf_oj.contracts.judging import ArtifactReference
from kf_oj.infra.artifacts.models import Artifact
from kf_oj.infra.artifacts.store import ArtifactError, ArtifactStore
async def lock_digest(session: AsyncSession, digest: str) -> None:
await session.execute(
text("SELECT pg_advisory_xact_lock(:key)"),
{"key": int(digest[:16], 16) & ((1 << 63) - 1)},
)
class ArtifactRepository:
def __init__(self, store: ArtifactStore):
self.store = store
async def put(
self, session: AsyncSession, data: bytes, expected: ArtifactReference | None = None
) -> ArtifactReference:
reference = self.store.reference(data)
if expected is not None and reference != expected:
raise ArtifactError("artifact digest or length mismatch")
await lock_digest(session, reference.digest)
existing = await session.get(Artifact, reference.digest)
if existing is not None and (
existing.state != "ready" or existing.bytes != reference.bytes
):
raise ArtifactError("artifact metadata unavailable")
await asyncio.to_thread(self.store.put, data, reference)
await session.execute(
insert(Artifact)
.values(digest=reference.digest, bytes=reference.bytes)
.on_conflict_do_nothing(index_elements=[Artifact.digest])
)
return reference
async def read(self, session: AsyncSession, reference: ArtifactReference) -> bytes:
metadata = await session.get(Artifact, reference.digest)
if metadata is None or metadata.state != "ready" or metadata.bytes != reference.bytes:
raise ArtifactError("artifact metadata unavailable")
return await asyncio.to_thread(self.store.read, reference)
async def put_file(
self, session: AsyncSession, descriptor: int, reference: ArtifactReference
) -> ArtifactReference:
await lock_digest(session, reference.digest)
existing = await session.get(Artifact, reference.digest)
if existing is not None and (
existing.state != "ready" or existing.bytes != reference.bytes
):
raise ArtifactError("artifact metadata unavailable")
task = asyncio.create_task(asyncio.to_thread(self.store.put_file, descriptor, reference))
try:
await asyncio.shield(task)
except BaseException:
await task
raise
await session.execute(
insert(Artifact)
.values(digest=reference.digest, bytes=reference.bytes)
.on_conflict_do_nothing(index_elements=[Artifact.digest])
)
return reference
async def check_metadata(self, session: AsyncSession, reference: ArtifactReference) -> None:
metadata = await session.get(Artifact, reference.digest)
if metadata is None or metadata.state != "ready" or metadata.bytes != reference.bytes:
raise ArtifactError("artifact metadata unavailable")
async def collect_metadata(
self,
sessions: async_sessionmaker,
*,
min_age_seconds: int = 86400,
limit: int = 100,
after: str | None = None,
) -> tuple[int, str | None]:
if min_age_seconds < 3600 or not 1 <= limit <= 100:
raise ArtifactError("invalid collection budget")
if after is not None:
ArtifactReference(digest=after, bytes=0)
async with sessions() as session:
query = select(Artifact.digest).where(
Artifact.state == "ready",
Artifact.created_at < func.now() - timedelta(seconds=min_age_seconds),
)
if after is not None:
query = query.where(Artifact.digest > after)
names = list(await session.scalars(query.order_by(Artifact.digest).limit(limit)))
marked = 0
for digest in names:
async with sessions.begin() as session:
try:
async with session.begin_nested():
changed = await self.mark_deleting(session, digest)
marked += int(changed)
except IntegrityError:
# Referenced rows are protected by their generated ready-state foreign key.
# No domain ORM imports or reverse dependencies are needed for reclamation.
pass
return marked, names[-1] if len(names) == limit else None
async def collect_orphans(
self, sessions: async_sessionmaker, *, min_age_seconds: int = 86400, limit: int = 100
) -> int:
# Only filesystem orphans are collected here. Referenced metadata is never deleted.
# A durable row cannot become an orphan while another transaction publishes it:
# every writer and collector holds the same transaction-scoped digest lock.
names = await asyncio.to_thread(
self.store.old_digests, min_age_seconds=min_age_seconds, limit=limit
)
collected = 0
for digest in names:
async with sessions.begin() as session:
await lock_digest(session, digest)
if await session.get(Artifact, digest) is None:
await asyncio.to_thread(self.store.remove, digest)
collected += 1
await asyncio.to_thread(
self.store.clean_temporary, min_age_seconds=min_age_seconds, limit=limit
)
return collected
async def mark_deleting(self, session: AsyncSession, digest: str) -> bool:
# FK(digest, 'ready') on every business reference rejects this transition if referenced.
await lock_digest(session, digest)
result = await session.scalar(
update(Artifact)
.where(Artifact.digest == digest, Artifact.state == "ready")
.values(state="deleting")
.returning(Artifact.digest)
)
return result is not None
async def finish_deleting(self, sessions: async_sessionmaker, *, limit: int = 100) -> int:
if not 1 <= limit <= 100:
raise ArtifactError("invalid collection budget")
async with sessions() as session:
names = list(
await session.scalars(
select(Artifact.digest)
.where(Artifact.state == "deleting")
.order_by(Artifact.digest)
.limit(limit)
)
)
count = 0
for digest in names:
async with sessions.begin() as session:
await lock_digest(session, digest)
metadata = await session.get(Artifact, digest)
if metadata is not None and metadata.state == "deleting":
await asyncio.to_thread(self.store.remove, digest)
await session.delete(metadata)
count += 1
return count
@@ -0,0 +1,208 @@
"""Owned, private, bounded import staging. A kernel lock survives parent/child handoff."""
import fcntl
import os
import stat
from contextlib import contextmanager
from pathlib import Path
from uuid import UUID
from kf_oj.contracts.admin_problems import MAX_ARCHIVE_FILES, STAGING_MAX_BYTES
from kf_oj.infra.artifacts.store import ArtifactError
class ImportStaging:
def __init__(self, root: Path):
root = root.absolute()
root.mkdir(parents=True, mode=0o700, exist_ok=True)
if root.resolve() != root:
raise ArtifactError("invalid staging root")
self.root = root
self.fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
self.directory(self.fd)
@staticmethod
def directory(fd):
s = os.fstat(fd)
if (
not stat.S_ISDIR(s.st_mode)
or s.st_uid != os.getuid()
or stat.S_IMODE(s.st_mode) != 0o700
):
raise ArtifactError("invalid staging directory")
def close(self):
os.close(self.fd)
@staticmethod
def names(descriptor, maximum):
result = []
with os.scandir(descriptor) as entries:
for entry in entries:
if len(result) >= maximum:
raise ArtifactError("staging file capacity exhausted")
result.append(entry.name)
return result
def operation_ids(self):
identifiers = []
for name in self.names(self.fd, 33):
if name == ".lock":
continue
try:
identifier = UUID(name)
if str(identifier) != name:
raise ValueError
except ValueError:
raise ArtifactError("unexpected staging entry") from None
identifiers.append(identifier)
return identifiers
@contextmanager
def exclusive(self):
lock = os.open(
".lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=self.fd
)
try:
from kf_oj.infra.artifacts.store import ArtifactStore
ArtifactStore._regular(os.fstat(lock))
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ArtifactError("import capacity busy") from None
yield lock
finally:
os.close(lock)
def usage(self):
total, count = 0, 0
for name in self.names(self.fd, 33):
if name == ".lock":
continue
try:
if str(UUID(name)) != name:
raise ValueError
except ValueError:
raise ArtifactError("unexpected staging entry") from None
descriptor = self.operation(UUID(name))
try:
for entry in self.names(descriptor, 3):
count += 1
if count > 2 * (MAX_ARCHIVE_FILES + 2):
raise ArtifactError("staging file capacity exhausted")
if entry == "members":
child = self.members(descriptor)
try:
for member in self.names(child, MAX_ARCHIVE_FILES):
count += 1
info = os.stat(member, dir_fd=child, follow_symlinks=False)
from kf_oj.infra.artifacts.store import ArtifactStore
ArtifactStore._regular(info)
total += info.st_size
finally:
os.close(child)
elif entry in ("archive", "validated.json"):
info = os.stat(entry, dir_fd=descriptor, follow_symlinks=False)
from kf_oj.infra.artifacts.store import ArtifactStore
ArtifactStore._regular(info)
total += info.st_size
else:
raise ArtifactError("unexpected staging entry")
if count > 2 * (MAX_ARCHIVE_FILES + 2) or total > STAGING_MAX_BYTES:
raise ArtifactError("staging capacity exhausted")
finally:
os.close(descriptor)
return total
def operation(self, operation_id: UUID, *, create=False):
name = str(operation_id)
if create:
try:
os.mkdir(name, 0o700, dir_fd=self.fd)
except FileExistsError:
pass
descriptor = os.open(
name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=self.fd
)
try:
self.directory(descriptor)
except Exception:
os.close(descriptor)
raise
return descriptor
@staticmethod
def members(operation, *, create=False):
if create:
os.mkdir("members", 0o700, dir_fd=operation)
descriptor = os.open(
"members", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=operation
)
try:
ImportStaging.directory(descriptor)
except Exception:
os.close(descriptor)
raise
return descriptor
def remove(self, operation_id):
try:
descriptor = self.operation(operation_id)
except FileNotFoundError:
return
try:
names = self.names(descriptor, 3)
if len(names) > 3:
raise ArtifactError("unexpected staging entry")
for name in names:
if name == "members":
child = self.members(descriptor)
try:
entries = self.names(child, MAX_ARCHIVE_FILES)
if len(entries) > MAX_ARCHIVE_FILES:
raise ArtifactError("unexpected staging entry")
for member in entries:
from kf_oj.infra.artifacts.store import ArtifactStore
ArtifactStore._regular(
os.stat(member, dir_fd=child, follow_symlinks=False)
)
os.unlink(member, dir_fd=child)
finally:
os.close(child)
os.rmdir("members", dir_fd=descriptor)
elif name in ("archive", "validated.json"):
from kf_oj.infra.artifacts.store import ArtifactStore
ArtifactStore._regular(os.stat(name, dir_fd=descriptor, follow_symlinks=False))
os.unlink(name, dir_fd=descriptor)
else:
raise ArtifactError("unexpected staging entry")
finally:
os.close(descriptor)
os.rmdir(str(operation_id), dir_fd=self.fd)
os.fsync(self.fd)
def reset_validation(self, operation_id):
directory = self.operation(operation_id)
try:
names = self.names(directory, 3)
if "members" in names:
members = self.members(directory)
try:
for name in self.names(members, MAX_ARCHIVE_FILES):
from kf_oj.infra.artifacts.store import ArtifactStore
ArtifactStore._regular(os.stat(name, dir_fd=members, follow_symlinks=False))
os.unlink(name, dir_fd=members)
finally:
os.close(members)
os.rmdir("members", dir_fd=directory)
if "validated.json" in names:
os.unlink("validated.json", dir_fd=directory)
os.fsync(directory)
finally:
os.close(directory)
+337
View File
@@ -0,0 +1,337 @@
"""Private immutable byte store. Authorization is applied before calling this adapter."""
import fcntl
import hashlib
import os
import re
import stat
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from uuid import uuid4
from kf_oj.contracts.judging import MAX_PACKAGE_BYTES, ArtifactReference
DIGEST = re.compile(r"[0-9a-f]{64}\Z")
TEMP = re.compile(r"\.tmp-[0-9a-f]{32}\Z")
class ArtifactError(ValueError):
"""Sanitized artifact failure; never includes paths or private byte content."""
class ArtifactStore:
def __init__(self, root: Path, *, max_bytes: int = 512 * 1024 * 1024, max_files: int = 4096):
if not 1 <= max_bytes <= 16 * 1024**3 or not 1 <= max_files <= 65536:
raise ArtifactError("invalid storage capacity")
root = root.absolute()
root.mkdir(mode=0o700, parents=True, exist_ok=True)
if root.resolve() != root:
raise ArtifactError("storage root must not contain links")
self._directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
self.max_bytes = max_bytes
self.max_files = max_files
info = os.fstat(self._directory)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
os.close(self._directory)
raise ArtifactError("storage root must be private and owned")
self._closed = False
def close(self) -> None:
if not self._closed:
os.close(self._directory)
self._closed = True
@contextmanager
def exclusive(self) -> Iterator[None]:
# Open a different description for every operation: flock also serializes threads.
descriptor = os.open(
".lock",
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC,
0o600,
dir_fd=self._directory,
)
try:
self._regular(os.fstat(descriptor))
deadline = time.monotonic() + 5
while True:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise ArtifactError("storage lock deadline exceeded") from None
time.sleep(0.01)
yield
finally:
os.close(descriptor)
@staticmethod
def _regular(info: os.stat_result) -> None:
if (
not stat.S_ISREG(info.st_mode)
or info.st_nlink != 1
or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o600
):
raise ArtifactError("invalid storage entry")
def _entries(self) -> list[tuple[str, os.stat_result]]:
entries = []
with os.scandir(self._directory) as directory:
for entry in directory:
if entry.name == ".lock":
continue
if not DIGEST.fullmatch(entry.name) and not TEMP.fullmatch(entry.name):
raise ArtifactError("unexpected storage entry")
info = entry.stat(follow_symlinks=False)
self._regular(info)
entries.append((entry.name, info))
if len(entries) > self.max_files:
raise ArtifactError("storage file capacity exceeded")
return entries
@staticmethod
def reference(data: bytes) -> ArtifactReference:
if type(data) is not bytes or len(data) > MAX_PACKAGE_BYTES:
raise ArtifactError("artifact size outside policy")
return ArtifactReference(digest=hashlib.sha256(data).hexdigest(), bytes=len(data))
def put(self, data: bytes, expected: ArtifactReference | None = None) -> ArtifactReference:
reference = self.reference(data)
if expected is not None and reference != expected:
raise ArtifactError("artifact digest or length mismatch")
with self.exclusive():
entries = self._entries()
if any(name == reference.digest for name, _ in entries):
self._read(reference)
return reference
if len(entries) + 1 > self.max_files or (
sum(info.st_size for _, info in entries) + len(data) > self.max_bytes
):
raise ArtifactError("storage capacity exhausted")
temporary = ".tmp-" + uuid4().hex
descriptor = os.open(
temporary,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
0o600,
dir_fd=self._directory,
)
try:
with os.fdopen(descriptor, "wb") as output:
output.write(data)
output.flush()
os.fsync(output.fileno())
# Locking covers every mutation. Never replace or repair an existing digest.
os.rename(
temporary,
reference.digest,
src_dir_fd=self._directory,
dst_dir_fd=self._directory,
)
os.fsync(self._directory)
finally:
try:
os.unlink(temporary, dir_fd=self._directory)
except FileNotFoundError:
pass
return reference
def _read(self, reference: ArtifactReference) -> bytes:
if (
type(reference.digest) is not str
or not DIGEST.fullmatch(reference.digest)
or type(reference.bytes) is not int
or not 0 <= reference.bytes <= MAX_PACKAGE_BYTES
):
raise ArtifactError("invalid artifact reference")
try:
descriptor = os.open(
reference.digest,
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
dir_fd=self._directory,
)
with os.fdopen(descriptor, "rb") as source:
info = os.fstat(source.fileno())
self._regular(info)
if info.st_size != reference.bytes:
raise ArtifactError("artifact digest or length mismatch")
data = source.read(reference.bytes + 1)
if self.reference(data) != reference:
raise ArtifactError("artifact digest or length mismatch")
return data
except OSError:
raise ArtifactError("artifact unavailable") from None
def read(self, reference: ArtifactReference) -> bytes:
with self.exclusive():
return self._read(reference)
def verify(self, reference: ArtifactReference) -> None:
"""Verify publication inputs with a 64 KiB buffer, without exposing hidden bytes."""
ArtifactReference.model_validate(reference)
with self.exclusive():
try:
descriptor = os.open(
reference.digest,
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
dir_fd=self._directory,
)
with os.fdopen(descriptor, "rb") as source:
before = os.fstat(source.fileno())
self._regular(before)
if before.st_size != reference.bytes:
raise ArtifactError("artifact digest or length mismatch")
digest = hashlib.sha256()
remaining = reference.bytes
while remaining:
data = source.read(min(65536, remaining))
if not data:
raise ArtifactError("artifact digest or length mismatch")
digest.update(data)
remaining -= len(data)
after = os.fstat(source.fileno())
if (
source.read(1)
or digest.hexdigest() != reference.digest
or any(
getattr(before, k) != getattr(after, k)
for k in (
"st_dev",
"st_ino",
"st_nlink",
"st_mode",
"st_uid",
"st_size",
"st_mtime_ns",
"st_ctime_ns",
)
)
):
raise ArtifactError("artifact input changed")
except OSError:
raise ArtifactError("artifact unavailable") from None
def put_file(self, source: int, expected: ArtifactReference) -> ArtifactReference:
"""Adopt a validated private FD without buffering a hidden testcase in memory."""
before = os.fstat(source)
self._regular(before)
if before.st_size != expected.bytes:
raise ArtifactError("artifact digest or length mismatch")
def unchanged(descriptor, original):
current = os.fstat(descriptor)
fields = (
"st_dev",
"st_ino",
"st_nlink",
"st_mode",
"st_uid",
"st_size",
"st_mtime_ns",
"st_ctime_ns",
)
if any(getattr(current, k) != getattr(original, k) for k in fields):
raise ArtifactError("artifact input changed")
def stream(descriptor, *, output=None):
digest = hashlib.sha256()
for offset in range(0, expected.bytes, 65536):
data = os.pread(descriptor, min(65536, expected.bytes - offset), offset)
if len(data) != min(65536, expected.bytes - offset):
raise ArtifactError("artifact digest or length mismatch")
digest.update(data)
if output is not None:
output.write(data)
if digest.hexdigest() != expected.digest:
raise ArtifactError("artifact digest or length mismatch")
with self.exclusive():
entries = self._entries()
if any(name == expected.digest for name, _ in entries):
existing = os.open(
expected.digest,
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
dir_fd=self._directory,
)
try:
self._regular(os.fstat(existing))
if os.fstat(existing).st_size != expected.bytes:
raise ArtifactError("artifact digest or length mismatch")
stream(source)
stream(existing)
finally:
os.close(existing)
else:
if (
len(entries) + 1 > self.max_files
or sum(s.st_size for _, s in entries) + expected.bytes > self.max_bytes
):
raise ArtifactError("storage capacity exhausted")
temporary = ".tmp-" + uuid4().hex
descriptor = os.open(
temporary,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
0o600,
dir_fd=self._directory,
)
try:
with os.fdopen(descriptor, "wb") as out:
stream(source, output=out)
out.flush()
os.fsync(out.fileno())
unchanged(source, before)
os.rename(
temporary,
expected.digest,
src_dir_fd=self._directory,
dst_dir_fd=self._directory,
)
os.fsync(self._directory)
finally:
try:
os.unlink(temporary, dir_fd=self._directory)
except FileNotFoundError:
pass
unchanged(source, before)
return expected
def remove(self, digest: str) -> None:
if not DIGEST.fullmatch(digest):
raise ArtifactError("invalid artifact identifier")
with self.exclusive():
try:
info = os.stat(digest, dir_fd=self._directory, follow_symlinks=False)
self._regular(info)
os.unlink(digest, dir_fd=self._directory)
os.fsync(self._directory)
except FileNotFoundError:
pass
def old_digests(self, *, min_age_seconds: int, limit: int) -> tuple[str, ...]:
if not 3600 <= min_age_seconds or not 1 <= limit <= 100:
raise ArtifactError("invalid collection budget")
with self.exclusive():
cutoff = time.time() - min_age_seconds
return tuple(
name
for name, info in self._entries()
if DIGEST.fullmatch(name) and info.st_mtime < cutoff
)[:limit]
def clean_temporary(self, *, min_age_seconds: int = 86400, limit: int = 100) -> int:
if min_age_seconds < 3600 or not 1 <= limit <= 100:
raise ArtifactError("invalid collection budget")
with self.exclusive():
cutoff = time.time() - min_age_seconds
names = [
name
for name, info in self._entries()
if TEMP.fullmatch(name) and info.st_mtime < cutoff
][:limit]
for name in names:
os.unlink(name, dir_fd=self._directory)
if names:
os.fsync(self._directory)
return len(names)
+30
View File
@@ -0,0 +1,30 @@
"""Bounded loopback control adapter; never owns evaluation database writes."""
from uuid import UUID
import httpx
class CoordinatorControl:
def __init__(self, url: str, token: str):
self.url, self.token = url.rstrip("/"), token
async def cancel(self, evaluation_id: UUID) -> bool:
try:
async with httpx.AsyncClient(
timeout=3, trust_env=False, follow_redirects=False
) as client:
# Response has no business payload. Do not buffer or log arbitrary remote bodies.
async with client.stream(
"POST",
self.url + "/internal/cancel",
headers={"Authorization": "Bearer " + self.token},
json={
"schema_version": 1,
"kind": "judge.evaluation",
"evaluation_id": str(evaluation_id),
},
) as response:
return response.status_code == 204
except httpx.HTTPError:
return False
+12
View File
@@ -0,0 +1,12 @@
"""Shared column types; business schemas are registered by composition roots."""
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class CreatedAt:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+15 -1
View File
@@ -6,7 +6,21 @@ from kf_oj.core.settings import Settings
def build_engine(settings: Settings) -> AsyncEngine:
if settings.database_url is None:
raise ValueError("KF_OJ_DATABASE_URL is required for database-backed operations")
return create_async_engine(settings.database_url.get_secret_value(), pool_pre_ping=True)
return create_async_engine(
settings.database_url.get_secret_value(),
pool_pre_ping=True,
pool_size=5,
max_overflow=0,
pool_timeout=5,
connect_args={
"command_timeout": 10,
"server_settings": {
"statement_timeout": "10000",
"lock_timeout": "5000",
"idle_in_transaction_session_timeout": "30000",
},
},
)
def build_session_factory(engine: AsyncEngine) -> async_sessionmaker:
@@ -0,0 +1,31 @@
"""Transactional notifications; contains IDs, never source or hidden artifacts."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Outbox(CreatedAt, Base):
__tablename__ = "outbox"
__table_args__ = (
CheckConstraint("failures BETWEEN 0 AND 100", name="failure_budget"),
CheckConstraint("kind = 'judge.evaluation'", name="kind"),
Index("ix_outbox_delivery", "delivered_at", "next_attempt_at", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
evaluation_id: Mapped[UUID] = mapped_column(ForeignKey("evaluations.id"), unique=True)
kind: Mapped[str] = mapped_column(String(32), server_default="judge.evaluation")
failures: Mapped[int] = mapped_column(server_default="0")
next_attempt_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
lock_owner: Mapped[UUID | None]
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(32))
@@ -0,0 +1,183 @@
"""Finite transactional outbox delivery; confirmations are notifications, not grades."""
import asyncio
import logging
import signal
from uuid import uuid4
import aio_pika
from sqlalchemy import text
from kf_oj.contracts.judge import JudgeTaskNotification
from kf_oj.core.settings import Settings
from kf_oj.infra.db.session import build_engine, build_session_factory
QUEUE = "kf.judge.evaluations.v1"
QUEUE_ARGUMENTS = {
"x-max-length": 64,
"x-max-length-bytes": 32768,
"x-overflow": "reject-publish",
"x-message-ttl": 600000,
}
class OutboxRepository:
def __init__(self, sessions):
self.sessions = sessions
async def claim(self):
owner = uuid4()
async with self.sessions.begin() as s:
row = (
await s.execute(
text("""WITH candidate AS (
SELECT id FROM outbox
WHERE delivered_at IS NULL AND next_attempt_at<=clock_timestamp()
AND (locked_until IS NULL OR locked_until<=clock_timestamp())
ORDER BY next_attempt_at,id FOR UPDATE SKIP LOCKED LIMIT 1)
UPDATE outbox o SET lock_owner=:owner,
locked_until=clock_timestamp()+interval '15 seconds'
FROM candidate c WHERE o.id=c.id RETURNING o.id,o.evaluation_id"""),
{"owner": owner},
)
).first()
return None if row is None else (row.id, row.evaluation_id, owner)
async def confirmed(self, item):
async with self.sessions.begin() as s:
result = await s.execute(
text("""UPDATE outbox SET delivered_at=clock_timestamp(),
locked_until=NULL,lock_owner=NULL,last_error_code=NULL
WHERE id=:id AND lock_owner=:owner AND locked_until>clock_timestamp()"""),
{"id": item[0], "owner": item[2]},
)
return result.rowcount == 1
async def failed(self, item):
async with self.sessions.begin() as s:
await s.execute(
text("""UPDATE outbox SET failures=least(failures+1,100),
next_attempt_at=clock_timestamp()+
least(power(2,least(failures+1,6)),60)*interval '1 second',
locked_until=NULL,lock_owner=NULL,last_error_code='broker_unavailable'
WHERE id=:id AND lock_owner=:owner AND locked_until>clock_timestamp()"""),
{"id": item[0], "owner": item[2]},
)
class ConfirmedBroker:
def __init__(self, url):
# Parent disabled does not suppress descendant loggers; set their inherited level.
for name in ("aio_pika", "aiormq", "pamqp"):
logging.getLogger(name).setLevel(logging.CRITICAL)
self.url = url
self.connection = None
self.channel = None
async def open(self):
async with asyncio.timeout(10):
await self._open()
async def _open(self):
# Use a finite connect, not a reconnect loop holding an outbox lock.
self.connection = await aio_pika.connect(self.url, timeout=5, heartbeat=30)
self.channel = await self.connection.channel(publisher_confirms=True, on_return_raises=True)
await self.channel.declare_queue(QUEUE, durable=True, arguments=QUEUE_ARGUMENTS, timeout=5)
async def publish(self, item):
# The complete operation ends before the database claim expires.
async with asyncio.timeout(12):
await self._publish(item)
async def _publish(self, item):
if self.channel is None or self.channel.is_closed:
await self.open()
body = (
JudgeTaskNotification(schema_version=1, kind="judge.evaluation", evaluation_id=item[1])
.model_dump_json()
.encode()
)
assert len(body) <= 512
confirmation = await self.channel.default_exchange.publish(
aio_pika.Message(
body,
content_type="application/json",
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
message_id=str(item[0]),
),
routing_key=QUEUE,
mandatory=True,
timeout=5,
)
from pamqp.commands import Basic
if not isinstance(confirmation, Basic.Ack):
raise RuntimeError("notification not confirmed")
async def close(self):
if self.connection is not None and not self.connection.is_closed:
try:
await asyncio.wait_for(self.connection.close(), timeout=2)
except Exception:
pass
self.connection = self.channel = None
async def run(settings: Settings, stop: asyncio.Event):
if settings.amqp_url is None:
raise ValueError("AMQP configuration required")
engine = build_engine(settings)
repository = OutboxRepository(build_session_factory(engine))
broker = ConfirmedBroker(settings.amqp_url.get_secret_value())
logger = logging.getLogger("kf_oj.outbox")
# Client exceptions may include connection URIs; suppress transport-library logging.
for name in ("aio_pika", "aiormq", "pamqp"):
logging.getLogger(name).disabled = True
try:
async with engine.connect() as c:
allowed = await c.scalar(
text("""SELECT NOT r.rolsuper AND NOT r.rolbypassrls
AND pg_has_role(current_user,'kf_oj_platform','MEMBER')
AND NOT has_column_privilege(current_user,'evaluations','state','UPDATE')
FROM pg_roles r WHERE r.rolname=current_user""")
)
if not allowed:
raise ValueError("dedicated platform database role required")
while not stop.is_set():
try:
item = await repository.claim()
if item is not None:
try:
await broker.publish(item)
await repository.confirmed(item)
except Exception:
await repository.failed(item)
await broker.close()
logger.warning("outbox broker unavailable")
except Exception:
logger.warning("outbox database unavailable")
try:
await asyncio.wait_for(stop.wait(), timeout=1)
except TimeoutError:
pass
finally:
await broker.close()
await engine.dispose()
def main():
async def entry():
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, stop.set)
await run(Settings(), stop)
try:
asyncio.run(entry())
except Exception:
raise SystemExit("outbox unavailable") from None
if __name__ == "__main__":
main()
+56 -2
View File
@@ -1,5 +1,59 @@
# identity
负责账号、登录凭证、会话与角色;向其他模块提供经过验证的用户身份和授权依据。
后续 `public.py` 暴露身份查询接口,`service.py` 编排用例,`repository.py` 封装持久化。
用户身份不能来自前端自报字段或模型参数。目前仅建立模块边界,未实现登录。
`public.py` 提供已验证主体,`service.py` 管理账号和可撤销会话,`repository.py` 封装持久化。
用户身份不能来自前端自报字段或模型参数。
本地管理命令为 `uv run --locked kf-accounts create <username>`。
创建口令由隐藏终端提示读取和确认,不接受命令行参数、环境中的默认口令或自助注册。
用户名为 3–24 个小写 ASCII 字母、数字或下划线;口令 12–128 字符,最多 512 UTF-8 字节。
密码摘要由新增的 `argon2-cffi` 依赖提供 Argon2id,固定 19 MiB/2 次/1 并行度。
参数依据 [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
和 [argon2-cffi 文档](https://argon2-cffi.readthedocs.io/en/stable/parameters.html)。
登录接口为 `/api/v1/auth/login`,退出 `/logout`,当前主体 `/me`。会话使用随机 256-bit token,
数据库只保存 token / CSRF 摘要;默认 12 小时有效,单账号保留至多 10 个有效会话。
账号禁用立即拒绝登录和旧会话。请求只采用 ASGI 连接 peer,不读取客户端转发地址。
`0005_identity_authority` 为旧账号保留普通用户默认值,并加入管理员标记与服务端派生的授权版本。
启用状态或管理员标记实际变化时,同一事务递增授权版本并撤销旧会话;事务回滚也回滚撤销。
平台账号仅可插入普通账号列、更新启用状态,不能写管理员标记或授权版本;管理员的启用状态
需要独立本地运维身份。`kf_oj_identity_operator` 与 `kf_oj_problem_import` 为无登录的独立权限组,
口令与登录身份由运维另行配置,平台不继承运维组。导入组目前没有表权限,后续导入迁移只授予
队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员。
保留授权或撤销历史后拒绝回退这次迁移。
`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`,其中维护能力和十进制授权版本来自当前
有效会话关联的账号;浏览器只展示这些信息,不能提交它们来获得授权。模块公开端口
`ProblemMaintenancePort.require_problem_maintenance` 在调用方写事务中重新检查账号、授权版本
与实时会话,并按账号→会话的顺序持有 SHARE 锁直到提交/回滚,防止验证之后撤权的竞态。
普通构造的 `Principal` 默认没有维护能力;会话摘要仅在服务端主体内传递,不进入 DTO 或 repr。
受控启动使用 `--no-proxy-headers`,避免服务器中间件预先替换 peer;反向代理后的用户共享
该连接预算,引入可信代理的独立限流策略之前不能直接打开客户端转发头信任。
每 peer 至多 10 次登录/15 分钟,预算通过真实数据库串行更新,失败尝试也消耗预算。
摘要计算最多同时 2 次,超出直接 429;限流表至多 4096 桶且每次有界回收过期桶。
有副作用的浏览器请求必须匹配 `KF_OJ_BROWSER_ORIGINS` 的显式来源;生产只接受 HTTPS 来源,
必须提供生产来源配置。会话 Cookie 为 HttpOnly / SameSite=Strict,生产 Secure;CSRF Cookie
供前端读出请求 token,在 `X-CSRF-Token` 携带,服务端核对其与当前会话绑定的摘要。
登录、主体和退出响应不缓存;请求校验错误不回显密码或输入值。API 层限制请求字节、块数和
读取期限。跨服务 Worker 凭证不使用用户 Cookie。
@@ -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
+79
View File
@@ -0,0 +1,79 @@
"""Controlled local account management; passwords are read from the terminal only."""
import argparse
import asyncio
import getpass
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:
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.")
finally:
await engine.dispose()
def main() -> None:
parser = argparse.ArgumentParser(description="Manage local OJ accounts")
parser.add_argument(
"command",
choices=("create", "disable", "enable", "bootstrap-admin", "grant-admin", "revoke-admin"),
)
parser.add_argument("username")
args = parser.parse_args()
password = None
if args.command == "create":
password = getpass.getpass("Password (12–128 characters): ")
if password != getpass.getpass("Repeat password: "):
parser.error("passwords differ")
try:
asyncio.run(run(args.command, args.username, password))
except IntegrityError:
parser.exit(1, "Account operation rejected by database constraints.\n")
except ValueError as error:
parser.exit(1, f"Account operation rejected: {error}.\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,84 @@
"""Identity-owned persistent account and revocable session records."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
SmallInteger,
String,
Text,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
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__ = (
CheckConstraint("username ~ '^[a-z0-9_]{3,24}$'", name="canonical_username"),
CheckConstraint("char_length(password_hash) BETWEEN 40 AND 256", name="password_hash_size"),
CheckConstraint("authorization_version >= 1", name="authorization_version"),
Index(
"ix_accounts_active_administrators",
"id",
postgresql_where=text("is_administrator AND enabled"),
),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(24), unique=True)
password_hash: Mapped[str] = mapped_column(Text)
enabled: Mapped[bool] = mapped_column(Boolean, server_default="true")
is_administrator: Mapped[bool] = mapped_column(Boolean, server_default="false")
authorization_version: Mapped[int] = mapped_column(BigInteger, server_default="1")
class Session(CreatedAt, Base):
__tablename__ = "sessions"
__table_args__ = (
CheckConstraint("token_hash ~ '^[0-9a-f]{64}$'", name="token_digest"),
CheckConstraint("csrf_hash ~ '^[0-9a-f]{64}$'", name="csrf_digest"),
CheckConstraint("expires_at > created_at", name="expiry"),
Index("ix_sessions_account_expiry", "account_id", "expires_at"),
)
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
account_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
csrf_hash: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class LoginThrottle(Base):
__tablename__ = "login_throttles"
__table_args__ = (
CheckConstraint("bucket_hash ~ '^[0-9a-f]{64}$'", name="bucket_digest"),
CheckConstraint("attempts BETWEEN 1 AND 10", name="attempt_budget"),
Index("ix_login_throttles_reset", "reset_at"),
)
bucket_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
attempts: Mapped[int]
reset_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
@@ -0,0 +1,32 @@
"""Argon2id password verification with explicit bounded parameters."""
import re
from argon2 import PasswordHasher, Type
from argon2.exceptions import InvalidHashError, VerificationError
USERNAME = re.compile(r"[a-z0-9_]{3,24}\Z")
HASHER = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1, type=Type.ID)
def validate_account(username: str, password: str) -> None:
if not USERNAME.fullmatch(username) or not 12 <= len(password) <= 128:
raise ValueError("username or password outside account policy")
if len(password.encode("utf-8")) > 512:
raise ValueError("password outside byte policy")
def hash_password(username: str, password: str) -> str:
validate_account(username, password)
return HASHER.hash(password)
def verify_password(stored: str, password: str) -> bool:
try:
# Stored hashes are owner-controlled, but don't allow corrupted parameters to allocate
# arbitrary memory. Account creation always uses these exact parameters.
if not stored.startswith("$argon2id$v=19$m=19456,t=2,p=1$"):
return False
return HASHER.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
@@ -0,0 +1,38 @@
"""Only verified principals cross business module boundaries."""
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
@dataclass(frozen=True)
class Principal:
account_id: UUID
username: str
can_maintain_problems: bool = False
authorization_version: int = 1
session_token_hash: str | None = field(default=None, repr=False, compare=False)
class MaintenanceDenied(ValueError):
pass
class ProblemMaintenancePort(Protocol):
async def require_problem_maintenance(self, session, principal: Principal) -> None:
"""Recheck and lock live authority/session inside the caller's write transaction."""
...
class AuthenticationFailed(ValueError):
pass
class ReservedMaintenancePort(Protocol):
async def require_reserved_maintenance(self, session, actor_id: UUID, epoch: int) -> None:
"""Recheck/lock an immutable accepted receipt, with no login/session secret access."""
...
class LoginThrottled(ValueError):
pass
@@ -0,0 +1,97 @@
from datetime import datetime
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from kf_oj.modules.identity.models import Account, LoginThrottle, Session
from kf_oj.modules.identity.public import LoginThrottled
async def consume_login_budget(
session: AsyncSession, bucket: str, now: datetime, reset_at: datetime
) -> None:
# An upsert establishes the key before FOR UPDATE; requests share one DB-backed budget.
inserted = await session.scalar(
insert(LoginThrottle)
.values(bucket_hash=bucket, attempts=1, reset_at=reset_at)
.on_conflict_do_nothing(index_elements=[LoginThrottle.bucket_hash])
.returning(LoginThrottle.bucket_hash)
)
row = await session.scalar(
select(LoginThrottle).where(LoginThrottle.bucket_hash == bucket).with_for_update()
)
assert row is not None
# The initial upsert consumes this request only if there was no prior row.
# A separate returning flag avoids granting an extra request during concurrency.
if inserted is not None:
return
if row.reset_at <= now:
row.attempts, row.reset_at = 1, reset_at
elif row.attempts >= 10:
raise LoginThrottled("login request budget exhausted")
else:
row.attempts += 1
async def prune_throttles(session: AsyncSession, now: datetime, *, limit: int = 100) -> None:
await session.execute(text("SELECT pg_advisory_xact_lock(-740164777)"))
keys = select(LoginThrottle.bucket_hash).where(LoginThrottle.reset_at <= now)
keys = keys.order_by(LoginThrottle.reset_at).limit(limit).with_for_update(skip_locked=True)
await session.execute(delete(LoginThrottle).where(LoginThrottle.bucket_hash.in_(keys)))
async def check_throttle_capacity(session: AsyncSession, bucket: str) -> None:
if await session.get(LoginThrottle, bucket) is None:
if await session.scalar(select(func.count()).select_from(LoginThrottle)) >= 4096:
raise LoginThrottled("login throttle capacity exhausted")
async def account_by_username(session: AsyncSession, username: str) -> Account | None:
return await session.scalar(select(Account).where(Account.username == username))
async def lock_authority(session: AsyncSession, account_id) -> Account | None:
return await session.scalar(
select(Account)
.where(Account.id == account_id)
.with_for_update(read=True)
.execution_options(populate_existing=True)
)
async def current_session(session: AsyncSession, token_hash: str, *, lock_session: bool = False):
query = (
select(Session, Account)
.join(Account)
.where(
Session.token_hash == token_hash,
Session.revoked_at.is_(None),
Session.expires_at > func.clock_timestamp(),
Account.enabled.is_(True),
)
)
if lock_session:
query = query.with_for_update(read=True, of=Session).execution_options(
populate_existing=True
)
return (await session.execute(query)).one_or_none()
async def prune_sessions(session: AsyncSession, account_id) -> None:
await session.execute(
delete(Session).where(
Session.account_id == account_id,
(Session.expires_at <= func.now()) | Session.revoked_at.is_not(None),
)
)
# Keep at most nine older valid sessions before adding the new one.
old = select(Session.token_hash).where(Session.account_id == account_id)
old = old.order_by(Session.created_at.desc(), Session.token_hash).offset(9)
await session.execute(delete(Session).where(Session.token_hash.in_(old)))
async def revoke(session: AsyncSession, token_hash: str) -> None:
await session.execute(
update(Session).where(Session.token_hash == token_hash).values(revoked_at=func.now())
)
@@ -0,0 +1,190 @@
import asyncio
import hashlib
import hmac
import re
import secrets
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from uuid import uuid4
from sqlalchemy import func, select, text, update
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.modules.identity import repository
from kf_oj.modules.identity.models import Account, Session
from kf_oj.modules.identity.passwords import (
HASHER,
hash_password,
validate_account,
verify_password,
)
from kf_oj.modules.identity.public import (
AuthenticationFailed,
LoginThrottled,
MaintenanceDenied,
Principal,
)
TOKEN = re.compile(r"[0-9a-f]{64}\Z")
def token_digest(value: str) -> str:
return hashlib.sha256(value.encode("ascii")).hexdigest()
@dataclass(frozen=True)
class IssuedSession:
principal: Principal
expires_at: datetime
token: str = field(repr=False)
csrf_token: str = field(repr=False)
class IdentityService:
def __init__(self, sessions: async_sessionmaker, *, lifetime_seconds: int = 43200):
if not 300 <= lifetime_seconds <= 86400:
raise ValueError("invalid session lifetime")
self.sessions = sessions
self.lifetime_seconds = lifetime_seconds
self._hash_slots = asyncio.Semaphore(2)
self._dummy_hash = HASHER.hash(secrets.token_hex(32))
async def create_account(self, username: str, password: str) -> Principal:
hashed = await asyncio.to_thread(hash_password, username, password)
async with self.sessions.begin() as session:
account = Account(id=uuid4(), username=username, password_hash=hashed)
session.add(account)
return Principal(account.id, username)
async def disable_account(self, username: str) -> bool:
async with self.sessions.begin() as session:
account = await repository.account_by_username(session, username)
if account is None:
return False
account.enabled = False
await session.execute(
update(Session)
.where(Session.account_id == account.id)
.values(revoked_at=func.now())
)
return True
async def login(self, username: str, password: str, *, peer: str) -> IssuedSession:
validate_account(username, password)
if not peer or len(peer) > 128:
raise AuthenticationFailed("invalid login context")
# Fail fast instead of allowing an unbounded password-hash queue.
if self._hash_slots.locked():
raise LoginThrottled("password verification capacity exhausted")
async with self._hash_slots:
async with self.sessions.begin() as session:
now = await session.scalar(select(func.now()))
assert now is not None
await repository.prune_throttles(session, now)
# Direct peer only; caller must never substitute forwarded client headers.
bucket = hashlib.sha256(peer.encode("utf-8")).hexdigest()
await repository.check_throttle_capacity(session, bucket)
await repository.consume_login_budget(
session, bucket, now, now + timedelta(minutes=15)
)
async with self.sessions() as session:
account = await repository.account_by_username(session, username)
hashed = account.password_hash if account is not None else self._dummy_hash
correct = await asyncio.to_thread(verify_password, hashed, password)
if not correct or account is None or not account.enabled:
raise AuthenticationFailed("invalid credentials")
token, csrf = secrets.token_hex(32), secrets.token_hex(32)
async with self.sessions.begin() as session:
# Serialize with disabling the account and concurrent logins before issuing.
account = await session.scalar(
select(Account).where(Account.id == account.id).with_for_update()
)
if account is None or not account.enabled:
raise AuthenticationFailed("invalid credentials")
now = await session.scalar(select(func.now()))
expires = now + timedelta(seconds=self.lifetime_seconds)
await repository.prune_sessions(session, account.id)
session.add(
Session(
token_hash=token_digest(token),
account_id=account.id,
csrf_hash=token_digest(csrf),
expires_at=expires,
)
)
principal = self._principal(account, token_digest(token))
return IssuedSession(principal, expires, token, csrf)
async def authenticate(self, token: str | None, *, csrf_token: str | None = None) -> Principal:
if token is None or not TOKEN.fullmatch(token):
raise AuthenticationFailed("invalid session")
async with self.sessions() as session:
row = await repository.current_session(session, token_digest(token))
if row is None:
raise AuthenticationFailed("invalid session")
record, account = row
if csrf_token is not None:
if not TOKEN.fullmatch(csrf_token) or not hmac.compare_digest(
record.csrf_hash, token_digest(csrf_token)
):
raise AuthenticationFailed("invalid request token")
return self._principal(account, record.token_hash)
@staticmethod
def _principal(account: Account, session_token_hash: str) -> Principal:
return Principal(
account.id,
account.username,
account.is_administrator,
account.authorization_version,
session_token_hash,
)
async def require_problem_maintenance(self, session, principal: Principal) -> None:
if not session.in_transaction():
raise ValueError("maintenance authorization requires an active write transaction")
if (
not principal.can_maintain_problems
or principal.session_token_hash is None
or not TOKEN.fullmatch(principal.session_token_hash)
):
raise MaintenanceDenied("problem maintenance required")
# Account first, then session: authority changes use that same lock order.
# SHARE locks last until the caller commits/rolls back; a stale principal,
# explicit logout, revocation or expired session cannot authorize a write.
account = await repository.lock_authority(session, principal.account_id)
if (
account is None
or not account.enabled
or not account.is_administrator
or account.authorization_version != principal.authorization_version
):
raise MaintenanceDenied("problem maintenance required")
row = await repository.current_session(
session, principal.session_token_hash, lock_session=True
)
if row is None or row[1].id != account.id:
raise MaintenanceDenied("problem maintenance required")
async def logout(self, token: str, csrf_token: str) -> None:
await self.authenticate(token, csrf_token=csrf_token)
async with self.sessions.begin() as session:
await repository.revoke(session, token_digest(token))
class ReservedMaintenance:
"""Import worker uses only the fixed, narrow SQL authority port."""
async def require_reserved_maintenance(self, session, actor_id, epoch):
if not session.in_transaction() or type(epoch) is not int or epoch < 1:
raise MaintenanceDenied("invalid maintenance receipt")
try:
await session.execute(
text("SELECT public.kf_lock_reserved_maintenance(:actor,:epoch)"),
{"actor": actor_id, "epoch": epoch},
)
except DBAPIError as error:
if getattr(error.orig, "sqlstate", None) == "42501":
raise MaintenanceDenied("problem maintenance revoked") from None
raise
+15 -3
View File
@@ -1,5 +1,17 @@
# operations
负责平台侧节点、任务积压、重试原因和审计查询的管理视图。
对判题状态的操作必须调用协调器协议;观测模块不成为第二个状态写入入口。
当前只有目录边界,节点登记、指标聚合和管理员接口待实现。
维护审计通过 `public.py` 的追加端口写入调用方事务;本模块不成为评测状态的写入入口。
上传、解析完成/失败、丢弃、发布、下架与恢复记录真实账号、授权版本、操作 UUID、对象与
数据库时间。发布回执和审计同时提交;审计失败使整个业务事务回滚,不确认半发布。
本机受控身份 CLI 使用已核验的窄权限数据库函数。授权变化由固定包装函数在同一事务
追加审计,主体类型为 operator,操作者为数据库 `session_user`,对象为实际变更账号;
不把授予/撤销冒充成该账号自己的操作。旧账号事件保留 account 主体类型。
管理员 `/api/v1/admin/maintenance-audit` 按时间和 UUID 的复合游标递减读取,每页至多
100项,可选择有限动作类型;只展示已提交事件。导入失败动作仍是失败,committed 表示
审计事务提交。响应不含口令、会话令牌、工件引用、内部路径或任意诊断正文。
应用数据库角色无审计 UPDATE/DELETE 权限,数据库触发器还阻止历史改写。
导入 Worker 只有追加权限,不能读取整个维护审计;Coordinator 无审计写入权限。
节点管理、部署控制及跨账号业务管理仍未开放。
@@ -0,0 +1,39 @@
"""Append-only bounded maintenance events, independent of evaluation state."""
from uuid import UUID
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class MaintenanceEvent(CreatedAt, Base):
__tablename__ = "problem_maintenance_events"
__table_args__ = (
CheckConstraint(
"action IN ('upload_created','upload_received','import_ready',"
"'import_failed','import_discarded','import_expired','published',"
"'disabled','restored','authority_bootstrap','authority_grant',"
"'authority_revoke','authority_enable','authority_disable')",
name="action",
),
CheckConstraint("authorization_version > 0", name="authority"),
CheckConstraint(
"(actor_kind='account' AND actor_id IS NOT NULL AND operator_name IS NULL) "
"OR (actor_kind='operator' AND actor_id IS NULL AND operator_name IS NOT NULL)",
name="actor",
),
Index("ix_problem_maintenance_events_page", "created_at", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
actor_id: Mapped[UUID | None] = mapped_column(ForeignKey("accounts.id"))
actor_kind: Mapped[str] = mapped_column(
String(8), default="account", server_default=text("'account'")
)
operator_name: Mapped[str | None] = mapped_column(String(63))
authorization_version: Mapped[int] = mapped_column(BigInteger)
operation_id: Mapped[UUID] = mapped_column()
action: Mapped[str] = mapped_column(String(24))
target_id: Mapped[UUID | None] = mapped_column()
@@ -0,0 +1,28 @@
"""Accepted actor receipts and finite maintenance events; no private data/log text."""
from typing import Literal, Protocol
from uuid import UUID
Action = Literal[
"upload_created",
"upload_received",
"import_ready",
"import_failed",
"import_discarded",
"import_expired",
"published",
"disabled",
"restored",
]
class MaintenanceAuditPort(Protocol):
async def record(
self,
session,
actor_id: UUID,
authorization_version: int,
operation_id: UUID,
action: Action,
target_id: UUID | None = None,
) -> None: ...
@@ -0,0 +1,62 @@
"""Only authorized administrators read bounded append-only maintenance projections."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import select, text, tuple_
from kf_oj.contracts.problem_maintenance import MaintenanceAuditEvent, MaintenanceAuditPage
from kf_oj.modules.identity.public import ProblemMaintenancePort
from kf_oj.modules.operations.models import MaintenanceEvent
class MaintenanceAuditQueries:
def __init__(self, sessions, identity: ProblemMaintenancePort):
self.sessions, self.identity = sessions, identity
async def page(self, principal, *, before=None, limit=20, action=None):
if not 1 <= limit <= 100:
raise ValueError("invalid page limit")
cursor = None
if before is not None:
stamp, identifier = before.split("|")
stamp = datetime.fromisoformat(stamp)
if stamp.tzinfo is None:
raise ValueError("invalid audit cursor")
cursor = stamp, UUID(identifier)
async with self.sessions.begin() as session:
await session.execute(text("SET LOCAL statement_timeout='5s'"))
await session.execute(text("SET LOCAL lock_timeout='2s'"))
await self.identity.require_problem_maintenance(session, principal)
q = select(MaintenanceEvent)
if cursor:
q = q.where(tuple_(MaintenanceEvent.created_at, MaintenanceEvent.id) < cursor)
if action:
q = q.where(MaintenanceEvent.action == action)
rows = list(
await session.scalars(
q.order_by(
MaintenanceEvent.created_at.desc(), MaintenanceEvent.id.desc()
).limit(limit + 1)
)
)
visible = rows[:limit]
return MaintenanceAuditPage(
items=tuple(
MaintenanceAuditEvent(
id=r.id,
created_at=r.created_at,
actor_kind=r.actor_kind,
actor_id=r.actor_id,
operator_name=r.operator_name,
authorization_version=str(r.authorization_version),
operation_id=r.operation_id,
action=r.action,
target_id=r.target_id,
)
for r in visible
),
next_before=f"{visible[-1].created_at.isoformat()}|{visible[-1].id}"
if len(rows) > limit
else None,
)
@@ -0,0 +1,27 @@
from uuid import uuid4
from sqlalchemy import func, select
from kf_oj.modules.operations.models import MaintenanceEvent
from kf_oj.modules.operations.public import Action
class MaintenanceAudit:
async def record(
self, session, actor_id, authorization_version, operation_id, action: Action, target_id=None
):
if not session.in_transaction():
raise ValueError("audit requires a write transaction")
# Supplying the trusted DB timestamp avoids ORM INSERT ... RETURNING:
# the ordinary importer has append-only authority and cannot SELECT this table.
session.add(
MaintenanceEvent(
id=uuid4(),
created_at=await session.scalar(select(func.clock_timestamp())),
actor_id=actor_id,
authorization_version=authorization_version,
operation_id=operation_id,
action=action,
target_id=target_id,
)
)
+77 -2
View File
@@ -1,5 +1,80 @@
# problems
负责题目逻辑身份、不可变版本、导入、标签、限制、发布状态与测试工件元数据。
跨模块访问通过后续 `public.py`;大文件存取通过 `infra/artifacts` 的适配器。
隐藏数据与公开样例分别授权。当前未创建题库表或导入接口。
跨模块通过 `public.py` 的不可变判题投影;大文件存取通过 `infra/artifacts` 的适配器。
公开题面/显式样例与隐藏输入/答案分别授权。公开 API 不返回工件摘要或存储路径。
`uv run --locked kf-problems <directory>` 导入并发布一个新版本。目录必须属于当前操作账号,
权限 0700;`problem.json` 遵循 `import_format.py`,只引用该目录下的单层普通文件名。
不解压归档、不跟随链接、不读取任意路径、不执行导入内容。默认清单可由 `--manifest` 指定。
要求配置 `KF_OJ_ARTIFACT_ROOT` 和可信 `KF_OJ_JUDGE_ADMISSION` JSON;同一准入配置应交付给节点,
不能由题目放宽。结构示例来自共享 `judge-admission.valid.json`,不是当前宿主隔离验收证明。
v1 清单固定 C17/C++20、工具链/镜像版本、精确字节比较、编译/运行/整个作业预算,并声明按序
测试点 `{key,input_file,expected_file}` 和最多十对公开样例 `{input_file,output_file}`。
题面以 `statement_file` 读取 UTF-8。每份样例至多 64 KiB,响应采用 base64 保留二进制数据。
同一 slug 受事务锁保护,每次发布递增 revision;发布后题面、限额、样例、测试点不可修改。
旧评测继续绑定旧版本;当前列表指向新版本。工件先持久化,再在同一事务建立引用和发布。
`GET /api/v1/problems` 以 slug 游标分页(`after` / `limit`,最多 100 项),
`GET /api/v1/problems/versions/{uuid}` 读取已发布版本。禁用逻辑题目时其版本对公开读取不可用。
内部判题投影仅供授权应用编排使用;外部 Worker 读取由 submissions 的有效租约端口控制。
v2 使用 `schema_version: 2` 与 `language_configurations`,每个配置包含 `language`、
完整 `toolchain` 身份、`compile_limits`、`run_limits` 和 `job_wall_time_us`。
可分别声明 `c17`、`cpp20`、`python313`、`java21`;不再同时提供 v1 顶层工具链或预算。
所有语言按可信节点配置准入,缺失能力/错误补丁版本/镜像摘要/过宽预算在发布前拒绝。
`judge.admission.example.json` 的镜像摘要为占位值,使用前替换为管理员制作镜像的实际摘要。
公开题面仅显示固定语言、版本、入口与预算,不公布宿主镜像路径和隐藏工件。
导入同一 slug 的 v2 版本保持原 v1 版本与提交不变。已有提交按其保存的协议版本解析,
不能重新序列化旧 canonical 数据来补入新字段。Java 使用 Main.java 与默认包 Main;
Python 使用 main.py。每个新版本的语言集合独立,浏览器不得为旧版本补开新语言。
Web题包导入是独立的待发布流程:`/api/v1/admin/problem-imports` 使用真实维护会话,
写入还要求明确Origin与绑定CSRF。先以稳定操作UUID、ZIP摘要和精确字节数建立容量预约,
再向该操作的 `/archive` 流式PUT;同键重试仍验证实际内容,不会确认异内容。
列表、详情和丢弃只开放给该操作的管理员本人,响应仅含公开题面、样例、语言预算与有限错误。
`ready` 尚未发布,不会加入学生题库,也不改变既有题目版本。
Web格式固定单层ZIP的stored/deflate,清单名为`problem.json`;公开文件与隐藏用例文件
必须显式分别声明且不得混用。文件名只接受有界ASCII单层普通名;未知成员、链接/设备、
加密、嵌套归档、额外前缀/尾随内容、损坏CRC与虚假大小被拒。旧受控目录CLI格式保持原义。
压缩与实际解压各64 MiB、最多534文件,上传最多4096帧/每帧1 MiB/30秒;
解析期限60秒。staging总预约256 MiB,每次128 MiB,单全局上传/解析槽位,
本人最多10个/全局32个活跃候选,操作24小时到期,解析死亡最多领取两次。
真实限额由管理capabilities返回;缺少配置或新鲜Worker资源证明时明确不可用。
`uv run --locked kf-problem-import-worker` 是普通用户、独立数据库登录的组合入口。
配置`KF_OJ_IMPORT_STAGING_ROOT`、`KF_OJ_ARTIFACT_ROOT`、可信准入和
`KF_OJ_IMPORT_WORKER_CGROUP`;staging与工件目录必须归服务用户所有且权限0700。
服务不会sudo、执行上传内容/学生源码、联网安装依赖或读写评测状态。启动及每次解析
读回128 MiB/swap0/PIDs16/CPU不超过一核及文件/FD/core上限,错误身份/无界环境拒绝服务。
解析子进程继承同一个内核文件锁,父进程死亡不能让GC在存活子进程仍持有文件时释放容量。
租约与fence防止旧结果写入;完整工件持久化、候选和引用原子落库后才返回ready。
丢弃/失败/到期先清理私有暂存字节再释放预约,保留不可变操作记录与追加式有限审计。
资源约束服务模板见`deploy/problem-import-worker.service.example`。API和Worker需可访问
同一普通用户的私有目录;数据库采用各自最小角色,导入Worker只通过固定身份公开端口
验证已受理的维护授权。PrivateNetwork模板要求Unix PostgreSQL socket;实际部署前由运维
设置外部私有配置、专用登录与目录,不包含默认口令,也不代表本轮已经生产部署。
管理员发布使用 `/api/v1/admin/problem-publications`,绑定独立操作 UUID、本人导入操作、
候选 UUID 与预期活动版本(首次发布为 null)。`publication.py` 为 CLI 与 Web 共用的
准入与不可变版本写入路径;Web 在事务内再次校验实时维护权限、候选摘要、工件实际字节
和可信节点配置。CLI 保留本机受控入口与原有导入格式,不借用网页会话。
成功维护回执只追加保存,UUID 同内容重试返回原快照,不重复创建 revision 或审计。
UUID 绑定不同内容或活动版本变化返回409;前端应核对最新状态后建立新的操作凭据。
题目状态写入同样要求预期活动版本及 enabled;下架不修改历史版本或删除任何提交。
新提交持有逻辑题目的共享锁直到提交事务结束,下架/恢复持有排他锁,二者明确排序。
管理列表按 slug、版本历史按递减 revision 分页,每页至多100条,仅返回公开元数据。
候选仍通过本人导入操作读取;维护回执仅本人可读。隐藏测试字节、摘要和宿主路径不进入
这些投影。所有管理读取也经实时维护权限检查,缺失发布配置明确503。
`for_accepted_execution` 仅在 submissions 已核对有效租约、源码归属及不可变尝试快照后
调用,读取已发布原版本时允许逻辑题目已下架。它没有外部 UUID 直读端点,不放宽未发布
版本或无有效租约的访问。公开读取与新提交仍检查 enabled。
@@ -0,0 +1,74 @@
"""Pure data validation shared by the bounded child and fenced candidate transaction."""
import hashlib
import json
from dataclasses import dataclass
from uuid import uuid5
from kf_oj.contracts.admin_problems import CandidatePreview, LegacyImportBudget
from kf_oj.contracts.judging import ArtifactReference, TestCase
from kf_oj.contracts.problems import PublicLanguageConfiguration
from kf_oj.modules.problems.import_format import ImportEnvelope, ProblemImport
from kf_oj.modules.problems.publication import admit_version
@dataclass(frozen=True)
class ValidatedCandidate:
manifest: dict
preview: CandidatePreview
references: dict[str, ArtifactReference]
@property
def digest(self):
return hashlib.sha256(
json.dumps(
self.manifest, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode()
).hexdigest()
def validate_candidate(manifest_data, preview_data, references, policy) -> ValidatedCandidate:
manifest = ImportEnvelope.model_validate(manifest_data).root
preview = CandidatePreview.model_validate(preview_data)
refs = {k: ArtifactReference.model_validate(v) for k, v in references.items()}
legacy = isinstance(manifest, ProblemImport)
if (
preview.slug != manifest.slug
or preview.title != manifest.title
or preview.case_count != len(manifest.cases)
or preview.judging_schema_version != manifest.schema_version
or tuple(v.value for v in preview.languages) != tuple(v.value for v in manifest.languages)
):
raise ValueError("invalid candidate projection")
expected_budget = (
LegacyImportBudget(
compile_limits=manifest.compile_limits,
run_limits=manifest.run_limits,
job_wall_time_us=manifest.job_wall_time_us,
)
if legacy
else None
)
configs = (
()
if legacy
else tuple(
PublicLanguageConfiguration.from_configuration(c)
for c in manifest.language_configurations
)
)
if preview.legacy_budget != expected_budget or preview.language_configurations != configs:
raise ValueError("invalid candidate budget")
names = {name for c in manifest.cases for name in (c.input_file, c.expected_file)}
if set(refs) != names:
raise ValueError("invalid hidden references")
cases = tuple(
TestCase(
case_id=uuid5(preview.candidate_id, c.key),
input=refs[c.input_file],
expected=refs[c.expected_file],
)
for c in manifest.cases
)
admit_version(policy, manifest, preview.candidate_id, preview.candidate_id, cases)
return ValidatedCandidate(manifest.model_dump(mode="json"), preview, refs)
+49
View File
@@ -0,0 +1,49 @@
"""Local, controlled import of ordinary files; no archive extraction or program execution."""
import argparse
import asyncio
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactStore
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.problems.service import ProblemService
async def run(root: Path, manifest: str) -> None:
settings = Settings()
if settings.artifact_root is None or settings.judge_admission is None:
raise ValueError("artifact root and trusted judge admission required")
store = ArtifactStore(
settings.artifact_root,
max_bytes=settings.artifact_max_bytes,
max_files=settings.artifact_max_files,
)
engine = build_engine(settings)
try:
service = ProblemService(
build_session_factory(engine), ArtifactRepository(store), settings.judge_admission
)
version_id = await service.import_directory(root, manifest)
print(f"Problem version published: {version_id}")
finally:
await engine.dispose()
store.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Import immutable problem version")
parser.add_argument("directory", type=Path)
parser.add_argument("--manifest", default="problem.json")
args = parser.parse_args()
try:
asyncio.run(run(args.directory, args.manifest))
except (ValueError, OSError, SQLAlchemyError):
parser.exit(1, "Problem import rejected; check controlled files, budgets and database.\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,86 @@
"""Local operator format. Filenames never cross into the judge execution protocol."""
from typing import Annotated, Literal, Self
from pydantic import Field, RootModel, model_validator
from kf_oj.contracts import judging_v2 as v2
from kf_oj.contracts.judging import (
MAX_CASES,
MAX_JOB_WALL_US,
Label,
Language,
RunLimits,
Toolchain,
Version,
WireModel,
)
FileName = Annotated[str, Field(strict=True, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")]
class ImportCase(WireModel):
key: Label
input_file: FileName
expected_file: FileName
class ImportSample(WireModel):
input_file: FileName
output_file: FileName
class ProblemImport(WireModel):
schema_version: Version
slug: Label
title: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
statement_file: FileName
languages: Annotated[tuple[Language, ...], Field(min_length=1, max_length=2)]
toolchain: Toolchain
comparison: Literal["exact_bytes"]
compile_limits: RunLimits
run_limits: RunLimits
job_wall_time_us: Annotated[int, Field(strict=True, ge=1000, le=MAX_JOB_WALL_US)]
cases: Annotated[tuple[ImportCase, ...], Field(min_length=1, max_length=MAX_CASES)]
samples: Annotated[tuple[ImportSample, ...], Field(max_length=10)]
@model_validator(mode="after")
def unique(self) -> Self:
if len(set(self.languages)) != len(self.languages):
raise ValueError("duplicate language")
if len({case.key for case in self.cases}) != len(self.cases):
raise ValueError("duplicate testcase key")
if len({(c.input_file, c.expected_file) for c in self.cases}) != len(self.cases):
raise ValueError("duplicate testcase reference")
return self
class ProblemImportV2(WireModel):
schema_version: v2.Version
slug: Label
title: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
statement_file: FileName
language_configurations: Annotated[
tuple[v2.LanguageConfiguration, ...], Field(min_length=1, max_length=4)
]
comparison: Literal["exact_bytes"]
cases: Annotated[tuple[ImportCase, ...], Field(min_length=1, max_length=MAX_CASES)]
samples: Annotated[tuple[ImportSample, ...], Field(max_length=10)]
@property
def languages(self) -> tuple[v2.Language, ...]:
return tuple(config.language for config in self.language_configurations)
@model_validator(mode="after")
def unique(self) -> Self:
if len(set(self.languages)) != len(self.languages):
raise ValueError("duplicate language configuration")
if len({case.key for case in self.cases}) != len(self.cases):
raise ValueError("duplicate testcase key")
if len({(case.input_file, case.expected_file) for case in self.cases}) != len(self.cases):
raise ValueError("duplicate testcase reference")
return self
class ImportEnvelope(RootModel[ProblemImport | ProblemImportV2]):
pass
@@ -0,0 +1,121 @@
"""Private durable imports and immutable candidates; no grading-state write authority."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
BigInteger,
CheckConstraint,
Computed,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
SmallInteger,
String,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
from kf_oj.modules.problems.models import ProblemVersion
class ImportPool(Base):
__tablename__ = "problem_import_pool"
__table_args__ = (
CheckConstraint("id = 1", name="singleton"),
CheckConstraint("reserved_bytes BETWEEN 0 AND 268435456", name="capacity"),
)
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
reserved_bytes: Mapped[int] = mapped_column(BigInteger, server_default="0")
worker_ready_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
worker_policy_digest: Mapped[str | None] = mapped_column(String(64))
class ProblemImportOperation(CreatedAt, Base):
__tablename__ = "problem_import_operations"
__table_args__ = (
CheckConstraint(
"state IN ('uploading','uploaded','validating','ready','failed',"
"'published','discarded','expired')",
name="state",
),
CheckConstraint("archive_digest ~ '^[0-9a-f]{64}$'", name="digest"),
CheckConstraint("archive_bytes BETWEEN 1 AND 67108864", name="bytes"),
CheckConstraint("authorization_version > 0", name="authority"),
CheckConstraint("attempts BETWEEN 0 AND 2 AND fence >= 0", name="attempts"),
CheckConstraint("reserved_bytes IN (0,134217728)", name="reservation"),
CheckConstraint("expires_at > created_at AND updated_at >= created_at", name="times"),
Index("ix_problem_import_owner", "owner_id", "id"),
Index("ix_problem_import_queue", "state", "created_at"),
Index("ix_problem_import_expiry", "expires_at"),
Index("ix_problem_import_reservations", "id", postgresql_where=text("reserved_bytes>0")),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
owner_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
authorization_version: Mapped[int] = mapped_column(BigInteger)
archive_digest: Mapped[str] = mapped_column(String(64))
archive_bytes: Mapped[int] = mapped_column(BigInteger)
state: Mapped[str] = mapped_column(String(16), server_default="uploading")
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
attempts: Mapped[int] = mapped_column(Integer, server_default="0")
fence: Mapped[int] = mapped_column(BigInteger, server_default="0")
reserved_bytes: Mapped[int] = mapped_column(BigInteger, server_default="134217728")
failure_code: Mapped[str | None] = mapped_column(String(32))
candidate_id: Mapped[UUID | None] = mapped_column(
ForeignKey("problem_candidates.id", use_alter=True, name="fk_problem_import_candidate")
)
published_version_id: Mapped[UUID | None] = mapped_column(ForeignKey(ProblemVersion.id))
class ProblemCandidate(CreatedAt, Base):
__tablename__ = "problem_candidates"
__table_args__ = (
UniqueConstraint("operation_id"),
CheckConstraint("manifest_digest ~ '^[0-9a-f]{64}$'", name="digest"),
CheckConstraint("case_count BETWEEN 1 AND 256", name="cases"),
CheckConstraint("octet_length(preview::text) <= 4194304", name="preview"),
CheckConstraint("octet_length(manifest::text) <= 262144", name="manifest"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
operation_id: Mapped[UUID] = mapped_column(ForeignKey("problem_import_operations.id"))
manifest_digest: Mapped[str] = mapped_column(String(64))
manifest: Mapped[dict] = mapped_column(JSONB)
preview: Mapped[dict] = mapped_column(JSONB)
case_count: Mapped[int] = mapped_column(Integer)
class CandidateCase(Base):
__tablename__ = "problem_candidate_cases"
__table_args__ = (
CheckConstraint("position BETWEEN 0 AND 255", name="position"),
CheckConstraint(
"input_bytes BETWEEN 0 AND 67108864 AND expected_bytes BETWEEN 0 AND 67108864",
name="bytes",
),
ForeignKeyConstraint(
["input_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
),
ForeignKeyConstraint(
["expected_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name="fk_candidate_cases_expected_artifacts",
),
)
candidate_id: Mapped[UUID] = mapped_column(
ForeignKey("problem_candidates.id"), primary_key=True
)
position: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(64))
input_digest: Mapped[str] = mapped_column(String(64))
input_bytes: Mapped[int] = mapped_column(BigInteger)
expected_digest: Mapped[str] = mapped_column(String(64))
expected_bytes: Mapped[int] = mapped_column(BigInteger)
artifact_state: Mapped[str] = mapped_column(String(8), Computed("'ready'", persisted=True))
@@ -0,0 +1,430 @@
"""Fenced ordinary-user import consumption. No student execution or evaluation DB access."""
import asyncio
import json
import os
import resource
import sys
from datetime import timedelta
from pathlib import Path
from uuid import uuid4
from sqlalchemy import delete, func, or_, select, text
from sqlalchemy.exc import DBAPIError
from kf_oj.contracts.admin_problems import MAX_IMPORT_ATTEMPTS, VALIDATION_DEADLINE_SECONDS
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import MaintenanceDenied
from kf_oj.modules.problems.candidate_validation import validate_candidate
from kf_oj.modules.problems.import_models import (
CandidateCase,
ImportPool,
ProblemCandidate,
)
from kf_oj.modules.problems.import_models import (
ProblemImportOperation as Operation,
)
from kf_oj.modules.problems.imports import ACTIVE, ImportUnavailable, ProblemImports, policy_digest
def verify_worker_limits(configured_group: Path):
if os.getuid() == 0:
raise ImportUnavailable("import worker must be ordinary")
actual = next(
line.split(":", 2)[2]
for line in Path("/proc/self/cgroup").read_text().splitlines()
if line.startswith("0::")
)
group = Path("/sys/fs/cgroup") / actual.lstrip("/")
if group.resolve() != configured_group.absolute().resolve():
raise ImportUnavailable("import worker cgroup mismatch")
caps = {
n: (group / n).read_text().strip()
for n in ("memory.max", "memory.swap.max", "pids.max", "cpu.max")
}
if (
caps["memory.max"] == "max"
or not 0 < int(caps["memory.max"]) <= 134217728
or caps["memory.swap.max"] != "0"
or caps["pids.max"] == "max"
or not 0 < int(caps["pids.max"]) <= 16
):
raise ImportUnavailable("import worker resource limits unavailable")
quota, period = caps["cpu.max"].split()
if quota == "max" or int(quota) > int(period):
raise ImportUnavailable("import worker CPU limit unavailable")
for name, maximum in (
(resource.RLIMIT_FSIZE, 67108864),
(resource.RLIMIT_NOFILE, 64),
(resource.RLIMIT_CORE, 0),
):
limits = resource.getrlimit(name)
if any(v == resource.RLIM_INFINITY or v > maximum for v in limits):
raise ImportUnavailable("import worker process limits unavailable")
return caps
class ImportWorker:
def __init__(self, sessions, staging, artifacts, authority, audit, policy, cgroup):
(
self.sessions,
self.staging,
self.artifacts,
self.authority,
self.audit,
self.policy,
self.cgroup,
) = sessions, staging, artifacts, authority, audit, policy, cgroup
async def heartbeat(self):
verify_worker_limits(self.cgroup)
async with self.sessions.begin() as session:
safe = await session.scalar(
text("""
SELECT rolcanlogin AND NOT(rolsuper OR rolcreatedb OR rolcreaterole
OR rolreplication OR rolbypassrls)
AND pg_has_role(current_user,'kf_oj_problem_import','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_identity_operator','MEMBER')
AND NOT pg_has_role(current_user,'kf_oj_identity_authority_owner','MEMBER')
FROM pg_roles WHERE rolname=current_user
""")
)
if not safe:
raise ImportUnavailable("dedicated unprivileged import login required")
pool = await session.get(ImportPool, 1, with_for_update=True)
if pool is None:
raise ImportUnavailable("import pool unavailable")
pool.worker_ready_until = await session.scalar(
select(func.clock_timestamp())
) + timedelta(seconds=90)
pool.worker_policy_digest = policy_digest(self.policy)
async def _release(self, operation_id):
await asyncio.to_thread(self.staging.remove, operation_id)
async with self.sessions.begin() as session:
pool = await ProblemImports.pool(session)
op = await session.get(Operation, operation_id, with_for_update=True)
pool.reserved_bytes -= op.reserved_bytes
op.reserved_bytes = 0
op.lease_until = None
async def collect(self):
# Caller owns the root flock, so a live upload/parser cannot be unlinked or
# have its reservation reused, even if its DB lease expired while suspended.
async with self.sessions() as session:
now = await session.scalar(select(func.clock_timestamp()))
names = list(
await session.scalars(
select(Operation.id)
.where(
or_(
Operation.reserved_bytes > 0,
Operation.state.in_(ACTIVE) & (Operation.expires_at <= now),
)
)
.order_by(Operation.updated_at, Operation.id)
.limit(32)
)
)
garbage = list(
await session.scalars(
select(ProblemCandidate.operation_id)
.join(Operation, Operation.id == ProblemCandidate.operation_id)
.where(Operation.state.in_(("discarded", "expired", "failed")))
.limit(32)
)
)
names = list(dict.fromkeys(names + garbage))[:32]
for identifier in names:
async with self.sessions.begin() as session:
await ProblemImports.pool(session)
op = await session.get(Operation, identifier, with_for_update=True)
now = await session.scalar(select(func.clock_timestamp()))
if op.state in ACTIVE and op.expires_at <= now:
op.state = "expired"
op.failure_code = "expired"
op.updated_at = now
op.candidate_id = None
op.fence += 1
await self.audit.record(
session, op.owner_id, op.authorization_version, op.id, "import_expired"
)
elif op.state == "uploading" and op.lease_until and op.lease_until <= now:
op.state = "failed"
op.failure_code = "upload_interrupted"
op.updated_at = now
op.fence += 1
await self.audit.record(
session, op.owner_id, op.authorization_version, op.id, "import_failed"
)
elif op.state == "validating" and op.lease_until and op.lease_until <= now:
if op.attempts < MAX_IMPORT_ATTEMPTS:
op.state = "uploaded"
op.failure_code = None
op.lease_until = None
op.fence += 1
op.updated_at = now
await asyncio.to_thread(self.staging.reset_validation, op.id)
else:
op.state = "failed"
op.failure_code = "validation_unavailable"
op.updated_at = now
op.fence += 1
await self.audit.record(
session, op.owner_id, op.authorization_version, op.id, "import_failed"
)
if op.state in ("discarded", "expired", "failed"):
op.candidate_id = None
await session.flush()
candidate = await session.scalar(
select(ProblemCandidate).where(ProblemCandidate.operation_id == op.id)
)
if candidate:
await session.execute(
delete(CandidateCase).where(CandidateCase.candidate_id == candidate.id)
)
await session.delete(candidate)
release = (
op.state in ("ready", "published", "discarded", "expired", "failed")
and op.reserved_bytes > 0
)
if release:
await self._release(identifier)
# Uncommitted staging objects after a failed transaction are removed only under
# the same kernel lock and only from this service's private, bounded namespace.
for identifier in self.staging.operation_ids():
async with self.sessions() as session:
exists = await session.get(Operation, identifier)
if exists is None:
await asyncio.to_thread(self.staging.remove, identifier)
async def once(self):
await self.heartbeat()
try:
with self.staging.exclusive() as lock:
await self.collect()
async with self.sessions.begin() as session:
await ProblemImports.pool(session)
now = await session.scalar(select(func.clock_timestamp()))
busy = await session.scalar(
select(func.count())
.select_from(Operation)
.where(
Operation.state.in_(("uploading", "validating")),
Operation.lease_until > now,
)
)
if busy:
return False
op = await session.scalar(
select(Operation)
.where(
Operation.state == "uploaded",
Operation.attempts < MAX_IMPORT_ATTEMPTS,
Operation.expires_at > now,
)
.order_by(Operation.created_at, Operation.id)
.limit(1)
.with_for_update()
)
if op is None:
return False
op.attempts += 1
op.fence += 1
op.state = "validating"
op.updated_at = now
op.lease_until = now + timedelta(seconds=VALIDATION_DEADLINE_SECONDS + 15)
identifier, fence, size, digest, actor, epoch = (
op.id,
op.fence,
op.archive_bytes,
op.archive_digest,
op.owner_id,
op.authorization_version,
)
try:
async with self.sessions.begin() as session:
await self.authority.require_reserved_maintenance(session, actor, epoch)
candidate_id = uuid4()
result = await self.parse(identifier, size, digest, candidate_id, lock)
if "failure_code" in result:
await self.fail(identifier, fence, result["failure_code"])
return True
candidate = validate_candidate(
result["manifest"], result["preview"], result["references"], self.policy
)
if (
candidate.digest != result["manifest_digest"]
or candidate.preview.candidate_id != candidate_id
):
raise ValueError("invalid candidate receipt")
directory = self.staging.operation(identifier)
members = self.staging.members(directory)
try:
async with self.sessions.begin() as session:
await self.authority.require_reserved_maintenance(session, actor, epoch)
await ProblemImports.pool(session)
op = await session.get(Operation, identifier, with_for_update=True)
now = await session.scalar(select(func.clock_timestamp()))
if (
op.state != "validating"
or op.fence != fence
or op.lease_until <= now
or op.expires_at <= now
):
raise ValueError("stale candidate receipt")
for name, reference in sorted(
candidate.references.items(), key=lambda item: item[1].digest
):
fd = os.open(
name,
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
dir_fd=members,
)
try:
await self.artifacts.put_file(session, fd, reference)
finally:
os.close(fd)
session.add(
ProblemCandidate(
id=candidate_id,
operation_id=identifier,
manifest_digest=candidate.digest,
manifest=candidate.manifest,
preview=candidate.preview.model_dump(mode="json"),
case_count=candidate.preview.case_count,
)
)
await session.flush()
for position, case in enumerate(candidate.manifest["cases"]):
input_ref, answer_ref = (
candidate.references[case["input_file"]],
candidate.references[case["expected_file"]],
)
session.add(
CandidateCase(
candidate_id=candidate_id,
position=position,
key=case["key"],
input_digest=input_ref.digest,
input_bytes=input_ref.bytes,
expected_digest=answer_ref.digest,
expected_bytes=answer_ref.bytes,
)
)
await session.flush()
# Adoption must not turn an expired lease into a ready candidate.
completed_at = await session.scalar(select(func.clock_timestamp()))
if op.lease_until <= completed_at or op.expires_at <= completed_at:
raise ValueError("candidate completion lease expired")
op.state = "ready"
op.candidate_id = candidate_id
op.updated_at = completed_at
op.lease_until = None
await self.audit.record(
session, actor, epoch, identifier, "import_ready", candidate_id
)
finally:
os.close(members)
os.close(directory)
await self._release(identifier)
except MaintenanceDenied:
await self.fail(identifier, fence, "authority_revoked")
except (ArtifactError, OSError):
await self.fail(identifier, fence, "storage_unavailable")
except (ValueError, DBAPIError):
await self.fail(identifier, fence, "validation_unavailable")
return True
except ArtifactError:
return False # Another live writer holds the one global slot.
async def fail(self, identifier, fence, code):
from kf_oj.contracts.admin_problems import ImportFailure
code = ImportFailure(code).value
async with self.sessions.begin() as session:
await ProblemImports.pool(session)
op = await session.get(Operation, identifier, with_for_update=True)
if op.state != "validating" or op.fence != fence:
return
op.state = "failed"
op.failure_code = code
op.updated_at = await session.scalar(select(func.clock_timestamp()))
op.lease_until = None
await self.audit.record(
session, op.owner_id, op.authorization_version, op.id, "import_failed"
)
await self._release(identifier)
async def parse(self, identifier, size, digest, candidate, lock):
verify_worker_limits(self.cgroup)
directory = self.staging.operation(identifier)
archive = members = None
try:
archive = os.open(
"archive",
os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
dir_fd=directory,
)
members = self.staging.members(directory, create=True)
# Preserve only the trusted runtime import roots, without inheriting DB/
# service credentials. Curated runtimes may keep dependencies outside site-packages.
env = {
"PATH": str(Path(sys.executable).parent),
"PYTHONPATH": os.pathsep.join(
dict.fromkeys(
[str(Path(__file__).parents[3])]
+ [p for p in os.environ.get("PYTHONPATH", "").split(os.pathsep) if p]
)
),
"PYTHONUNBUFFERED": "1",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONNOUSERSITE": "1",
"LC_ALL": "C.UTF-8",
}
# Minimal environment may require the trusted dynamic-loader path in a curated VM.
if "LD_LIBRARY_PATH" in os.environ:
env["LD_LIBRARY_PATH"] = os.environ["LD_LIBRARY_PATH"]
child = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"kf_oj.workers._archive_child",
str(archive),
str(members),
str(lock),
str(size),
digest,
str(candidate),
self.policy.model_dump_json(),
env=env,
pass_fds=(archive, members, lock),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
async with asyncio.timeout(VALIDATION_DEADLINE_SECONDS + 5):
output, _ = await child.communicate()
except BaseException:
child.kill()
await child.wait()
raise
if child.returncode != 0 or not 0 < len(output) <= 4 * 1024**2:
return {
"failure_code": "validation_deadline"
if child.returncode == -14
else "validation_unavailable"
}
result = json.loads(output)
if not isinstance(result, dict):
raise ValueError("invalid child result")
return result
except TimeoutError:
return {"failure_code": "validation_deadline"}
finally:
if members is not None:
os.close(members)
if archive is not None:
os.close(archive)
os.close(directory)
@@ -0,0 +1,438 @@
"""Authenticated durable import lifecycle; filesystem work remains behind private adapters."""
import asyncio
import hashlib
import os
from contextlib import asynccontextmanager
from datetime import timedelta
from uuid import UUID
from sqlalchemy import func, select, text
from kf_oj.contracts.admin_problems import (
IMPORT_LIFETIME_SECONDS,
MAX_OWNER_CANDIDATES,
MAX_TOTAL_CANDIDATES,
MAX_UPLOAD_CHUNK_BYTES,
MAX_UPLOAD_CHUNKS,
STAGING_MAX_BYTES,
STAGING_RESERVATION_BYTES,
UPLOAD_DEADLINE_SECONDS,
CandidatePreview,
CreateProblemImport,
ProblemImportCapabilities,
ProblemImportDetail,
ProblemImportPage,
ProblemImportSummary,
)
from kf_oj.infra.artifacts.staging import ImportStaging
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import MaintenanceDenied, ProblemMaintenancePort
from kf_oj.modules.operations.public import MaintenanceAuditPort
from kf_oj.modules.problems.import_models import ImportPool, ProblemCandidate
from kf_oj.modules.problems.import_models import ProblemImportOperation as Operation
ACTIVE = ("uploading", "uploaded", "validating", "ready")
class ImportConflict(ValueError):
pass
class ImportBadDigest(ImportConflict):
pass
class ImportMissing(ValueError):
pass
class ImportUnavailable(ValueError):
pass
def summary(operation) -> ProblemImportSummary:
return ProblemImportSummary(
operation_id=operation.id,
state=operation.state,
expected_archive_digest=operation.archive_digest,
archive_bytes=operation.archive_bytes,
created_at=operation.created_at,
expires_at=operation.expires_at,
updated_at=operation.updated_at,
failure_code=operation.failure_code,
candidate_id=operation.candidate_id,
published_version_id=operation.published_version_id,
)
def policy_digest(policy):
return hashlib.sha256(policy.model_dump_json().encode()).hexdigest()
class ProblemImports:
def __init__(
self,
sessions,
staging: ImportStaging,
identity: ProblemMaintenancePort,
audit: MaintenanceAuditPort,
policy,
):
self.sessions, self.staging, self.identity, self.audit, self.policy = (
sessions,
staging,
identity,
audit,
policy,
)
@asynccontextmanager
async def write(self, principal):
async with self.sessions.begin() as session:
await session.execute(text("SET LOCAL statement_timeout='5s'"))
await session.execute(text("SET LOCAL lock_timeout='2s'"))
await self.identity.require_problem_maintenance(session, principal)
yield session
@staticmethod
async def pool(session):
pool = await session.get(ImportPool, 1, with_for_update=True)
if pool is None:
raise ImportUnavailable("import capacity unavailable")
actual = await session.scalar(
select(func.coalesce(func.sum(Operation.reserved_bytes), 0)).where(
Operation.reserved_bytes > 0
)
)
if pool.reserved_bytes != actual:
raise ImportUnavailable("import capacity inconsistent")
return pool
async def capabilities(self):
async with self.sessions() as session:
pool = await session.get(ImportPool, 1)
now = await session.scalar(select(func.clock_timestamp()))
available = bool(
pool
and pool.worker_ready_until
and pool.worker_ready_until > now
and pool.worker_policy_digest == policy_digest(self.policy)
)
return ProblemImportCapabilities(
uploads_available=available, validation_available=available
)
async def create(self, principal, request: CreateProblemImport):
# Immutable receipt retries need no filesystem slot, including while a parser/GC
# owns the root lock. Missing receipts still enter the locked creation path below.
async with self.write(principal) as session:
existing = await session.get(Operation, request.operation_id)
if existing:
if (
existing.owner_id != principal.account_id
or existing.archive_digest != request.archive_digest
or existing.archive_bytes != request.archive_bytes
):
raise ImportConflict("operation key already bound")
return summary(existing)
try:
with self.staging.exclusive():
await asyncio.to_thread(self.staging.usage)
async with self.write(principal) as session:
pool = await self.pool(session)
existing = await session.get(
Operation, request.operation_id, with_for_update=True
)
if existing:
if (
existing.owner_id != principal.account_id
or existing.archive_digest != request.archive_digest
or existing.archive_bytes != request.archive_bytes
):
raise ImportConflict("operation key already bound")
return summary(existing)
now = await session.scalar(select(func.clock_timestamp()))
if (
not pool.worker_ready_until
or pool.worker_ready_until <= now
or pool.worker_policy_digest != policy_digest(self.policy)
):
raise ImportUnavailable("import validation unavailable")
global_count = await session.scalar(
select(func.count())
.select_from(Operation)
.where(Operation.state.in_(ACTIVE))
)
owner_count = await session.scalar(
select(func.count())
.select_from(Operation)
.where(
Operation.state.in_(ACTIVE), Operation.owner_id == principal.account_id
)
)
busy = await session.scalar(
select(func.count())
.select_from(Operation)
.where(
Operation.state.in_(("uploading", "validating")),
Operation.lease_until > now,
)
)
if (
global_count >= MAX_TOTAL_CANDIDATES
or owner_count >= MAX_OWNER_CANDIDATES
or busy
or pool.reserved_bytes + STAGING_RESERVATION_BYTES > STAGING_MAX_BYTES
):
raise ImportUnavailable("import capacity exhausted")
operation = Operation(
id=request.operation_id,
owner_id=principal.account_id,
authorization_version=principal.authorization_version,
archive_digest=request.archive_digest,
archive_bytes=request.archive_bytes,
state="uploading",
created_at=now,
updated_at=now,
expires_at=now + timedelta(seconds=IMPORT_LIFETIME_SECONDS),
lease_until=now + timedelta(seconds=UPLOAD_DEADLINE_SECONDS + 15),
attempts=0,
fence=0,
reserved_bytes=STAGING_RESERVATION_BYTES,
)
session.add(operation)
pool.reserved_bytes += STAGING_RESERVATION_BYTES
await self.audit.record(
session,
principal.account_id,
principal.authorization_version,
operation.id,
"upload_created",
)
await session.flush()
return summary(operation)
except ArtifactError:
raise ImportUnavailable("import storage unavailable") from None
async def detail(self, principal, operation_id):
# Recheck live authority even when a principal was injected by an internal caller.
async with self.write(principal) as session:
op = await session.get(Operation, operation_id)
if op is None or op.owner_id != principal.account_id:
raise ImportMissing("import unavailable")
candidate = (
await session.get(ProblemCandidate, op.candidate_id) if op.candidate_id else None
)
return ProblemImportDetail(
**summary(op).model_dump(),
candidate=CandidatePreview.model_validate(candidate.preview) if candidate else None,
)
async def list(self, principal, after=None, limit=20):
if not 1 <= limit <= 100:
raise ValueError("invalid page limit")
async with self.write(principal) as session:
query = select(Operation).where(Operation.owner_id == principal.account_id)
if after:
query = query.where(Operation.id > after)
rows = list(await session.scalars(query.order_by(Operation.id).limit(limit + 1)))
return ProblemImportPage(
items=tuple(summary(o) for o in rows[:limit]),
next_after=rows[limit - 1].id if len(rows) > limit else None,
)
async def upload(self, principal, operation_id: UUID, chunks, content_length: int | None):
# Reject missing/foreign receipts before consulting shared filesystem capacity.
# The locked transaction below still rechecks authority and ownership before writes.
async with self.write(principal) as session:
op = await session.get(Operation, operation_id)
if op is None or op.owner_id != principal.account_id:
raise ImportMissing("import unavailable")
if content_length is not None and content_length != op.archive_bytes:
raise ImportConflict("archive length mismatch")
descriptor = operation_fd = None
fence = None
received = 0
chunk_count = 0
digest = hashlib.sha256()
try:
with self.staging.exclusive():
async with self.write(principal) as session:
await self.pool(session)
op = await session.get(Operation, operation_id, with_for_update=True)
if not op or op.owner_id != principal.account_id:
raise ImportMissing("import unavailable")
accepted = op.state in ("uploaded", "validating", "ready", "published")
now = await session.scalar(select(func.clock_timestamp()))
if (
not accepted
and (
op.state != "uploading"
or op.lease_until is None
or op.lease_until <= now
)
) or op.expires_at <= now:
raise ImportConflict("upload no longer available")
if content_length is not None and content_length != op.archive_bytes:
raise ImportConflict("archive length mismatch")
if not accepted:
op.fence += 1
fence = op.fence
op.lease_until = now + timedelta(seconds=UPLOAD_DEADLINE_SECONDS + 15)
expected_bytes, expected_digest = op.archive_bytes, op.archive_digest
if not accepted:
operation_fd = self.staging.operation(operation_id, create=True)
descriptor = os.open(
"archive",
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
0o600,
dir_fd=operation_fd,
)
async with asyncio.timeout(UPLOAD_DEADLINE_SECONDS):
async for chunk in chunks:
chunk_count += 1
received += len(chunk)
if (
type(chunk) is not bytes
or len(chunk) > MAX_UPLOAD_CHUNK_BYTES
or chunk_count > MAX_UPLOAD_CHUNKS
or received > expected_bytes
):
raise ImportConflict("archive bytes outside policy")
digest.update(chunk)
if accepted:
continue
# Cancellation waits for the finite writer outside the event loop.
task = asyncio.create_task(asyncio.to_thread(write_all, descriptor, chunk))
try:
await asyncio.shield(task)
except BaseException:
await task
raise
if received != expected_bytes or digest.hexdigest() != expected_digest:
raise ImportBadDigest("archive digest or length mismatch")
if accepted:
async with self.write(principal) as session:
op = await session.get(Operation, operation_id)
if not op or op.state not in (
"uploaded",
"validating",
"ready",
"published",
):
raise ImportConflict("import receipt no longer available")
return summary(op)
await asyncio.to_thread(os.fsync, descriptor)
os.close(descriptor)
descriptor = None
os.fsync(operation_fd)
async with self.write(principal) as session:
await self.pool(session)
op = await session.get(Operation, operation_id, with_for_update=True)
now = await session.scalar(select(func.clock_timestamp()))
if op.state != "uploading" or op.fence != fence or op.lease_until <= now:
raise ImportConflict("upload receipt expired")
op.state = "uploaded"
op.lease_until = None
op.updated_at = now
await self.audit.record(
session, op.owner_id, op.authorization_version, op.id, "upload_received"
)
result = summary(op)
return result
except BaseException as error:
# Failure bookkeeping cannot authorize a new business write. It only fences this
# accepted upload and releases capacity after physical bytes have been removed.
if descriptor is not None:
os.close(descriptor)
descriptor = None
if operation_fd is not None:
os.close(operation_fd)
operation_fd = None
if fence is not None:
try:
with self.staging.exclusive():
await asyncio.to_thread(self.staging.remove, operation_id)
async with self.sessions.begin() as session:
pool = await self.pool(session)
op = await session.get(Operation, operation_id, with_for_update=True)
if op and op.state == "uploading" and op.fence == fence:
op.state = "failed"
op.failure_code = (
"upload_deadline"
if isinstance(error, TimeoutError)
else "digest_mismatch"
if isinstance(error, ImportBadDigest)
else "authority_revoked"
if isinstance(error, MaintenanceDenied)
else "storage_unavailable"
if isinstance(error, OSError)
else "upload_interrupted"
)
op.updated_at = await session.scalar(select(func.clock_timestamp()))
op.lease_until = None
pool.reserved_bytes -= op.reserved_bytes
op.reserved_bytes = 0
await self.audit.record(
session,
op.owner_id,
op.authorization_version,
op.id,
"import_failed",
)
except (ArtifactError, OSError):
pass # Durable reservation retained for fenced GC.
raise
finally:
if descriptor is not None:
os.close(descriptor)
if operation_fd is not None:
os.close(operation_fd)
async def discard(self, principal, operation_id):
async with self.write(principal) as session:
op = await session.get(Operation, operation_id)
if op is None or op.owner_id != principal.account_id:
raise ImportMissing("import unavailable")
if op.state == "discarded":
return summary(op)
try:
with self.staging.exclusive():
async with self.write(principal) as session:
pool = await self.pool(session)
op = await session.get(Operation, operation_id, with_for_update=True)
if not op or op.owner_id != principal.account_id:
raise ImportMissing("import unavailable")
if op.state == "published":
raise ImportConflict("published import cannot be discarded")
if op.state == "discarded":
return summary(op)
await asyncio.to_thread(self.staging.remove, operation_id)
op.state = "discarded"
op.candidate_id = None
op.failure_code = None
op.lease_until = None
op.fence += 1
op.updated_at = await session.scalar(select(func.clock_timestamp()))
pool.reserved_bytes -= op.reserved_bytes
op.reserved_bytes = 0
await self.audit.record(
session,
principal.account_id,
principal.authorization_version,
op.id,
"import_discarded",
)
return summary(op)
except ArtifactError:
raise ImportUnavailable("import storage unavailable") from None
def write_all(descriptor, data):
remaining = memoryview(data)
while remaining:
size = os.write(descriptor, remaining)
if size <= 0:
raise OSError("short staging write")
remaining = remaining[size:]
@@ -0,0 +1,48 @@
import os
import re
import stat
from pathlib import Path
class LocalImportFiles:
def __init__(self, root: Path):
root = root.absolute()
if root.resolve() != root:
raise ValueError("import directory must not contain links")
self.descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
info = os.fstat(self.descriptor)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
self.close()
raise ValueError("import directory must be private and owned")
def close(self):
os.close(self.descriptor)
def read(self, filename: str, maximum: int) -> bytes:
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", filename):
raise ValueError("invalid local filename")
try:
descriptor = os.open(
filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=self.descriptor
)
with os.fdopen(descriptor, "rb") as stream:
before = os.fstat(stream.fileno())
if (
not stat.S_ISREG(before.st_mode)
or before.st_nlink != 1
or before.st_uid != os.getuid()
or before.st_size > maximum
):
raise ValueError("invalid local input file")
data = stream.read(maximum + 1)
after = os.fstat(stream.fileno())
if (
len(data) > maximum
or before.st_size != len(data)
or before.st_mtime_ns != after.st_mtime_ns
or before.st_ctime_ns != after.st_ctime_ns
):
raise ValueError("local input changed during import")
return data
except OSError:
raise ValueError("local input unavailable") from None
@@ -0,0 +1,324 @@
"""Administrator transactions; no evaluation writes or private data in projections."""
import asyncio
import hashlib
import json
from contextlib import asynccontextmanager
from uuid import uuid4, uuid5
from sqlalchemy import func, select, text
from kf_oj.contracts.admin_problems import CandidatePreview
from kf_oj.contracts.judging import ArtifactReference, TestCase
from kf_oj.contracts.problem_maintenance import (
MaintainedProblem,
MaintainedProblemPage,
MaintainedVersion,
MaintainedVersionPage,
MaintenanceReceipt,
)
from kf_oj.modules.identity.public import ProblemMaintenancePort
from kf_oj.modules.operations.public import MaintenanceAuditPort
from kf_oj.modules.problems import repository
from kf_oj.modules.problems.candidate_validation import validate_candidate
from kf_oj.modules.problems.import_format import ImportEnvelope
from kf_oj.modules.problems.import_models import (
CandidateCase,
ProblemCandidate,
ProblemImportOperation,
)
from kf_oj.modules.problems.imports import ImportConflict, ImportMissing, ImportUnavailable
from kf_oj.modules.problems.maintenance_models import ProblemMaintenanceOperation
from kf_oj.modules.problems.models import Problem, ProblemCase, ProblemVersion
from kf_oj.modules.problems.publication import admit_version, publish_version
from kf_oj.modules.problems.service import ProblemService
def project_problem(problem, version):
return MaintainedProblem(
id=problem.id,
slug=problem.slug,
enabled=problem.enabled,
active_version_id=problem.active_version_id,
revision=version.revision if version else 0,
title=version.title if version else None,
created_at=problem.created_at,
)
class ProblemMaintenance:
def __init__(
self,
sessions,
identity: ProblemMaintenancePort,
audit: MaintenanceAuditPort,
artifacts=None,
policy=None,
):
self.sessions, self.identity, self.audit = sessions, identity, audit
self.artifacts, self.policy = artifacts, policy
self._publishing = asyncio.Semaphore(2)
@asynccontextmanager
async def transaction(self, principal):
async with self.sessions.begin() as session:
await session.execute(text("SET LOCAL statement_timeout='5s'"))
await session.execute(text("SET LOCAL lock_timeout='2s'"))
await self.identity.require_problem_maintenance(session, principal)
yield session
async def page(self, principal, *, after=None, limit=20, enabled=None):
if not 1 <= limit <= 100:
raise ValueError("invalid page limit")
async with self.transaction(principal) as session:
q = select(Problem, ProblemVersion).outerjoin(
ProblemVersion, Problem.active_version_id == ProblemVersion.id
)
if after is not None:
q = q.where(Problem.slug > after)
if enabled is not None:
q = q.where(Problem.enabled == enabled)
rows = list(await session.execute(q.order_by(Problem.slug).limit(limit + 1)))
return MaintainedProblemPage(
items=tuple(project_problem(*r) for r in rows[:limit]),
next_after=rows[limit - 1][0].slug if len(rows) > limit else None,
)
async def by_slug(self, principal, slug):
async with self.transaction(principal) as session:
row = (
await session.execute(
select(Problem, ProblemVersion)
.outerjoin(ProblemVersion, Problem.active_version_id == ProblemVersion.id)
.where(Problem.slug == slug)
)
).one_or_none()
if row is None:
raise ImportMissing("problem unavailable")
return project_problem(*row)
async def detail(self, principal, problem_id):
async with self.transaction(principal) as session:
problem = await session.get(Problem, problem_id)
if problem is None:
raise ImportMissing("problem unavailable")
version = (
await session.get(ProblemVersion, problem.active_version_id)
if problem.active_version_id
else None
)
return project_problem(problem, version)
async def versions(self, principal, problem_id, *, before=None, limit=20):
if not 1 <= limit <= 100 or (before is not None and before < 1):
raise ValueError("invalid page limit")
async with self.transaction(principal) as session:
problem = await session.get(Problem, problem_id)
if problem is None:
raise ImportMissing("problem unavailable")
count = (
select(func.count())
.where(ProblemCase.version_id == ProblemVersion.id)
.scalar_subquery()
)
q = select(ProblemVersion, count).where(
ProblemVersion.problem_id == problem_id, ProblemVersion.published_at.is_not(None)
)
if before is not None:
q = q.where(ProblemVersion.revision < before)
rows = list(
await session.execute(q.order_by(ProblemVersion.revision.desc()).limit(limit + 1))
)
return MaintainedVersionPage(
items=tuple(
MaintainedVersion(
**ProblemService._summary(problem, v).model_dump(),
published_at=v.published_at,
case_count=n,
)
for v, n in rows[:limit]
),
next_before=rows[limit - 1][0].revision if len(rows) > limit else None,
)
async def receipt(self, principal, operation_id):
async with self.transaction(principal) as session:
row = await session.get(ProblemMaintenanceOperation, operation_id)
if row is None or row.owner_id != principal.account_id:
raise ImportMissing("operation unavailable")
return MaintenanceReceipt.model_validate(row.receipt)
async def _replay(self, session, principal, action, body, target):
digest = hashlib.sha256(
json.dumps(
{"action": action, "target": str(target), "request": body.model_dump(mode="json")},
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
key = int.from_bytes(hashlib.sha256(body.operation_id.bytes).digest()[:4], signed=True)
await session.execute(text("SELECT pg_advisory_xact_lock(1262907470,:key)"), {"key": key})
row = await session.get(ProblemMaintenanceOperation, body.operation_id)
if row is not None:
if row.owner_id != principal.account_id:
raise ImportMissing("operation unavailable")
if row.action != action or row.request_digest != digest:
raise ImportConflict("operation UUID already bound")
return digest, MaintenanceReceipt.model_validate(row.receipt)
return digest, None
async def _record(
self, session, principal, request, action, digest, problem, version, published=None
):
receipt = MaintenanceReceipt(
operation_id=request.operation_id,
action=action,
completed_at=await session.scalar(select(func.clock_timestamp())),
problem=project_problem(problem, version),
published_version_id=published,
)
session.add(
ProblemMaintenanceOperation(
id=request.operation_id,
owner_id=principal.account_id,
authorization_version=principal.authorization_version,
action=action,
request_digest=digest,
receipt=receipt.model_dump(mode="json"),
)
)
await self.audit.record(
session,
principal.account_id,
principal.authorization_version,
request.operation_id,
{"publish": "published", "disable": "disabled", "restore": "restored"}[action],
published if action == "publish" else problem.id,
)
await session.flush()
return receipt
async def publish(self, principal, request):
if self.artifacts is None or self.policy is None or self._publishing.locked():
raise ImportUnavailable("publication unavailable")
async with self._publishing:
async with self.transaction(principal) as session:
digest, replay = await self._replay(
session, principal, "publish", request, request.import_operation_id
)
if replay:
return replay
op = await session.get(
ProblemImportOperation, request.import_operation_id, with_for_update=True
)
if op is None or op.owner_id != principal.account_id:
raise ImportMissing("candidate unavailable")
if (
op.state != "ready"
or op.candidate_id != request.candidate_id
or op.authorization_version != principal.authorization_version
or op.expires_at <= await session.scalar(select(func.clock_timestamp()))
):
raise ImportConflict("candidate no longer publishable")
candidate = await session.get(ProblemCandidate, op.candidate_id)
if candidate is None or candidate.operation_id != op.id:
raise ImportUnavailable("candidate unavailable")
manifest = ImportEnvelope.model_validate(candidate.manifest).root
preview = CandidatePreview.model_validate(candidate.preview)
rows = list(
await session.scalars(
select(CandidateCase)
.where(CandidateCase.candidate_id == candidate.id)
.order_by(CandidateCase.position)
)
)
if len(rows) != len(manifest.cases) or len(rows) != candidate.case_count:
raise ImportUnavailable("candidate references unavailable")
refs = {}
for position, (row, c) in enumerate(zip(rows, manifest.cases, strict=True)):
if row.position != position or row.key != c.key:
raise ImportUnavailable("candidate references unavailable")
for name, ref in (
(
c.input_file,
ArtifactReference(digest=row.input_digest, bytes=row.input_bytes),
),
(
c.expected_file,
ArtifactReference(digest=row.expected_digest, bytes=row.expected_bytes),
),
):
if name in refs and refs[name] != ref:
raise ImportUnavailable("candidate references unavailable")
refs[name] = ref
validated = validate_candidate(
candidate.manifest, candidate.preview, refs, self.policy
)
if (
validated.digest != candidate.manifest_digest
or preview.candidate_id != candidate.id
):
raise ImportUnavailable("candidate integrity unavailable")
await repository.lock_slug(session, manifest.slug)
problem = await repository.lock_problem(session, manifest.slug)
actual = problem.active_version_id if problem else None
if actual != request.expected_active_version_id or (
problem and not problem.enabled
):
raise ImportConflict("active version or availability changed")
if problem is None:
problem = Problem(id=uuid4(), slug=manifest.slug)
session.add(problem)
await session.flush()
version_id = uuid4()
cases = tuple(
TestCase(
case_id=uuid5(version_id, c.key),
input=refs[c.input_file],
expected=refs[c.expected_file],
)
for c in manifest.cases
)
admit_version(self.policy, manifest, version_id, problem.id, cases)
# Candidate FKs protect all referenced ready metadata from GC. The
# private adapter additionally re-hashes disk bytes without buffering them.
for ref in sorted(
{r.digest: r for r in refs.values()}.values(), key=lambda r: r.digest
):
await self.artifacts.check_metadata(session, ref)
task = asyncio.create_task(asyncio.to_thread(self.artifacts.store.verify, ref))
try:
await asyncio.shield(task)
except BaseException:
await task
raise
version = await publish_version(
session, problem, manifest, preview, version_id, cases
)
op.state, op.published_version_id = "published", version.id
op.updated_at = await session.scalar(select(func.clock_timestamp()))
return await self._record(
session, principal, request, "publish", digest, problem, version, version.id
)
async def availability(self, principal, problem_id, request, *, enabled):
action = "restore" if enabled else "disable"
async with self.transaction(principal) as session:
digest, replay = await self._replay(session, principal, action, request, problem_id)
if replay:
return replay
problem = await session.get(Problem, problem_id, with_for_update=True)
if problem is None:
raise ImportMissing("problem unavailable")
if (
problem.active_version_id != request.expected_active_version_id
or problem.enabled != request.expected_enabled
or problem.enabled == enabled
):
raise ImportConflict("active version or availability changed")
version = await session.get(ProblemVersion, problem.active_version_id)
if version is None or version.published_at is None:
raise ImportUnavailable("published version unavailable")
problem.enabled = enabled
await session.flush()
return await self._record(session, principal, request, action, digest, problem, version)
@@ -0,0 +1,26 @@
"""Append-only actor-bound receipts; replay never re-applies a published operation."""
from uuid import UUID
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class ProblemMaintenanceOperation(CreatedAt, Base):
__tablename__ = "problem_maintenance_operations"
__table_args__ = (
CheckConstraint("action IN ('publish','disable','restore')", name="action"),
CheckConstraint("request_digest ~ '^[0-9a-f]{64}$'", name="digest"),
CheckConstraint("authorization_version > 0", name="authority"),
CheckConstraint("octet_length(receipt::text) <= 4096", name="receipt"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
owner_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
authorization_version: Mapped[int] = mapped_column(BigInteger)
action: Mapped[str] = mapped_column(String(16))
request_digest: Mapped[str] = mapped_column(String(64))
receipt: Mapped[dict] = mapped_column(JSONB)
@@ -0,0 +1,110 @@
"""Immutable published versions, ordered cases, and mutable public availability."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
SmallInteger,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Problem(CreatedAt, Base):
__tablename__ = "problems"
__table_args__ = (
CheckConstraint("slug ~ '^[a-z0-9][a-z0-9_-]{0,63}$'", name="slug"),
ForeignKeyConstraint(
["active_version_id", "id"],
["problem_versions.id", "problem_versions.problem_id"],
name="fk_problems_active_version",
use_alter=True,
deferrable=True,
initially="DEFERRED",
),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(64), unique=True)
active_version_id: Mapped[UUID | None]
enabled: Mapped[bool] = mapped_column(Boolean, server_default="true")
class ProblemVersion(CreatedAt, Base):
__tablename__ = "problem_versions"
__table_args__ = (
UniqueConstraint("id", "problem_id"),
UniqueConstraint("problem_id", "revision"),
CheckConstraint("revision > 0", name="revision"),
CheckConstraint("octet_length(statement) BETWEEN 1 AND 262144", name="statement_bytes"),
CheckConstraint("char_length(title) BETWEEN 1 AND 160", name="title_size"),
CheckConstraint("comparison = 'exact_bytes'", name="comparison"),
CheckConstraint("job_wall_time_us BETWEEN 1000 AND 3600000000", name="job_wall"),
CheckConstraint("judging_schema_version IN (1,2)", name="judging_schema"),
CheckConstraint(
"(judging_schema_version = 1 AND language_configurations IS NULL "
"AND toolchain IS NOT NULL AND compile_limits IS NOT NULL "
"AND run_limits IS NOT NULL AND job_wall_time_us IS NOT NULL) OR "
"(judging_schema_version = 2 AND language_configurations IS NOT NULL "
"AND toolchain IS NULL AND compile_limits IS NULL "
"AND run_limits IS NULL AND job_wall_time_us IS NULL "
"AND public.kf_valid_language_configurations(languages, language_configurations))",
name="language_configurations",
),
Index("ix_problem_versions_public", "problem_id", "published_at"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
problem_id: Mapped[UUID] = mapped_column(ForeignKey("problems.id"))
revision: Mapped[int] = mapped_column(Integer)
title: Mapped[str] = mapped_column(String(160))
statement: Mapped[str] = mapped_column(Text)
languages: Mapped[list[str]] = mapped_column(JSONB)
judging_schema_version: Mapped[int] = mapped_column(SmallInteger, server_default="1")
language_configurations: Mapped[list[dict] | None] = mapped_column(JSONB(none_as_null=True))
toolchain: Mapped[dict | None] = mapped_column(JSONB(none_as_null=True))
compile_limits: Mapped[dict | None] = mapped_column(JSONB(none_as_null=True))
run_limits: Mapped[dict | None] = mapped_column(JSONB(none_as_null=True))
job_wall_time_us: Mapped[int | None] = mapped_column(BigInteger)
comparison: Mapped[str] = mapped_column(String(16), server_default="exact_bytes")
public_samples: Mapped[list[dict]] = mapped_column(JSONB)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ProblemCase(Base):
__tablename__ = "problem_cases"
__table_args__ = (
UniqueConstraint("id", "version_id"),
UniqueConstraint("version_id", "position"),
CheckConstraint("position BETWEEN 0 AND 255", name="position"),
ForeignKeyConstraint(
["input_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
),
ForeignKeyConstraint(
["expected_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name="fk_problem_cases_expected_digest_artifacts",
),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
version_id: Mapped[UUID] = mapped_column(ForeignKey("problem_versions.id"))
position: Mapped[int]
input_digest: Mapped[str] = mapped_column(String(64))
expected_digest: Mapped[str] = mapped_column(String(64))
artifact_state: Mapped[str] = mapped_column(String(8), Computed("'ready'", persisted=True))
@@ -0,0 +1,84 @@
"""Authorized immutable version projection; hidden references stay out of public DTOs."""
from dataclasses import dataclass
from typing import Protocol
from uuid import UUID
from kf_oj.contracts import judging as v1
from kf_oj.contracts import judging_v2 as v2
from kf_oj.contracts.judging_versions import Manifest
class ProblemUnavailable(ValueError):
pass
@dataclass(frozen=True)
class JudgingVersion:
version_id: UUID
problem_id: UUID
languages: tuple[v1.Language | v2.Language, ...]
toolchain: v1.Toolchain | None
compile_limits: v1.RunLimits | None
run_limits: v1.RunLimits | None
job_wall_time_us: int | None
cases: tuple[v1.TestCase, ...]
judging_schema_version: int = 1
language_configurations: tuple[v2.LanguageConfiguration, ...] = ()
def manifest(
self,
evaluation_id: UUID,
submission_id: UUID,
language: v1.Language | v2.Language,
source: v1.ArtifactReference,
) -> Manifest:
"""Build only from the immutable authorized version, preserving v1 wire bytes."""
if language not in self.languages:
raise ProblemUnavailable("language unavailable")
fields = dict(
schema_version=self.judging_schema_version,
kind="judge.manifest",
evaluation_id=evaluation_id,
submission_id=submission_id,
problem_version_id=self.version_id,
language=language,
source=source,
cases=self.cases,
comparison="exact_bytes",
)
if self.judging_schema_version == 1:
if self.language_configurations:
raise ProblemUnavailable("inconsistent legacy version")
return v1.JobManifest(
**fields,
toolchain=self.toolchain,
compile_limits=self.compile_limits,
run_limits=self.run_limits,
job_wall_time_us=self.job_wall_time_us,
)
if self.judging_schema_version != 2 or any(
field is not None
for field in (
self.toolchain,
self.compile_limits,
self.run_limits,
self.job_wall_time_us,
)
):
raise ProblemUnavailable("unsupported judging configuration")
if tuple(c.language for c in self.language_configurations) != self.languages:
raise ProblemUnavailable("inconsistent language configurations")
configuration = next(c for c in self.language_configurations if c.language == language)
return v2.JobManifest(
**fields,
**configuration.model_dump(exclude={"language"}),
)
class ProblemPort(Protocol):
async def for_submission(self, session, version_id: UUID) -> JudgingVersion: ...
async def for_accepted_execution(self, session, version_id: UUID) -> JudgingVersion:
"""Require an already-validated live execution lease; allow a disabled published version."""
...
@@ -0,0 +1,82 @@
"""One admission and immutable publication path for local and web imports."""
from uuid import uuid4
from sqlalchemy import func, select
from kf_oj.contracts import judging_v2 as v2
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference
from kf_oj.modules.problems import repository
from kf_oj.modules.problems.import_format import ProblemImport
from kf_oj.modules.problems.models import ProblemCase, ProblemVersion
from kf_oj.modules.problems.public import JudgingVersion, ProblemUnavailable
def admit_version(policy, manifest, version_id, problem_id, cases):
legacy = isinstance(manifest, ProblemImport)
projection = JudgingVersion(
version_id,
problem_id,
manifest.languages,
manifest.toolchain if legacy else None,
manifest.compile_limits if legacy else None,
manifest.run_limits if legacy else None,
manifest.job_wall_time_us if legacy else None,
cases,
manifest.schema_version,
() if legacy else manifest.language_configurations,
)
for language in manifest.languages:
v2.admit_manifest(
policy,
projection.manifest(
uuid4(),
uuid4(),
language,
ArtifactReference(digest="0" * 64, bytes=MAX_SOURCE_BYTES),
),
1,
)
return projection
async def publish_version(session, problem, manifest, preview, version_id, cases):
"""Caller holds the logical slug/row locks and has made all artifacts durable."""
if not problem.enabled:
raise ProblemUnavailable("problem disabled")
legacy = isinstance(manifest, ProblemImport)
version = ProblemVersion(
id=version_id,
problem_id=problem.id,
revision=await repository.next_revision(session, problem.id),
title=manifest.title,
statement=preview.statement,
languages=[lang.value for lang in manifest.languages],
judging_schema_version=manifest.schema_version,
language_configurations=None
if legacy
else [c.model_dump(mode="json") for c in manifest.language_configurations],
toolchain=manifest.toolchain.model_dump(mode="json") if legacy else None,
compile_limits=manifest.compile_limits.model_dump(mode="json") if legacy else None,
run_limits=manifest.run_limits.model_dump(mode="json") if legacy else None,
job_wall_time_us=manifest.job_wall_time_us if legacy else None,
comparison=manifest.comparison,
public_samples=[s.model_dump(mode="json") for s in preview.samples],
)
session.add(version)
await session.flush()
for position, case in enumerate(cases):
session.add(
ProblemCase(
id=case.case_id,
version_id=version_id,
position=position,
input_digest=case.input.digest,
expected_digest=case.expected.digest,
)
)
await session.flush()
version.published_at = await session.scalar(select(func.now()))
problem.active_version_id = version_id
await session.flush()
return version
@@ -0,0 +1,74 @@
import hashlib
from uuid import UUID
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from kf_oj.modules.problems.models import Problem, ProblemCase, ProblemVersion
async def lock_slug(session: AsyncSession, slug: str) -> None:
# Two-key namespace is disjoint from the one-key artifact advisory locks.
key = int.from_bytes(hashlib.sha256(slug.encode()).digest()[:4], signed=True)
await session.execute(text("SELECT pg_advisory_xact_lock(1262907468,:key)"), {"key": key})
async def lock_problem(session: AsyncSession, slug: str) -> Problem | None:
return await session.scalar(select(Problem).where(Problem.slug == slug).with_for_update())
async def next_revision(session: AsyncSession, problem_id: UUID) -> int:
latest = await session.scalar(
select(func.max(ProblemVersion.revision)).where(ProblemVersion.problem_id == problem_id)
)
return (latest or 0) + 1
async def published_version(session: AsyncSession, version_id: UUID, *, admission=False):
query = (
select(ProblemVersion, Problem)
.join(Problem, Problem.id == ProblemVersion.problem_id)
.where(ProblemVersion.id == version_id, ProblemVersion.published_at.is_not(None))
)
if admission:
# A new submission commits while this shared problem lock is held. A
# concurrent disable either precedes admission or follows that commit.
query = query.where(Problem.enabled.is_(True)).with_for_update(read=True, of=Problem)
return (await session.execute(query)).one_or_none()
async def public_version(session: AsyncSession, version_id: UUID, *, admission=False):
if admission:
return await published_version(session, version_id, admission=True)
return (
await session.execute(
select(ProblemVersion, Problem)
.join(Problem, Problem.id == ProblemVersion.problem_id)
.where(
ProblemVersion.id == version_id,
ProblemVersion.published_at.is_not(None),
Problem.enabled.is_(True),
)
)
).one_or_none()
async def ordered_cases(session: AsyncSession, version_id: UUID):
return list(
await session.scalars(
select(ProblemCase)
.where(ProblemCase.version_id == version_id)
.order_by(ProblemCase.position)
)
)
async def list_public(session: AsyncSession, after: str | None, limit: int):
query = (
select(Problem, ProblemVersion)
.join(ProblemVersion, ProblemVersion.id == Problem.active_version_id)
.where(Problem.enabled.is_(True), ProblemVersion.published_at.is_not(None))
)
if after is not None:
query = query.where(Problem.slug > after)
return list(await session.execute(query.order_by(Problem.slug).limit(limit)))
@@ -0,0 +1,228 @@
import base64
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID, uuid4, uuid5
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.contracts import judging_v2 as v2
from kf_oj.contracts.judging import (
MAX_PACKAGE_BYTES,
MAX_SOURCE_BYTES,
ArtifactReference,
Language,
RunLimits,
TestCase,
Toolchain,
decode_wire,
)
from kf_oj.contracts.problems import (
ProblemDetail,
ProblemPage,
ProblemSummary,
PublicLanguageConfiguration,
PublicSample,
)
from kf_oj.infra.artifacts.models import Artifact
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.modules.problems import repository
from kf_oj.modules.problems.import_format import ImportEnvelope, ProblemImport
from kf_oj.modules.problems.local_files import LocalImportFiles
from kf_oj.modules.problems.models import Problem
from kf_oj.modules.problems.public import JudgingVersion, ProblemUnavailable
from kf_oj.modules.problems.publication import admit_version, publish_version
class ProblemService:
def __init__(
self,
sessions: async_sessionmaker,
artifacts: ArtifactRepository | None = None,
policy: v2.NodeAdmission | None = None,
):
self.sessions, self.artifacts, self.policy = sessions, artifacts, policy
async def import_directory(self, root: Path, manifest_file: str = "problem.json") -> UUID:
if self.artifacts is None or self.policy is None:
raise ValueError("artifact store and trusted node policy required for import")
files = LocalImportFiles(root)
try:
manifest = decode_wire(ImportEnvelope, files.read(manifest_file, 256 * 1024)).root
stdout_limit = (
manifest.run_limits.stdout_bytes
if isinstance(manifest, ProblemImport)
else min(c.run_limits.stdout_bytes for c in manifest.language_configurations)
)
statement = files.read(manifest.statement_file, 256 * 1024).decode("utf-8")
if not statement or "\x00" in statement or "\x00" in manifest.title:
raise ValueError("invalid public text")
# Read the complete bounded package before writing anything. Public samples are
# separately declared, at most 64 KiB each, and never inferred from hidden tests.
remaining = MAX_PACKAGE_BYTES - MAX_SOURCE_BYTES
payloads = []
for case in manifest.cases:
data = files.read(case.input_file, remaining)
remaining -= len(data)
answer = files.read(case.expected_file, min(remaining, stdout_limit))
remaining -= len(answer)
payloads.append((data, answer))
samples = tuple(
PublicSample(
input=base64.b64encode(files.read(s.input_file, 65536)).decode("ascii"),
output=base64.b64encode(files.read(s.output_file, 65536)).decode("ascii"),
)
for s in manifest.samples
)
finally:
files.close()
version_id = uuid4()
cases = tuple(
TestCase(
case_id=uuid5(version_id, case.key),
input=self.artifacts.store.reference(data),
expected=self.artifacts.store.reference(answer),
)
for case, (data, answer) in zip(manifest.cases, payloads, strict=True)
)
admit_version(self.policy, manifest, version_id, uuid4(), cases)
async with self.sessions.begin() as session:
# Lock existing and not-yet-existing logical slugs before assigning revisions.
await repository.lock_slug(session, manifest.slug)
problem = await repository.lock_problem(session, manifest.slug)
if problem is None:
problem = Problem(id=uuid4(), slug=manifest.slug)
session.add(problem)
await session.flush()
if not problem.enabled:
raise ProblemUnavailable("problem disabled")
# Consistent digest lock order avoids deadlocks between overlapping imports/jobs.
unique = {
ref.digest: (data, ref)
for case, (input_bytes, answer_bytes) in zip(cases, payloads, strict=True)
for data, ref in [(input_bytes, case.input), (answer_bytes, case.expected)]
}
for digest in sorted(unique):
data, reference = unique[digest]
await self.artifacts.put(session, data, reference)
await publish_version(
session,
problem,
manifest,
SimpleNamespace(statement=statement, samples=samples),
version_id,
cases,
)
return version_id
@staticmethod
def _summary(problem, version) -> ProblemSummary:
return ProblemSummary(
id=problem.id,
slug=problem.slug,
version_id=version.id,
revision=version.revision,
title=version.title,
languages=tuple(version.languages),
)
async def list(self, *, after: str | None = None, limit: int = 20) -> ProblemPage:
if not 1 <= limit <= 100:
raise ValueError("invalid page limit")
async with self.sessions() as session:
rows = await repository.list_public(session, after, limit + 1)
visible = rows[:limit]
return ProblemPage(
items=tuple(self._summary(*row) for row in visible),
next_after=visible[-1][0].slug if len(rows) > limit else None,
)
async def detail(self, version_id: UUID) -> ProblemDetail:
async with self.sessions() as session:
row = await repository.public_version(session, version_id)
if row is None:
raise ProblemUnavailable("problem version unavailable")
version, problem = row
return ProblemDetail(
**self._summary(problem, version).model_dump(),
statement=version.statement,
samples=tuple(PublicSample.model_validate(s) for s in version.public_samples),
run_limits=(
RunLimits.model_validate(version.run_limits)
if version.judging_schema_version == 1
else None
),
language_configurations=tuple(
PublicLanguageConfiguration.from_configuration(
v2.LanguageConfiguration.model_validate(c)
)
for c in version.language_configurations or ()
),
)
async def for_submission(self, session, version_id: UUID) -> JudgingVersion:
if self.artifacts is None:
raise ProblemUnavailable("artifact service unavailable")
row = await repository.public_version(session, version_id, admission=True)
return await self._judging_version(session, row)
async def for_accepted_execution(self, session, version_id: UUID) -> JudgingVersion:
# Only WorkerAccess calls this after validating the current lease and its
# immutable submission/attempt binding; this is never a public UUID lookup.
row = await repository.published_version(session, version_id)
return await self._judging_version(session, row)
async def _judging_version(self, session, row):
if row is None:
raise ProblemUnavailable("problem version unavailable")
version, problem = row
cases = await repository.ordered_cases(session, version.id)
refs = []
digests = {d for case in cases for d in (case.input_digest, case.expected_digest)}
records = {
a.digest: a
for a in await session.scalars(select(Artifact).where(Artifact.digest.in_(digests)))
}
for case in cases:
input_record = records.get(case.input_digest)
answer_record = records.get(case.expected_digest)
if (
not input_record
or not answer_record
or input_record.state != "ready"
or answer_record.state != "ready"
):
raise ProblemUnavailable("problem artifacts unavailable")
refs.append(
TestCase(
case_id=case.id,
input=ArtifactReference(digest=case.input_digest, bytes=input_record.bytes),
expected=ArtifactReference(
digest=case.expected_digest, bytes=answer_record.bytes
),
)
)
return JudgingVersion(
version.id,
problem.id,
tuple(
(Language if version.judging_schema_version == 1 else v2.Language)(v)
for v in version.languages
),
Toolchain.model_validate(version.toolchain)
if version.judging_schema_version == 1
else None,
RunLimits.model_validate(version.compile_limits)
if version.judging_schema_version == 1
else None,
RunLimits.model_validate(version.run_limits)
if version.judging_schema_version == 1
else None,
version.job_wall_time_us,
tuple(refs),
version.judging_schema_version,
tuple(
v2.LanguageConfiguration.model_validate(c)
for c in version.language_configurations or ()
),
)
@@ -0,0 +1,31 @@
"""Stricter Web package declarations; the accepted local CLI format remains unchanged."""
from dataclasses import dataclass
from kf_oj.contracts.judging import decode_wire
from kf_oj.modules.problems.import_format import ImportEnvelope, ProblemImport, ProblemImportV2
ARCHIVE_SUFFIXES = (".zip", ".jar", ".war", ".tar", ".tgz", ".gz", ".bz2", ".xz", ".7z", ".rar")
@dataclass(frozen=True)
class WebDeclaration:
manifest: ProblemImport | ProblemImportV2
members: frozenset[str]
def decode_web_declaration(data: bytes) -> WebDeclaration:
# decode_wire rejects duplicate JSON keys, unknown fields and noncanonical
# protocol values before either local paths or node policy can be considered.
manifest = decode_wire(ImportEnvelope, data).root
hidden = {name for c in manifest.cases for name in (c.input_file, c.expected_file)}
samples = {name for s in manifest.samples for name in (s.input_file, s.output_file)}
metadata = {"problem.json", manifest.statement_file}
if len(metadata) != 2 or hidden & (samples | metadata) or samples & metadata:
raise ValueError("public and hidden package declarations must be separate")
members = frozenset(metadata | hidden | samples)
if len({name.lower() for name in members}) != len(members):
raise ValueError("package names must be case-insensitively unique")
if any(name.lower().endswith(ARCHIVE_SUFFIXES) for name in members):
raise ValueError("nested archive declarations are unsupported")
return WebDeclaration(manifest, members)
@@ -3,4 +3,48 @@
负责提交幂等请求、不可变源码引用、创建初始评测、历史查询与学生可见结果。
创建提交、评测和待投递事件必须处于同一事务。
协调器拥有领取后的执行状态与最终结果写入权;本模块不能绕过协议覆盖终态。
后续 `public.py` 提供授权后的学习证据入口。当前未实现提交接口。
`queries.py` 提供已验证主体的提交历史、详情与私有源码读取,其他账号不可读取;
历史按创建时间/UUID降序游标翻页,一次至多100条。读取完整成绩时严格核对报告契约和
评测绑定,仅投影允许公开的诊断/计量/测试点序号。取消通过注入的 Coordinator 控制
端口接受意图,不更新执行状态;202与清理完成的终态不同。
`worker_access.py` 核对当前 evaluation / attempt / Worker / fencing、未撤销及未过期凭证、
固定 submission 与版本及任务清单摘要;只允许读取该清单声明的工件。
内部 API 使用 WorkerLease bearer 和 X-Worker-ID / X-Fencing-Version,不使用用户会话授权。
允许状态为 claimed / compiling / running;取消请求、终态和旧 attempt 均拒绝。
字节准备后再次核对当前租约;最多同时两个有限读取,超过直接 429,不排无界等待队列。
任务清单为 UTF-8 JSON、对象键递归排序、无额外空白,并以这些确切字节的 SHA-256 标识。
Python/Rust `manifest_bytes` 与共享字节样例保持一致;Coordinator 必须用同一序列化定义存快照。
读取凭证与客户端提交的身份分开:身份和归属从实际数据库绑定获得,不接受客户端自报 user ID。
`service.py` 按账号与幂等UUID绑定版本/语言/源码摘要,持久化源码后同一事务创建
submission、queued evaluation 和 outbox;同键同内容复用既有提交,不同内容409。
待评测固定总量64、个人8,取消清理中也占用容量;配置或容量不足明确拒绝。
Coordinator 通过正式 claim 创建短期领取权,Worker 不写业务数据库。
真实服务链与安全条件必须在独立 VM 验收;数据库 fixture 的租约或合成报告只能
验证控制规则、授权和投影,不能作为真实程序执行/停止证明。
`performance.py` 提供本人提交的只读匿名聚合。Coordinator 在受验证最终 AC 的同一事务
写入不可变 `evaluation_performance`;平台只有 SELECT 权限。比较组绑定不可变版本、
语言、精确镜像/工具链、编译与运行预算,以及运维核验后配置的测量环境纪元。
没有原纪元的旧尝试不会通过当前配置被补造可信性,仍保留个人历史与正常成绩。
单条受控回填与纪元变更规则见[协调器说明](../../../../../judge/crates/coordinator/README.md)。
历史可按 `problem_version_id` 与可选 wire `language` 过滤,始终带当前账号条件。
`GET /api/v1/submissions/{submission_id}/performance` 每次先检查会话和所有权;
匿名拒绝、其他主体与不存在记录同为不可用,私有响应为 `no-store`。
缓存命中仍查归属。每进程最多一个聚合查询,不排等待队列;SQL 期限两秒、请求总期限
三秒、锁等待至多半秒。聚合 DTO 缓存三十秒且最多128项,新 AC 的索引水位改变即失效。
缓存不保存逐条样本数组,不向浏览器发送其他账号的身份、源码或逐条用量。
每指标在同一个只读可重复读快照内,排除当前提交,取最近最多10,000条非缺测的其他
有效 AC,响应带快照、最早样本及截断标志。少于五条时仅显示本次真实用量。
CPU 为测试点运行 CPU 微秒之和,内存为测试点运行峰值字节的最大值,不包括编译。
用量与桶边界以十进制字符串传输;零合法、缺测不补零。严格优于数按原整数的 `>`
计算,同值独立计数。响应至多24个连续桶,只有最后上界包含端点;浏览器仅格式化单位
和绘制坐标,比例以服务端严格计数除以样本数,不按人数统计。
下架只阻止新提交与公开题目读取。Worker 的版本读取通过 `ProblemPort.for_accepted_execution`,
在本模块验证当前 Coordinator 租约/尝试/源码绑定之后才调用;生成并比较原 canonical
快照,再次核对租约后才释放字节。无有效租约、外来工件、未发布版本和其他提交仍拒绝。
本人历史和源码读取保留所有权检查,不依赖题目当前 enabled 状态。
@@ -0,0 +1,233 @@
"""Platform creates initial rows; Coordinator exclusively advances execution."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Submission(CreatedAt, Base):
__tablename__ = "submissions"
__table_args__ = (
UniqueConstraint("account_id", "idempotency_key"),
UniqueConstraint("id", "problem_version_id"),
CheckConstraint("language IN ('c17','cpp20','python313','java21')", name="language"),
CheckConstraint("request_digest ~ '^[0-9a-f]{64}$'", name="request_digest"),
ForeignKeyConstraint(
["problem_version_id", "problem_id"],
["problem_versions.id", "problem_versions.problem_id"],
),
ForeignKeyConstraint(
["source_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
),
Index("ix_submissions_account_history", "account_id", "created_at", "id"),
Index(
"ix_submissions_owned_problem_history",
"account_id",
"problem_version_id",
text("created_at DESC"),
text("id DESC"),
),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
account_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
problem_id: Mapped[UUID]
problem_version_id: Mapped[UUID]
language: Mapped[str] = mapped_column(String(16))
source_digest: Mapped[str] = mapped_column(String(64))
artifact_state: Mapped[str] = mapped_column(String(8), Computed("'ready'", persisted=True))
idempotency_key: Mapped[UUID]
request_digest: Mapped[str] = mapped_column(String(64))
class Evaluation(CreatedAt, Base):
__tablename__ = "evaluations"
__table_args__ = (
UniqueConstraint("id", "problem_version_id"),
ForeignKeyConstraint(
["submission_id", "problem_version_id"],
["submissions.id", "submissions.problem_version_id"],
),
ForeignKeyConstraint(
["current_attempt_id", "id"],
["judge_attempts.id", "judge_attempts.evaluation_id"],
name="fk_evaluations_current_attempt",
use_alter=True,
deferrable=True,
initially="DEFERRED",
),
CheckConstraint(
"state IN ('queued','claimed','compiling','running','cancelling',"
"'finished','cancelled','failed')",
name="state",
),
CheckConstraint("fencing_version >= 0", name="fence"),
CheckConstraint("max_attempts BETWEEN 1 AND 5", name="max_attempts"),
CheckConstraint("attempts_count BETWEEN 0 AND max_attempts", name="attempts_count"),
CheckConstraint(
"state NOT IN ('claimed','compiling','running','cancelling') "
"OR current_attempt_id IS NOT NULL",
name="active_attempt",
),
Index("ix_evaluations_recovery", "state", "not_before", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
submission_id: Mapped[UUID] = mapped_column(unique=True)
problem_version_id: Mapped[UUID]
state: Mapped[str] = mapped_column(String(16), server_default="queued")
fencing_version: Mapped[int] = mapped_column(BigInteger, server_default="0")
attempts_count: Mapped[int] = mapped_column(server_default="0")
max_attempts: Mapped[int] = mapped_column(server_default="3")
current_attempt_id: Mapped[UUID | None]
not_before: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
cancel_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
verdict: Mapped[str | None] = mapped_column(String(32))
failure_code: Mapped[str | None] = mapped_column(String(32))
final_report: Mapped[dict | None] = mapped_column(JSONB)
final_report_digest: Mapped[str | None] = mapped_column(String(64))
class JudgeAttempt(CreatedAt, Base):
__tablename__ = "judge_attempts"
__table_args__ = (
UniqueConstraint("id", "evaluation_id"),
UniqueConstraint("evaluation_id", "number", name="uq_judge_attempts_evaluation_number"),
UniqueConstraint(
"evaluation_id", "fencing_version", name="uq_judge_attempts_evaluation_fence"
),
CheckConstraint("number BETWEEN 1 AND 5", name="number"),
CheckConstraint("fencing_version > 0", name="fence"),
CheckConstraint("lease_token_hash ~ '^[0-9a-f]{64}$'", name="token_digest"),
CheckConstraint("manifest_digest ~ '^[0-9a-f]{64}$'", name="manifest_digest"),
CheckConstraint(
"measurement_epoch IS NULL OR measurement_epoch ~ '^[0-9a-f]{64}$'",
name="measurement_epoch",
),
CheckConstraint("sequence >= 0", name="sequence"),
Index("ix_judge_attempts_expiry", "expires_at", "evaluation_id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
evaluation_id: Mapped[UUID] = mapped_column(ForeignKey("evaluations.id"))
worker_id: Mapped[UUID]
number: Mapped[int]
fencing_version: Mapped[int] = mapped_column(BigInteger)
lease_token_hash: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
manifest: Mapped[dict] = mapped_column(JSONB)
manifest_digest: Mapped[str] = mapped_column(String(64))
sequence: Mapped[int] = mapped_column(BigInteger, server_default="0")
stage: Mapped[str | None] = mapped_column(String(16))
case_index: Mapped[int | None]
report: Mapped[dict | None] = mapped_column(JSONB)
report_digest: Mapped[str | None] = mapped_column(String(64))
cleanup_complete: Mapped[bool | None] = mapped_column(Boolean)
measurement_epoch: Mapped[str | None] = mapped_column(String(64))
class EvaluationPerformance(Base):
"""Coordinator-only immutable final projection; the platform only reads it."""
__tablename__ = "evaluation_performance"
__table_args__ = (
ForeignKeyConstraint(
["evaluation_id", "problem_version_id"],
["evaluations.id", "evaluations.problem_version_id"],
),
ForeignKeyConstraint(
["submission_id", "problem_version_id"],
["submissions.id", "submissions.problem_version_id"],
),
ForeignKeyConstraint(
["attempt_id", "evaluation_id"], ["judge_attempts.id", "judge_attempts.evaluation_id"]
),
CheckConstraint("language IN ('c17','cpp20','python313','java21')", name="language"),
CheckConstraint("comparison_group ~ '^[0-9a-f]{64}$'", name="comparison_group"),
CheckConstraint("measurement_epoch ~ '^[0-9a-f]{64}$'", name="measurement_epoch"),
CheckConstraint("report_digest ~ '^[0-9a-f]{64}$'", name="report_digest"),
CheckConstraint("cpu_time_us IS NULL OR cpu_time_us >= 0", name="cpu"),
CheckConstraint("memory_peak_bytes IS NULL OR memory_peak_bytes >= 0", name="memory"),
Index(
"ix_performance_group_watermark",
"comparison_group",
text("finished_at DESC"),
text("evaluation_id DESC"),
),
Index(
"ix_performance_cpu_time_us_cohort",
"comparison_group",
text("finished_at DESC"),
text("evaluation_id DESC"),
postgresql_where=text("cpu_time_us IS NOT NULL"),
),
Index(
"ix_performance_memory_peak_bytes_cohort",
"comparison_group",
text("finished_at DESC"),
text("evaluation_id DESC"),
postgresql_where=text("memory_peak_bytes IS NOT NULL"),
),
)
evaluation_id: Mapped[UUID] = mapped_column(primary_key=True)
submission_id: Mapped[UUID] = mapped_column(unique=True)
attempt_id: Mapped[UUID] = mapped_column(unique=True)
problem_version_id: Mapped[UUID]
language: Mapped[str] = mapped_column(String(16))
comparison_group: Mapped[str] = mapped_column(String(64))
measurement_epoch: Mapped[str] = mapped_column(String(64))
report_digest: Mapped[str] = mapped_column(String(64))
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
cpu_time_us: Mapped[int | None] = mapped_column(BigInteger)
memory_peak_bytes: Mapped[int | None] = mapped_column(BigInteger)
class CaseResult(Base):
__tablename__ = "case_results"
__table_args__ = (
UniqueConstraint("attempt_id", "position"),
ForeignKeyConstraint(
["case_id", "problem_version_id"], ["problem_cases.id", "problem_cases.version_id"]
),
ForeignKeyConstraint(
["evaluation_id", "problem_version_id"],
["evaluations.id", "evaluations.problem_version_id"],
),
ForeignKeyConstraint(
["attempt_id", "evaluation_id"], ["judge_attempts.id", "judge_attempts.evaluation_id"]
),
CheckConstraint("position BETWEEN 0 AND 255", name="position"),
CheckConstraint("cpu_time_us IS NULL OR cpu_time_us >= 0", name="cpu"),
CheckConstraint("memory_peak_bytes IS NULL OR memory_peak_bytes >= 0", name="memory"),
)
attempt_id: Mapped[UUID] = mapped_column(primary_key=True)
case_id: Mapped[UUID] = mapped_column(primary_key=True)
evaluation_id: Mapped[UUID]
problem_version_id: Mapped[UUID]
position: Mapped[int]
verdict: Mapped[str] = mapped_column(String(32))
cpu_time_us: Mapped[int | None] = mapped_column(BigInteger)
memory_peak_bytes: Mapped[int | None] = mapped_column(BigInteger)
@@ -0,0 +1,217 @@
"""Read-only, bounded anonymous cohorts from Coordinator's immutable projection."""
import asyncio
import json
import time
from collections import OrderedDict
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.contracts.judging import I64_MAX
from kf_oj.contracts.judging_versions import decode_report
from kf_oj.contracts.submission_performance import (
MAX_PERFORMANCE_SAMPLES,
MIN_PERFORMANCE_SAMPLES,
PerformanceBucket,
PerformanceMetric,
SubmissionPerformance,
)
from kf_oj.modules.identity.public import Principal
from kf_oj.modules.submissions.models import Evaluation, EvaluationPerformance, Submission
from kf_oj.modules.submissions.public import SubmissionUnavailable
CACHE_TTL_SECONDS = 30
CACHE_CAPACITY = 128
HISTOGRAM_BUCKETS = 24
def metric(unit, current, rows=(), *, reason=None) -> PerformanceMetric:
"""Integer arithmetic throughout; conversion for display belongs to the client."""
truncated = len(rows) > MAX_PERFORMANCE_SAMPLES
rows = rows[:MAX_PERFORMANCE_SAMPLES]
count = len(rows)
base = dict(
unit=unit,
current=None if current is None else str(current),
sample_count=count,
window_truncated=truncated,
oldest_sample_at=rows[-1][1] if rows else None,
)
if reason is None:
reason = (
"measurement_missing"
if current is None
else ("insufficient_samples" if count < MIN_PERFORMANCE_SAMPLES else None)
)
if reason is not None:
return PerformanceMetric(
**base,
available=False,
reason=reason,
strictly_slower_count=None,
tied_count=None,
buckets=(),
)
values = [row[0] for row in rows]
end = max(1, current, max(values))
step = (end + HISTOGRAM_BUCKETS - 1) // HISTOGRAM_BUCKETS
# Last upper bound includes the maximum, including I64_MAX. No overflow/padding.
boundaries = list(range(0, end, step)) + [end]
counts = [0] * (len(boundaries) - 1)
for value in values:
counts[min(value // step, len(counts) - 1)] += 1
return PerformanceMetric(
**base,
available=True,
reason=None,
strictly_slower_count=sum(value > current for value in values),
tied_count=sum(value == current for value in values),
buckets=tuple(
PerformanceBucket(
start=str(start),
end=str(stop),
upper_inclusive=index == len(counts) - 1,
count=counts[index],
)
for index, (start, stop) in enumerate(zip(boundaries, boundaries[1:], strict=False))
),
)
class SubmissionPerformanceQueries:
def __init__(self, sessions: async_sessionmaker):
self.sessions = sessions
# No queued aggregate workload. At most one bounded in-memory row cohort per process.
self._slot = asyncio.Lock()
# Cache contains small immutable aggregate DTOs, never 10,000-row arrays.
self._cache: OrderedDict[tuple, tuple[float, SubmissionPerformance]] = OrderedDict()
async def detail(self, principal: Principal, submission_id: UUID) -> SubmissionPerformance:
if self._slot.locked():
raise ValueError("performance query capacity unavailable")
async with self._slot:
try:
async with asyncio.timeout(3), self.sessions() as session, session.begin():
await session.execute(
text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
)
await session.execute(text("SET LOCAL statement_timeout='2s'"))
await session.execute(text("SET LOCAL lock_timeout='500ms'"))
# Ownership checked even on a cache hit. Session authentication is upstream.
row = (
await session.execute(
select(Submission, Evaluation, EvaluationPerformance)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.outerjoin(
EvaluationPerformance,
EvaluationPerformance.evaluation_id == Evaluation.id,
)
.where(
Submission.id == submission_id,
Submission.account_id == principal.account_id,
)
)
).one_or_none()
if row is None:
raise SubmissionUnavailable("submission unavailable")
submission, evaluation, projection = row
as_of = await session.scalar(text("SELECT clock_timestamp()"))
accepted = (
evaluation.state == "finished"
and evaluation.verdict == "accepted"
and evaluation.failure_code is None
)
if projection is not None and (
not accepted
or projection.attempt_id != evaluation.current_attempt_id
or projection.report_digest != evaluation.final_report_digest
):
raise ValueError("stored performance binding invalid")
cpu = memory = None
reason = None if accepted else "not_accepted"
if projection is not None:
cpu, memory = projection.cpu_time_us, projection.memory_peak_bytes
elif accepted:
# A single owned legacy report, never a full-table JSON scan or promotion.
report = decode_report(
json.dumps(evaluation.final_report, ensure_ascii=False).encode()
)
if (
report.binding.evaluation_id != evaluation.id
or report.binding.attempt_id != evaluation.current_attempt_id
or report.verdict.value != "accepted"
or not report.cleanup_complete
):
raise ValueError("stored result binding invalid")
cpu, memory = report.run_cpu_time_us, report.run_memory_peak_bytes
reason = "environment_unverified"
if any(v is not None and not 0 <= v <= I64_MAX for v in (cpu, memory)):
raise ValueError("stored measurement out of range")
base = dict(
submission_id=submission.id,
problem_version_id=submission.problem_version_id,
language=submission.language,
as_of=as_of,
comparison_group=None
if projection is None
else projection.comparison_group,
)
if projection is None:
return SubmissionPerformance(
**base,
cpu=metric("cpu_microseconds", cpu, reason=reason),
memory=metric("bytes", memory, reason=reason),
)
group = projection.comparison_group
watermark = (
await session.execute(
select(
EvaluationPerformance.finished_at,
EvaluationPerformance.evaluation_id,
)
.where(EvaluationPerformance.comparison_group == group)
.order_by(
EvaluationPerformance.finished_at.desc(),
EvaluationPerformance.evaluation_id.desc(),
)
.limit(1)
)
).one()
key = (submission.id, projection.report_digest, group, tuple(watermark))
cached = self._cache.get(key)
now = time.monotonic()
if cached is not None and now - cached[0] < CACHE_TTL_SECONDS:
self._cache.move_to_end(key)
return cached[1]
metrics = []
for column, unit, current in [
(EvaluationPerformance.cpu_time_us, "cpu_microseconds", cpu),
(EvaluationPerformance.memory_peak_bytes, "bytes", memory),
]:
rows = (
await session.execute(
select(column, EvaluationPerformance.finished_at)
.where(
EvaluationPerformance.comparison_group == group,
EvaluationPerformance.evaluation_id != evaluation.id,
column.is_not(None),
)
.order_by(
EvaluationPerformance.finished_at.desc(),
EvaluationPerformance.evaluation_id.desc(),
)
.limit(MAX_PERFORMANCE_SAMPLES + 1)
)
).all()
metrics.append(metric(unit, current, rows))
result = SubmissionPerformance(**base, cpu=metrics[0], memory=metrics[1])
self._cache[key] = (now, result)
self._cache.move_to_end(key)
while len(self._cache) > CACHE_CAPACITY:
self._cache.popitem(last=False)
return result
except (SQLAlchemyError, TimeoutError) as error:
raise ValueError("performance database unavailable") from error
@@ -0,0 +1,30 @@
from typing import Protocol
from uuid import UUID
class CancelUnavailable(ValueError):
pass
class CancellationPort(Protocol):
async def cancel(self, evaluation_id: UUID) -> bool: ...
class SubmissionUnavailable(ValueError):
pass
class WorkerAccessDenied(ValueError):
"""Never reveal whether the evaluation, attempt or artifact exists."""
class WorkerReadBusy(ValueError):
pass
class IdempotencyConflict(ValueError):
pass
class SubmissionBusy(ValueError):
pass
@@ -0,0 +1,184 @@
import base64
import json
from datetime import datetime
from uuid import UUID
from sqlalchemy import select, tuple_
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.contracts.judging import MAX_SOURCE_BYTES, ArtifactReference
from kf_oj.contracts.judging_versions import decode_report
from kf_oj.contracts.submissions import (
CancellationAccepted,
PublicCaseResult,
PublicJudgement,
SubmissionDetail,
SubmissionPage,
SubmissionSummary,
)
from kf_oj.infra.artifacts.models import Artifact
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.identity.public import Principal
from kf_oj.modules.submissions.models import Evaluation, Submission
from kf_oj.modules.submissions.public import (
CancellationPort,
CancelUnavailable,
SubmissionUnavailable,
)
def cursor_encode(submission: Submission) -> str:
return (
base64.urlsafe_b64encode(
f"{submission.created_at.isoformat()}|{submission.id}".encode("ascii")
)
.decode("ascii")
.rstrip("=")
)
def cursor_decode(value: str) -> tuple[datetime, UUID]:
try:
if not 1 <= len(value) <= 128:
raise ValueError
date, identifier = (
base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
.decode("ascii")
.split("|")
)
timestamp = datetime.fromisoformat(date)
if timestamp.utcoffset() is None:
raise ValueError
return timestamp, UUID(identifier)
except (ValueError, UnicodeError):
raise ValueError("invalid history cursor") from None
def summary(submission: Submission, evaluation: Evaluation) -> SubmissionSummary:
terminal = evaluation.state in {"finished", "failed", "cancelled"}
return SubmissionSummary(
submission_id=submission.id,
evaluation_id=evaluation.id,
problem_version_id=submission.problem_version_id,
language=submission.language,
state=evaluation.state,
created_at=submission.created_at,
updated_at=evaluation.updated_at,
attempts=evaluation.attempts_count,
verdict=evaluation.verdict if terminal else None,
failure_code=evaluation.failure_code,
)
class SubmissionQueries:
def __init__(
self,
sessions: async_sessionmaker,
artifacts: ArtifactRepository,
cancellation: CancellationPort | None = None,
):
self.sessions, self.artifacts = sessions, artifacts
self.cancellation = cancellation
def owned(self, principal: Principal, submission_id: UUID):
return (
select(Submission, Evaluation)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.where(Submission.id == submission_id, Submission.account_id == principal.account_id)
)
async def page(
self,
principal: Principal,
after: str | None,
limit: int,
problem_version_id: UUID | None = None,
language: str | None = None,
) -> SubmissionPage:
if not 1 <= limit <= 100:
raise ValueError("invalid history limit")
query = (
select(Submission, Evaluation)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.where(Submission.account_id == principal.account_id)
.order_by(Submission.created_at.desc(), Submission.id.desc())
.limit(limit + 1)
)
if problem_version_id is not None:
query = query.where(Submission.problem_version_id == problem_version_id)
if language is not None:
query = query.where(Submission.language == language)
if after is not None:
query = query.where(tuple_(Submission.created_at, Submission.id) < cursor_decode(after))
async with self.sessions() as session:
rows = (await session.execute(query)).all()
return SubmissionPage(
items=tuple(summary(s, e) for s, e in rows[:limit]),
next_after=cursor_encode(rows[limit - 1][0]) if len(rows) > limit else None,
)
async def detail(self, principal: Principal, submission_id: UUID) -> SubmissionDetail:
async with self.sessions() as session:
row = (await session.execute(self.owned(principal, submission_id))).one_or_none()
if row is None:
raise SubmissionUnavailable("submission unavailable")
submission, evaluation = row
result = None
if evaluation.state in {"finished", "failed", "cancelled"} and evaluation.final_report:
# A single joined snapshot, strict validation and explicit projection prevent leaking
# internal bindings, artifacts, hidden case identifiers or partial attempt reports.
report = decode_report(
json.dumps(
evaluation.final_report, ensure_ascii=False, separators=(",", ":")
).encode()
)
if str(report.binding.evaluation_id) != str(evaluation.id):
raise ValueError("stored result binding invalid")
if report.verdict.value != evaluation.verdict:
raise ValueError("stored result verdict invalid")
result = PublicJudgement(
compilation_status=report.compilation.status,
diagnostics=report.compilation.diagnostics,
diagnostics_truncated=report.compilation.diagnostics_truncated,
cpu_time_us=report.run_cpu_time_us,
memory_peak_bytes=report.run_memory_peak_bytes,
cleanup_complete=report.cleanup_complete,
cases=tuple(
PublicCaseResult(position=i, **c.model_dump(exclude={"case_id"}))
for i, c in enumerate(report.cases)
),
)
return SubmissionDetail(**summary(submission, evaluation).model_dump(), result=result)
async def cancel(self, principal: Principal, submission_id: UUID) -> CancellationAccepted:
async with self.sessions() as session:
row = (await session.execute(self.owned(principal, submission_id))).one_or_none()
if row is None:
raise SubmissionUnavailable("submission unavailable")
submission, evaluation = row
if self.cancellation is None or not await self.cancellation.cancel(evaluation.id):
raise CancelUnavailable("cancellation service unavailable")
return CancellationAccepted(submission_id=submission.id, evaluation_id=evaluation.id)
async def owned_source(self, principal: Principal, submission_id: UUID) -> bytes:
async with self.sessions() as session:
submission = await session.scalar(
select(Submission).where(
Submission.id == submission_id, Submission.account_id == principal.account_id
)
)
if submission is None:
raise SubmissionUnavailable("submission unavailable")
metadata = await session.get(Artifact, submission.source_digest)
if (
metadata is None
or metadata.state != "ready"
or not 1 <= metadata.bytes <= MAX_SOURCE_BYTES
):
raise SubmissionUnavailable("submission source unavailable")
reference = ArtifactReference(digest=metadata.digest, bytes=metadata.bytes)
try:
return await self.artifacts.read(session, reference)
except ArtifactError:
raise SubmissionUnavailable("submission source unavailable") from None
@@ -0,0 +1,27 @@
from sqlalchemy import func, select
from kf_oj.contracts.judging import LeaseBinding
from kf_oj.modules.submissions.models import Evaluation, JudgeAttempt, Submission
async def leased_submission(session, binding: LeaseBinding, token_hash: str):
return (
await session.execute(
select(Evaluation, JudgeAttempt, Submission)
.join(JudgeAttempt, JudgeAttempt.id == Evaluation.current_attempt_id)
.join(Submission, Submission.id == Evaluation.submission_id)
.where(
Evaluation.id == binding.evaluation_id,
Evaluation.state.in_(("claimed", "compiling", "running")),
Evaluation.cancel_requested_at.is_(None),
Evaluation.fencing_version == binding.fencing_version,
JudgeAttempt.id == binding.attempt_id,
JudgeAttempt.evaluation_id == Evaluation.id,
JudgeAttempt.worker_id == binding.worker_id,
JudgeAttempt.fencing_version == binding.fencing_version,
JudgeAttempt.lease_token_hash == token_hash,
JudgeAttempt.expires_at > func.clock_timestamp(),
JudgeAttempt.revoked_at.is_(None),
)
)
).one_or_none()
@@ -0,0 +1,123 @@
"""Only initial queued evaluation/outbox writes; execution belongs to Coordinator."""
import hashlib
import json
from uuid import uuid4
from sqlalchemy import func, select, text
from kf_oj.contracts.judging_v2 import admit_manifest
from kf_oj.contracts.submissions import CreateSubmission, SubmissionCreated
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.messaging.models import Outbox
from kf_oj.modules.identity.public import Principal
from kf_oj.modules.problems.public import ProblemPort, ProblemUnavailable
from kf_oj.modules.submissions.models import Evaluation, Submission
from kf_oj.modules.submissions.public import (
IdempotencyConflict,
SubmissionBusy,
SubmissionUnavailable,
)
class SubmissionService:
def __init__(self, sessions, problems: ProblemPort, artifacts: ArtifactRepository, admission):
self.sessions, self.problems = sessions, problems
self.artifacts, self.admission = artifacts, admission
@staticmethod
def _response(submission, evaluation):
return SubmissionCreated(
submission_id=submission.id,
evaluation_id=evaluation.id,
problem_version_id=submission.problem_version_id,
language=submission.language,
state=evaluation.state,
created_at=submission.created_at,
)
async def create(self, principal: Principal, request: CreateSubmission) -> SubmissionCreated:
# Validate even trusted non-HTTP callers. No path or argv is accepted.
request = CreateSubmission.model_validate(
request.model_dump(exclude={"language"}) | {"language": request.language}
)
source = request.source.encode("utf-8")
request_digest = hashlib.sha256(
json.dumps(
[
str(request.problem_version_id),
request.language.value,
hashlib.sha256(source).hexdigest(),
],
separators=(",", ":"),
).encode()
).hexdigest()
if self.admission is None:
raise SubmissionUnavailable("judge admission unavailable")
async with self.sessions.begin() as session:
# Bounded pending capacity and per-account idempotency serialize across API processes.
# Two-key namespace is separate from artifact/slug advisory locks.
await session.execute(text("SELECT pg_advisory_xact_lock(1262907470, 0)"))
existing = (
await session.execute(
select(Submission, Evaluation)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.where(
Submission.account_id == principal.account_id,
Submission.idempotency_key == request.idempotency_key,
)
)
).one_or_none()
if existing is not None:
if existing[0].request_digest != request_digest:
raise IdempotencyConflict("idempotency key belongs to another request")
return self._response(*existing)
pending = ("queued", "claimed", "compiling", "running", "cancelling")
total = await session.scalar(
select(func.count()).select_from(Evaluation).where(Evaluation.state.in_(pending))
)
owned = await session.scalar(
select(func.count())
.select_from(Submission)
.join(Evaluation, Evaluation.submission_id == Submission.id)
.where(Submission.account_id == principal.account_id, Evaluation.state.in_(pending))
)
if total >= 64 or owned >= 8:
raise SubmissionBusy("pending submission capacity exhausted")
try:
version = await self.problems.for_submission(session, request.problem_version_id)
except ProblemUnavailable:
raise SubmissionUnavailable("problem version unavailable") from None
if request.language not in version.languages:
raise SubmissionUnavailable("language unavailable")
# Bytes fsync before metadata/ref transaction. Rollback may leave collectable bytes,
# but cannot leave an evaluation that refers to non-durable bytes.
reference = await self.artifacts.put(session, source)
submission_id, evaluation_id = uuid4(), uuid4()
manifest = version.manifest(evaluation_id, submission_id, request.language, reference)
admit_manifest(self.admission, manifest, 1)
submission = Submission(
id=submission_id,
account_id=principal.account_id,
problem_id=version.problem_id,
problem_version_id=version.version_id,
language=request.language.value,
source_digest=reference.digest,
idempotency_key=request.idempotency_key,
request_digest=request_digest,
)
session.add(submission)
await session.flush()
await session.execute(
Evaluation.__table__.insert().values(
id=evaluation_id,
submission_id=submission_id,
problem_version_id=version.version_id,
)
)
session.add(Outbox(id=uuid4(), evaluation_id=evaluation_id))
await session.flush()
evaluation = await session.get(Evaluation, evaluation_id)
response = self._response(submission, evaluation)
# The transaction commit must succeed before any success is returned.
return response
@@ -0,0 +1,110 @@
import asyncio
import hashlib
import hmac
import json
import re
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import async_sessionmaker
from kf_oj.contracts.judging import LeaseBinding
from kf_oj.contracts.judging_versions import (
Manifest,
decode_manifest,
manifest_bytes,
)
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactError
from kf_oj.modules.problems.public import ProblemPort, ProblemUnavailable
from kf_oj.modules.submissions import repository
from kf_oj.modules.submissions.public import WorkerAccessDenied, WorkerReadBusy
class WorkerAccess:
def __init__(
self, sessions: async_sessionmaker, problems: ProblemPort, artifacts: ArtifactRepository
):
self.sessions, self.problems, self.artifacts = sessions, problems, artifacts
self._reads = asyncio.Semaphore(2)
@asynccontextmanager
async def _read_slot(self):
if self._reads.locked():
raise WorkerReadBusy("worker read capacity exhausted")
async with self._reads:
yield
@staticmethod
def _token_hash(token: str) -> str:
if not re.fullmatch(r"[0-9a-f]{64}", token):
raise WorkerAccessDenied("worker grant rejected")
return hashlib.sha256(token.encode("ascii")).hexdigest()
async def _manifest(self, session, binding: LeaseBinding, token: str) -> Manifest:
row = await repository.leased_submission(session, binding, self._token_hash(token))
if row is None:
raise WorkerAccessDenied("worker grant rejected")
evaluation, attempt, submission = row
# Use a sanitized decoder at this trust boundary. JSONB snapshots are immutable for
# runtime roles; use canonical bytes for both retrieval and digest verification.
try:
manifest = decode_manifest(json.dumps(attempt.manifest).encode())
if (
manifest.evaluation_id != evaluation.id
or manifest.submission_id != submission.id
or manifest.problem_version_id != evaluation.problem_version_id
or manifest.problem_version_id != submission.problem_version_id
or manifest.language.value != submission.language
or manifest.source.digest != submission.source_digest
or not hmac.compare_digest(
hashlib.sha256(manifest_bytes(manifest)).hexdigest(), attempt.manifest_digest
)
):
raise WorkerAccessDenied("worker snapshot rejected")
version = await self.problems.for_accepted_execution(
session, manifest.problem_version_id
)
await self.artifacts.check_metadata(session, manifest.source)
expected = version.manifest(
manifest.evaluation_id, manifest.submission_id, manifest.language, manifest.source
)
if version.problem_id != submission.problem_id or manifest_bytes(
manifest
) != manifest_bytes(expected):
raise WorkerAccessDenied("worker snapshot rejected")
return manifest
except (ValueError, ProblemUnavailable):
raise WorkerAccessDenied("worker snapshot rejected") from None
async def manifest(self, binding: LeaseBinding, token: str) -> bytes:
async with self._read_slot(), self.sessions() as session:
manifest = await self._manifest(session, binding, token)
payload = manifest_bytes(manifest)
# READ COMMITTED: recheck after all preparation and just before releasing bytes.
if (
await repository.leased_submission(session, binding, self._token_hash(token))
is None
):
raise WorkerAccessDenied("worker grant rejected")
return payload
async def artifact(self, binding: LeaseBinding, token: str, digest: str) -> bytes:
async with self._read_slot(), self.sessions() as session:
manifest = await self._manifest(session, binding, token)
references = (manifest.source,) + tuple(
reference for case in manifest.cases for reference in (case.input, case.expected)
)
reference = next((ref for ref in references if ref.digest == digest), None)
if reference is None:
raise WorkerAccessDenied("artifact grant rejected")
try:
payload = await self.artifacts.read(session, reference)
except ArtifactError:
raise WorkerAccessDenied("artifact unavailable") from None
if (
await repository.leased_submission(session, binding, self._token_hash(token))
is None
):
raise WorkerAccessDenied("worker grant rejected")
return payload
+116
View File
@@ -0,0 +1,116 @@
"""Fixed data-only child. It inherits no DB credentials and executes no uploaded contents."""
import base64
import json
import os
import resource
import signal
import sys
import time
from uuid import UUID
from pydantic import TypeAdapter
from kf_oj.contracts.admin_problems import (
VALIDATION_DEADLINE_SECONDS,
CandidatePreview,
LegacyImportBudget,
)
from kf_oj.contracts.judging_v2 import NodeAdmission
from kf_oj.contracts.problems import PublicLanguageConfiguration, PublicSample
from kf_oj.infra.artifacts.archive_reader import ArchiveError, ArchiveReader
from kf_oj.modules.problems.candidate_validation import validate_candidate
from kf_oj.modules.problems.import_format import ProblemImport
from kf_oj.modules.problems.web_format import decode_web_declaration
def main():
# These are ordinary downward-only process limits, independent of the parent's life.
resource.setrlimit(resource.RLIMIT_AS, (128 * 1024**2, 128 * 1024**2))
resource.setrlimit(resource.RLIMIT_CPU, (60, 60))
resource.setrlimit(resource.RLIMIT_FSIZE, (64 * 1024**2, 64 * 1024**2))
resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
signal.alarm(VALIDATION_DEADLINE_SECONDS)
archive, directory, lock, size, digest, candidate, policy_json = sys.argv[1:]
archive, directory, lock = int(archive), int(directory), int(lock)
os.fstat(lock) # Held inherited flock prevents GC releasing capacity while any child lives.
deadline = time.monotonic() + VALIDATION_DEADLINE_SECONDS
result = {"failure_code": "invalid_archive"}
try:
reader = ArchiveReader(archive, int(size), deadline=deadline)
reader.verify_digest(digest)
result["failure_code"] = "invalid_manifest"
declared = decode_web_declaration(reader.read("problem.json", 256 * 1024))
if {m.name for m in reader.members} != set(declared.members):
raise ValueError("unexpected package members")
manifest = declared.manifest
result["failure_code"] = "invalid_data"
statement = reader.read(manifest.statement_file, 256 * 1024).decode("utf-8")
sample_data = {
name: reader.read(name, 65536)
for name in sorted({n for s in manifest.samples for n in (s.input_file, s.output_file)})
}
samples = tuple(
PublicSample(
input=base64.b64encode(sample_data[s.input_file]).decode("ascii"),
output=base64.b64encode(sample_data[s.output_file]).decode("ascii"),
)
for s in manifest.samples
)
references = reader.extract_remaining(directory)
reader.verify_unchanged()
legacy = isinstance(manifest, ProblemImport)
preview = CandidatePreview(
candidate_id=UUID(candidate),
slug=manifest.slug,
title=manifest.title,
statement=statement,
samples=samples,
judging_schema_version=manifest.schema_version,
languages=manifest.languages,
legacy_budget=LegacyImportBudget(
compile_limits=manifest.compile_limits,
run_limits=manifest.run_limits,
job_wall_time_us=manifest.job_wall_time_us,
)
if legacy
else None,
language_configurations=()
if legacy
else tuple(
PublicLanguageConfiguration.from_configuration(c)
for c in manifest.language_configurations
),
case_count=len(manifest.cases),
)
result["failure_code"] = "policy_rejected"
validated = validate_candidate(
manifest.model_dump(mode="json"),
preview.model_dump(mode="json"),
{k: {"digest": v[0], "bytes": v[1]} for k, v in references.items()},
TypeAdapter(NodeAdmission).validate_json(policy_json),
)
result = {
"manifest": validated.manifest,
"preview": validated.preview.model_dump(mode="json"),
"references": {k: v.model_dump(mode="json") for k, v in validated.references.items()},
"manifest_digest": validated.digest,
}
except ArchiveError as error:
result = {
"failure_code": "digest_mismatch"
if str(error) == "archive_digest_mismatch"
else "invalid_archive"
}
except (ValueError, OSError, MemoryError):
result = {"failure_code": result["failure_code"]}
payload = json.dumps(result, separators=(",", ":"), ensure_ascii=True).encode()
if len(payload) > 4 * 1024**2:
payload = b'{"failure_code":"invalid_data"}'
sys.stdout.buffer.write(payload)
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()
@@ -0,0 +1,66 @@
"""Ordinary import service composition. Provision its dedicated DB role and cgroup separately."""
import asyncio
import sys
from sqlalchemy.exc import DBAPIError
from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.staging import ImportStaging
from kf_oj.infra.artifacts.store import ArtifactStore
from kf_oj.infra.db.session import build_engine, build_session_factory
from kf_oj.modules.identity.service import ReservedMaintenance
from kf_oj.modules.operations.service import MaintenanceAudit
from kf_oj.modules.problems.import_worker import ImportWorker, verify_worker_limits
async def run():
settings = Settings()
if (
settings.import_staging_root is None
or settings.artifact_root is None
or settings.judge_admission is None
or settings.import_worker_cgroup is None
):
raise ValueError("import service configuration required")
verify_worker_limits(settings.import_worker_cgroup)
engine = build_engine(settings)
store = ArtifactStore(
settings.artifact_root,
max_bytes=settings.artifact_max_bytes,
max_files=settings.artifact_max_files,
)
staging = ImportStaging(settings.import_staging_root)
try:
worker = ImportWorker(
build_session_factory(engine),
staging,
ArtifactRepository(store),
ReservedMaintenance(),
MaintenanceAudit(),
settings.judge_admission,
settings.import_worker_cgroup,
)
while True:
if not await worker.once():
await asyncio.sleep(1)
finally:
staging.close()
store.close()
await engine.dispose()
def main():
try:
asyncio.run(run())
except (ValueError, OSError, DBAPIError):
print(
"import service unavailable; check trusted configuration and resource limits",
file=sys.stderr,
)
raise SystemExit(1) from None
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
"""Optional real PostgreSQL acceptance; never reuse or reset an existing business DB."""
import asyncio
import os
import re
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
import asyncpg
import pytest
from sqlalchemy.engine import make_url
@pytest.fixture
def postgres_url():
admin = os.environ.get("KF_OJ_TEST_POSTGRES")
if admin is None:
pytest.skip("real PostgreSQL acceptance requires KF_OJ_TEST_POSTGRES")
url = make_url(admin)
database = "kf_oj_test_" + uuid4().hex
assert url.drivername == "postgresql+asyncpg"
test_url = url.set(database=database).render_as_string(hide_password=False)
async def manage(command):
connection = await asyncpg.connect(
url.set(drivername="postgresql").render_as_string(hide_password=False)
)
try:
await connection.execute(command)
finally:
await connection.close()
asyncio.run(manage(f'CREATE DATABASE "{database}"'))
try:
migrate(test_url, "upgrade", "head")
yield test_url
finally:
asyncio.run(manage(f'DROP DATABASE "{database}"'))
def migrate(url: str, command: str, target: str | None = None) -> None:
try:
subprocess.run(
[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,
capture_output=True,
)
except subprocess.CalledProcessError as error:
diagnostic = ((error.stdout or b"") + (error.stderr or b"")).decode(errors="replace")[
-8192:
]
diagnostic = re.sub(r"postgresql(?:\+asyncpg)?://[^\s]+", "[test database URL]", diagnostic)
raise AssertionError(f"test migration {command} failed: " + diagnostic) from None
async def connect(url: str):
return await asyncpg.connect(url.replace("postgresql+asyncpg:", "postgresql:", 1))
+180
View File
@@ -0,0 +1,180 @@
"""Finite adverse archive/private-storage acceptance: independent capped VM only."""
import hashlib
import io
import json
import os
import stat
import struct
import time
import zipfile
from pathlib import Path
from uuid import uuid4
import pytest
from test_admin_import_vm import independent_import_vm as independent_import_vm
from test_admin_import_vm import package
from kf_oj.contracts.judging_v2 import AdmissionRegistry
from kf_oj.infra.artifacts.archive_reader import ArchiveError, ArchiveReader
from kf_oj.infra.artifacts.staging import ImportStaging
from kf_oj.infra.artifacts.store import ArtifactError, ArtifactStore
ROOT = Path(__file__).resolve().parents[2]
def policy():
return AdmissionRegistry.model_validate_json(
(ROOT / "tests/fixtures/contracts-v2/judge-registry.valid.json").read_bytes()
)
def proof(name, value):
print(
"KF_FINAL_ACCEPTANCE " + json.dumps({"name": name, "result": value}, default=str),
flush=True,
)
def test_adverse_zip_and_private_storage_boundaries(independent_import_vm, tmp_path):
outside = tmp_path / "outside-sentinel"
outside.write_bytes(b"unchanged synthetic sentinel")
original = hashlib.sha256(outside.read_bytes()).hexdigest()
good, _ = package(policy())
cases = {}
for name in ("../outside-sentinel", "/outside-sentinel", "a/b", r"a\b", "a:b"):
cases["path-" + repr(name)] = package(
policy(), payload_mutation=lambda p, n=name: p.update({n: b"bad"})
)[0]
for kind in (
stat.S_IFLNK,
stat.S_IFCHR,
stat.S_IFBLK,
stat.S_IFIFO,
stat.S_IFSOCK,
stat.S_IFDIR,
):
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as archive:
entry = zipfile.ZipInfo("unsafe")
entry.create_system = 3
entry.external_attr = (kind | 0o600) << 16
archive.writestr(entry, b"synthetic")
cases[f"type-{kind}"] = out.getvalue()
for duplicate in ("statement.md", "STATEMENT.MD"):
out = io.BytesIO(good)
with zipfile.ZipFile(out, "a") as archive:
archive.writestr(duplicate, b"different")
cases["duplicate-" + duplicate] = out.getvalue()
for signature in (
b"PK\x03\x04",
b"\x1f\x8b",
b"BZh",
b"\xfd7zXZ\x00",
b"Rar!\x1a\x07",
b"7z\xbc\xaf\x27\x1c",
b"X" * 257 + b"ustar",
):
cases["nested-" + signature[:6].hex()] = package(
policy(),
payload_mutation=lambda p, s=signature: p.update({"hidden.in": s + b"synthetic"}),
)[0]
central = good.index(b"PK\x01\x02")
for label, local_offset, central_offset, value, fmt in (
("encrypted", 6, 8, 1, "<H"),
("unsupported", 8, 10, 99, "<H"),
("declared-size", 22, 24, 67108865, "<I"),
("crc", 14, 16, 1, "<I"),
):
blob = bytearray(good)
struct.pack_into(fmt, blob, local_offset, value)
struct.pack_into(fmt, blob, central + central_offset, value)
cases[label] = bytes(blob)
for label, extra in (("zip64", b"\x01\x00\x00\x00"), ("unknown-extra", b"\x99\x99\x00\x00")):
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as archive:
entry = zipfile.ZipInfo("data")
entry.extra = extra
archive.writestr(entry, b"synthetic")
cases[label] = out.getvalue()
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as archive:
for i in range(535):
archive.writestr(f"f{i}", b"")
cases["file-count"] = out.getvalue()
# Actual bounded inflation exceeds the forged declared size. The 65 MiB stream
# is produced in chunks under the VM cap; no 65 MiB object is allocated.
out = io.BytesIO()
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
with archive.open("inflate", "w") as member:
for _ in range(1040):
member.write(b"A" * 65536)
blob = bytearray(out.getvalue())
center = blob.index(b"PK\x01\x02")
struct.pack_into("<I", blob, 22, 67108864)
struct.pack_into("<I", blob, center + 24, 67108864)
cases["actual-inflate-over-64MiB"] = bytes(blob)
refused = []
for index, (label, blob) in enumerate(cases.items()):
file = tmp_path / f"archive-{index}"
file.write_bytes(blob)
file.chmod(0o600)
fd = os.open(file, os.O_RDONLY)
try:
with pytest.raises(ArchiveError) as error:
reader = ArchiveReader(fd, len(blob), deadline=time.monotonic() + 60)
for member in reader.members:
for _ in reader.chunks(member.name, 67108864):
pass
assert str(error.value) in ("invalid_archive", "archive_deadline")
refused.append(label)
finally:
os.close(fd)
file.unlink()
file = tmp_path / "mutable"
file.write_bytes(good)
file.chmod(0o600)
fd = os.open(file, os.O_RDONLY)
try:
reader = ArchiveReader(fd, len(good), deadline=time.monotonic() + 60)
with file.open("r+b") as writer:
writer.write(b"X")
with pytest.raises(ArchiveError, match="archive_changed"):
reader.verify_unchanged()
finally:
os.close(fd)
store = ArtifactStore(tmp_path / "objects", max_bytes=4, max_files=2)
try:
ref = store.put(b"1234")
with pytest.raises(ArtifactError, match="capacity"):
store.put(b"5")
assert store.read(ref) == b"1234"
store.remove(ref.digest)
recovered = store.put(b"5")
assert store.read(recovered) == b"5"
finally:
store.close()
link_root = tmp_path / "linked-store"
link_root.symlink_to(tmp_path / "objects", target_is_directory=True)
with pytest.raises((ArtifactError, OSError)):
ArtifactStore(link_root)
staging = ImportStaging(tmp_path / "staging")
operation_id = uuid4()
(tmp_path / "staging" / str(operation_id)).symlink_to(tmp_path, target_is_directory=True)
try:
with pytest.raises((ArtifactError, OSError)):
staging.operation(operation_id)
finally:
staging.close()
assert hashlib.sha256(outside.read_bytes()).hexdigest() == original
proof(
"archive_and_storage",
{
"refused": refused,
"outside_unchanged": True,
"content_never_executed": True,
"changed_descriptor_rejected": True,
"quota_recovered": True,
"symlink_roots_rejected": True,
},
)
+319
View File
@@ -0,0 +1,319 @@
"""Upgrade, adverse storage and cross-process admission: capped independent VM only."""
import asyncio
import json
import os
import resource
import subprocess
import sys
import time
from pathlib import Path
from uuid import uuid4
import httpx
from conftest import connect
from sqlalchemy.engine import make_url
from test_admin_import_vm import independent_import_vm as independent_import_vm
from kf_oj.contracts.judging_v2 import AdmissionRegistry
from kf_oj.modules.identity.passwords import hash_password
ROOT = Path(__file__).resolve().parents[2]
def policy():
return AdmissionRegistry.model_validate_json(
(ROOT / "tests/fixtures/contracts-v2/judge-registry.valid.json").read_bytes()
)
def proof(name, value):
print(
"KF_FINAL_ACCEPTANCE " + json.dumps({"name": name, "result": value}, default=str),
flush=True,
)
def test_cross_process_admission_and_worker_identity(independent_import_vm, postgres_url, tmp_path):
groups = independent_import_vm
roles = {name: "final_" + name + "_" + uuid4().hex for name in ("api", "worker")}
users = {f"capacity_admin_{i}": uuid4() for i in range(5)}
password = "Synthetic capacity acceptance only 2026!"
artifact, staging = tmp_path / "artifacts", tmp_path / "staging"
artifact.mkdir(mode=0o700)
staging.mkdir(mode=0o700)
processes, logs = {}, {}
base = {
k: v
for k, v in os.environ.items()
if k
in (
"PATH",
"PYTHONPATH",
"LD_LIBRARY_PATH",
"LC_ALL",
"PYTHONUNBUFFERED",
"PYTHONDONTWRITEBYTECODE",
)
}
def enter(group):
def apply():
(group / "cgroup.procs").write_text(str(os.getpid()))
resource.setrlimit(resource.RLIMIT_FSIZE, (67108864, 67108864))
resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64))
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
return apply
def start(name, *, bad_identity=False, bad_group=False):
worker = name == "worker"
group = groups["import-worker" if worker else "api"]
env = base | {
"KF_OJ_DATABASE_URL": make_url(postgres_url)
.set(username=roles["api" if bad_identity or not worker else "worker"])
.render_as_string(hide_password=False),
"KF_OJ_ENVIRONMENT": "test",
"KF_OJ_ARTIFACT_ROOT": str(artifact),
"KF_OJ_IMPORT_STAGING_ROOT": str(staging),
"KF_OJ_JUDGE_ADMISSION": policy().model_dump_json(),
"KF_OJ_BROWSER_ORIGINS": '["http://127.0.0.1:58000"]',
}
if worker:
env["KF_OJ_IMPORT_WORKER_CGROUP"] = str(groups["api"] if bad_group else group)
command = [sys.executable, "-m", "kf_oj.workers.problem_import"]
else:
command = [
sys.executable,
"-m",
"uvicorn",
"kf_oj.api.app:create_app",
"--factory",
"--host",
"127.0.0.1",
"--port",
"58000" if name == "api0" else "58001",
"--no-proxy-headers",
"--limit-concurrency",
"4",
"--backlog",
"16",
]
log = (tmp_path / (name + ".log")).open("a")
logs[name] = log
processes[name] = subprocess.Popen(
command, env=env, stdout=log, stderr=log, preexec_fn=enter(group)
)
def stop(name):
process = processes.pop(name, None)
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=20)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
if name in logs:
logs.pop(name).close()
async def run():
connection = await connect(postgres_url)
try:
for name, membership in (("api", "kf_oj_platform"), ("worker", "kf_oj_problem_import")):
await connection.execute(
f"CREATE ROLE {roles[name]} LOGIN NOSUPERUSER "
"NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS"
)
await connection.execute(f"GRANT {membership} TO {roles[name]}")
for name, identifier in users.items():
await connection.execute(
"INSERT INTO accounts(id,username,password_hash) VALUES($1,$2,$3)",
identifier,
name,
hash_password(name, password),
)
await connection.execute(
"SELECT * FROM kf_manage_account_authority($1,$2)",
name,
"bootstrap" if name.endswith("_0") else "grant",
)
for wrong in ("identity", "cgroup"):
start("worker", bad_identity=wrong == "identity", bad_group=wrong == "cgroup")
assert await asyncio.to_thread(processes["worker"].wait, 120) != 0
assert not await connection.fetchval(
"SELECT worker_ready_until>clock_timestamp() "
"FROM problem_import_pool WHERE id=1"
)
stop("worker")
start("worker")
deadline = time.monotonic() + 180
while not await connection.fetchval(
"SELECT worker_ready_until>clock_timestamp() FROM problem_import_pool WHERE id=1"
):
assert time.monotonic() < deadline and processes["worker"].poll() is None
await asyncio.sleep(0.1)
# Quiesce consumption. The actual verified heartbeat is retained for
# finite admission checks; synthetic capacity rows cannot be parsed.
stop("worker")
start("api0")
start("api1")
prefix = "/api/v1/admin/problem-imports"
async with (
httpx.AsyncClient(base_url="http://127.0.0.1:58000", timeout=30) as first,
httpx.AsyncClient(base_url="http://127.0.0.1:58001", timeout=30) as second,
):
for client in (first, second):
deadline = time.monotonic() + 300
while True:
try:
if (await client.get("/api/v1/health/live")).status_code == 200:
break
except httpx.ConnectError:
pass
assert time.monotonic() < deadline
await asyncio.sleep(0.1)
result = await client.post(
"/api/v1/auth/login",
json={
"username": "capacity_admin_0",
"password": password,
},
headers={"Origin": "http://127.0.0.1:58000"},
)
assert result.status_code == 200, result.text
def headers(client):
return {
"Origin": "http://127.0.0.1:58000",
"X-CSRF-Token": client.cookies["kf_csrf"],
}
def body(key=None):
return {
"operation_id": str(key or uuid4()),
"archive_digest": "a" * 64,
"archive_bytes": 100,
}
async def refresh_worker():
previous = await connection.fetchval(
"SELECT worker_ready_until FROM problem_import_pool WHERE id=1"
)
start("worker")
deadline = time.monotonic() + 120
while True:
current = await connection.fetchval(
"SELECT worker_ready_until FROM problem_import_pool WHERE id=1"
)
if current and (previous is None or current > previous):
break
assert time.monotonic() < deadline
assert processes["worker"].poll() is None
await asyncio.sleep(0.1)
stop("worker")
await refresh_worker()
request = body()
replies = await asyncio.gather(
*(
client.post(prefix, json=request, headers=headers(client))
for client in (first, second)
)
)
# Identical UUIDs converge across independent API processes. A
# transient root-lock refusal is retried once after the winner.
assert 201 in [r.status_code for r in replies]
retry = await second.post(prefix, json=request, headers=headers(second))
assert retry.status_code == 201
assert (
await connection.fetchval("SELECT count(*) FROM problem_import_operations") == 1
)
assert (
await connection.fetchval("SELECT reserved_bytes FROM problem_import_pool")
== 134217728
)
other = await first.post(prefix, json=body(), headers=headers(first))
assert other.status_code == 503
discarded = await first.post(
prefix + "/" + request["operation_id"] + "/discard", headers=headers(first)
)
assert discarded.status_code == 200, discarded.text
assert (
await connection.fetchval("SELECT reserved_bytes FROM problem_import_pool") == 0
)
async def capacity_rows(count):
# Explicit unmeasured database fixtures exercise owner/global
# admission boundaries; no ready candidate or grading is forged.
await connection.execute(
"DELETE FROM problem_import_operations WHERE state='uploaded'"
)
identifiers = list(users.values())
for i in range(count):
await connection.execute(
"""
INSERT INTO problem_import_operations(id,owner_id,authorization_version,
archive_digest,archive_bytes,state,reserved_bytes,expires_at,updated_at)
VALUES($1,$2,2,$3,100,'uploaded',0,
clock_timestamp()+interval '1 hour',clock_timestamp())
""",
uuid4(),
identifiers[0] if count == 10 else identifiers[1 + i % 4],
"b" * 64,
)
results = {}
for label, count in (("owner_limit", 10), ("global_limit", 32)):
await capacity_rows(0)
await refresh_worker()
await capacity_rows(count)
before = await connection.fetchval(
"SELECT count(*) FROM problem_import_operations"
)
response = await second.post(prefix, json=body(), headers=headers(second))
assert response.status_code == 503
assert (
await connection.fetchval("SELECT count(*) FROM problem_import_operations")
== before
)
results[label] = {
"fixture_count": count,
"actual_http_status": 503,
"no_new_receipt": True,
}
await capacity_rows(0)
await refresh_worker()
resumed = body()
result = await second.post(prefix, json=resumed, headers=headers(second))
assert result.status_code == 201, result.text
assert (
await first.post(
prefix + "/" + resumed["operation_id"] + "/discard", headers=headers(first)
)
).status_code == 200
assert (
await connection.fetchval("SELECT reserved_bytes FROM problem_import_pool") == 0
)
proof(
"cross_process_admission",
results
| {
"two_actual_api_processes": True,
"identical_uuid_one_receipt": True,
"single_slot_http_status": 503,
"wrong_worker_identity_and_group_exit_nonzero": True,
"ordinary_worker_heartbeat_verified": True,
"recovery_new_operation_201": True,
"remaining_reserved_bytes": 0,
},
)
finally:
for name in list(processes):
stop(name)
for role in roles.values():
await connection.execute(f"DROP OWNED BY {role}")
await connection.execute(f"DROP ROLE {role}")
await connection.close()
asyncio.run(run())
@@ -0,0 +1,156 @@
"""Contract checks only; upload/parser/security evidence requires the independent VM."""
import copy
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from pydantic import ValidationError
from kf_oj.contracts.admin_problems import (
CandidatePreview,
CreateProblemImport,
LegacyImportBudget,
ProblemImportCapabilities,
ProblemImportDetail,
)
from kf_oj.contracts.judging import AdmissionPolicy
from kf_oj.modules.problems.import_format import ImportEnvelope
from kf_oj.modules.problems.web_format import decode_web_declaration
ROOT = Path(__file__).resolve().parents[2]
def legacy_manifest():
policy = AdmissionPolicy.model_validate_json(
(ROOT / "tests/fixtures/contracts/judge-admission.valid.json").read_bytes()
)
return {
"schema_version": 1,
"slug": "web-fixture",
"title": "Public",
"statement_file": "statement.md",
"languages": ["c17", "cpp20"],
"toolchain": policy.toolchain.model_dump(mode="json"),
"comparison": "exact_bytes",
"compile_limits": policy.compile_ceiling.model_dump(),
"run_limits": policy.run_ceiling.model_dump(),
"job_wall_time_us": 60000000,
"cases": [{"key": "binary", "input_file": "hidden.in", "expected_file": "hidden.out"}],
"samples": [{"input_file": "sample.in", "output_file": "sample.out"}],
}
def test_web_declaration_separates_public_data_and_preserves_local_format():
source = legacy_manifest()
original = copy.deepcopy(source)
declared = decode_web_declaration(json.dumps(source).encode())
assert declared.members == frozenset(
("problem.json", "statement.md", "hidden.in", "hidden.out", "sample.in", "sample.out")
)
assert declared.manifest.model_dump(mode="json") == original
assert ImportEnvelope.model_validate(source).root == declared.manifest
for mutation in (
lambda x: x["samples"][0].update(input_file="hidden.in"),
lambda x: x.update(statement_file="problem.json"),
lambda x: x["cases"][0].update(input_file="Statement.md"),
lambda x: x["cases"][0].update(input_file="hidden.zip"),
lambda x: x.update(languages=["rust"]),
lambda x: x.update(download_url="https://example.invalid/private"),
):
value = copy.deepcopy(source)
mutation(value)
with pytest.raises(ValueError):
decode_web_declaration(json.dumps(value).encode())
with pytest.raises(ValueError):
decode_web_declaration(
json.dumps(source)
.replace('"schema_version": 1', '"schema_version": 1, "schema_version": 1')
.encode()
)
def test_import_operation_key_and_preview_have_no_private_payload_fields():
operation = {
"schema_version": 1,
"operation_id": str(uuid4()),
"archive_digest": "a" * 64,
"archive_bytes": 64 * 1024**2,
}
assert CreateProblemImport.model_validate(operation).archive_bytes == 64 * 1024**2
for field, value in (
("archive_bytes", True),
("archive_bytes", 64 * 1024**2 + 1),
("url", "https://example.invalid"),
("role", "admin"),
):
with pytest.raises(ValidationError):
CreateProblemImport.model_validate(operation | {field: value})
manifest = ImportEnvelope.model_validate(legacy_manifest()).root
preview = CandidatePreview(
candidate_id=uuid4(),
slug=manifest.slug,
title=manifest.title,
statement="Public only",
samples=(),
judging_schema_version=1,
languages=manifest.languages,
legacy_budget=LegacyImportBudget(
compile_limits=manifest.compile_limits,
run_limits=manifest.run_limits,
job_wall_time_us=manifest.job_wall_time_us,
),
case_count=1,
)
now = datetime.now(UTC)
response = ProblemImportDetail(
operation_id=uuid4(),
state="ready",
expected_archive_digest="a" * 64,
archive_bytes=100,
created_at=now,
updated_at=now,
expires_at=now + timedelta(days=1),
failure_code=None,
candidate_id=preview.candidate_id,
published_version_id=None,
candidate=preview,
)
assert set(response.candidate.model_dump()) == {
"candidate_id",
"slug",
"title",
"statement",
"samples",
"judging_schema_version",
"languages",
"legacy_budget",
"language_configurations",
"case_count",
}
with pytest.raises(ValidationError):
CandidatePreview.model_validate(preview.model_dump() | {"input_digest": "a" * 64})
capabilities = ProblemImportCapabilities(uploads_available=False, validation_available=False)
assert (
capabilities.limits.global_concurrency == 1 and capabilities.limits.owner_candidates == 10
)
def test_standalone_import_worker_resolves_all_referenced_metadata():
# A separate trusted interpreter avoids accidental model registration by API test imports.
import subprocess
import sys
script = """
import kf_oj.workers.problem_import
from kf_oj.infra.db.base import Base
for table in Base.metadata.tables.values():
for foreign in table.foreign_keys:
assert foreign.column.table is not None
"""
result = subprocess.run(
[sys.executable, "-c", script], capture_output=True, text=True, timeout=15
)
assert result.returncode == 0, result.stderr
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
"""Approved baseline upgrade/retention: actual PostgreSQL in an independent capped VM."""
import asyncio
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
from uuid import uuid4
from conftest import connect, migrate
from sqlalchemy.engine import make_url
from test_admin_import_vm import independent_import_vm as independent_import_vm
from test_admin_import_vm import package
from kf_oj.contracts.judging_v2 import AdmissionRegistry
ROOT = Path(__file__).resolve().parents[2]
def policy():
return AdmissionRegistry.model_validate_json(
(ROOT / "tests/fixtures/contracts-v2/judge-registry.valid.json").read_bytes()
)
def proof(name, value):
print(
"KF_FINAL_ACCEPTANCE " + json.dumps({"name": name, "result": value}, default=str),
flush=True,
)
def test_baseline_upgrade_preserves_existing_data(independent_import_vm, postgres_url, tmp_path):
baseline = Path("/kf/baseline-platform")
assert baseline.is_dir(), "curated approved baseline required"
url = make_url(postgres_url).set(database="kf_oj_test_upgrade_" + uuid4().hex)
rendered = url.render_as_string(hide_password=False)
async def manage(command):
connection = await connect(postgres_url)
try:
await connection.execute(command)
finally:
await connection.close()
def old_run(args):
result = subprocess.run(
[sys.executable, *args],
cwd=baseline,
env=os.environ
| {
"PYTHONPATH": "/kf/baseline-platform/src:/kf/site",
"KF_OJ_DATABASE_URL": rendered,
"KF_BASELINE_FIXTURES": str(tmp_path),
},
capture_output=True,
timeout=300,
)
assert result.returncode == 0, (result.stdout + result.stderr).decode(errors="replace")[
-2000:
]
return result
# Real old-schema data is created through the old public services. No student
# source executes here; historical report fixtures are explicitly unmeasured.
for multi in (False, True):
directory = tmp_path / ("v2" if multi else "v1")
directory.mkdir(mode=0o700)
_, payload = package(policy(), multi=multi)
for name, data in payload.items():
(directory / name).write_bytes(data)
(directory / name).chmod(0o600)
seed = """
import asyncio,hashlib,json,os,secrets
from datetime import datetime,timedelta,timezone
from pathlib import Path
from uuid import uuid4
from sqlalchemy import text,update
from kf_oj.api.app import create_app
from kf_oj.core.settings import Settings
from kf_oj.contracts.judging import ArtifactReference
from kf_oj.contracts.judging_v2 import AdmissionRegistry
from kf_oj.contracts.judging_versions import manifest_bytes
from kf_oj.contracts.submissions import CreateSubmission
from kf_oj.modules.submissions.models import Evaluation,JudgeAttempt
async def main():
root=Path(os.environ['KF_BASELINE_FIXTURES'])
fixture=Path('/kf/tests/fixtures/contracts-v2/judge-registry.valid.json')
policy=AdmissionRegistry.model_validate_json(fixture.read_bytes())
database=os.environ['KF_OJ_DATABASE_URL']
app=create_app(Settings(_env_file=None,database_url=database,
artifact_root=root/'objects',judge_admission=policy))
async with app.router.lifespan_context(app):
password='Synthetic upgrade acceptance only 2026!'
user=await app.state.identity.create_account('upgrade_student',password)
for name in ('v1','v2'):
version=await app.state.problems.import_directory(root/name)
request=CreateSubmission(problem_version_id=version,language='c17',
idempotency_key=uuid4(),source='int main(void){return 0;}')
created=await app.state.submissions.create(user,request)
# Controlled, unmeasured historical database fixtures prove preservation of
# reports and their digests. They never claim execution or trusted performance.
async with app.state.sessions.begin() as session:
projected=await app.state.problems.for_submission(session,version)
source=ArtifactReference(digest=hashlib.sha256(request.source.encode()).hexdigest(),
bytes=len(request.source.encode()))
manifest=projected.manifest(created.evaluation_id,created.submission_id,'c17',source)
md=hashlib.sha256(manifest_bytes(manifest)).hexdigest()
aid,wid=uuid4(),uuid4()
template=('contracts' if name=='v1' else 'contracts-v2')
file=('judge-result.valid.json' if name=='v1' else 'judge-result.valid-c17.json')
report=json.loads((Path('/kf/tests/fixtures')/template/file).read_text())
report['binding']={'evaluation_id':str(created.evaluation_id),'attempt_id':str(aid),
'worker_id':str(wid),'fencing_version':1}
report['manifest_digest']=md
report['cases']=[{'case_id':str(case.case_id),'verdict':'accepted',
'cpu_time_us':100,'memory_peak_bytes':262144}
for case in manifest.cases]
report['run_cpu_time_us']=100*len(manifest.cases)
report['run_memory_peak_bytes']=262144
digest=hashlib.sha256(json.dumps(report,sort_keys=True,separators=(',',':'),
ensure_ascii=False).encode()).hexdigest()
await session.execute(text('SET LOCAL ROLE kf_oj_coordinator'))
session.add(JudgeAttempt(id=aid,evaluation_id=created.evaluation_id,worker_id=wid,
number=1,fencing_version=1,lease_token_hash=hashlib.sha256(
secrets.token_hex(32).encode()).hexdigest(),
expires_at=datetime.now(timezone.utc)+timedelta(minutes=10),
manifest=manifest.model_dump(mode='json'),manifest_digest=md,
report=report,report_digest=digest,cleanup_complete=True))
await session.flush()
await session.execute(update(Evaluation).where(Evaluation.id==created.evaluation_id)
.values(current_attempt_id=aid,state='finished',attempts_count=1,
fencing_version=1,verdict='accepted',final_report=report,
final_report_digest=digest))
asyncio.run(main())
"""
asyncio.run(manage(f'CREATE DATABASE "{url.database}"'))
try:
old_run(["-m", "alembic", "upgrade", "head"])
old_run(["-c", seed])
async def snapshot(columns=None):
connection = await connect(rendered)
try:
if columns is None:
rows = await connection.fetch("""
SELECT table_name,column_name FROM information_schema.columns
WHERE table_schema='public' AND table_name<>'alembic_version'
ORDER BY table_name,ordinal_position
""")
columns = {}
for row in rows:
columns.setdefault(row["table_name"], []).append(row["column_name"])
data = {}
for table, names in columns.items():
query = (
"SELECT " + ",".join('"' + n + '"' for n in names) + ' FROM "' + table + '"'
)
records = sorted(
json.dumps(dict(r), sort_keys=True, default=str)
for r in await connection.fetch(query)
)
data[table] = {
"rows": len(records),
"sha256": hashlib.sha256("\n".join(records).encode()).hexdigest(),
}
return columns, data
finally:
await connection.close()
columns, before = asyncio.run(snapshot())
assert before["accounts"]["rows"] == 1 and before["problem_versions"]["rows"] == 2
assert before["submissions"]["rows"] == 2
assert before["judge_attempts"]["rows"] == 2
migrate(rendered, "upgrade", "head")
_, after = asyncio.run(snapshot(columns))
assert after == before
migrate(rendered, "check")
async def defaults():
connection = await connect(rendered)
try:
account = await connection.fetchrow(
"SELECT is_administrator,authorization_version FROM accounts"
)
assert not account["is_administrator"] and account["authorization_version"] == 1
assert await connection.fetchval("SELECT count(*) FROM evaluation_performance") == 0
assert (
await connection.fetchval("SELECT count(*) FROM problem_maintenance_events")
== 0
)
for role in ("kf_oj_identity_operator", "kf_oj_problem_import"):
row = await connection.fetchrow("SELECT * FROM pg_roles WHERE rolname=$1", role)
assert not any(
row[k]
for k in (
"rolcanlogin",
"rolsuper",
"rolcreatedb",
"rolcreaterole",
"rolreplication",
"rolbypassrls",
)
)
finally:
await connection.close()
asyncio.run(defaults())
proof(
"baseline_upgrade",
{
"baseline": "5df3c370aab0b12a1c3f8670a0c066f8b77a4078",
"retained_tables": before,
"old_v1_v2_manifests_and_source_bytes": "exact",
"historical_report_retention_fixture_unmeasured": True,
"fixture_reports_not_student_execution_or_performance_acceptance": True,
"fresh_current_database": postgres_url.split("/")[-1].split("?")[0],
},
)
finally:
asyncio.run(manage(f'DROP DATABASE "{url.database}"'))
+244
View File
@@ -0,0 +1,244 @@
"""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 not 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(
"SELECT * FROM public.kf_manage_account_authority('storage_user','bootstrap')"
)
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(
"SELECT * FROM public.kf_manage_account_authority('storage_user','bootstrap')"
)
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,
)
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(
"SELECT * FROM public.kf_manage_account_authority('storage_user','revoke')"
)
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(
"SELECT * FROM public.kf_manage_account_authority('storage_user','revoke')"
)
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")
+204
View File
@@ -0,0 +1,204 @@
import asyncio
import hashlib
import os
from uuid import uuid4
import pytest
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from kf_oj.contracts.judging import ArtifactReference
from kf_oj.infra.artifacts.models import Artifact
from kf_oj.infra.artifacts.repository import ArtifactRepository
from kf_oj.infra.artifacts.store import ArtifactError, ArtifactStore
def test_immutable_store_digest_capacity_and_recovery(tmp_path):
root = tmp_path / "artifacts"
store = ArtifactStore(root, max_bytes=6, max_files=2)
try:
first = store.put(b"abc")
assert store.read(first) == b"abc"
assert store.put(b"abc") == first
with pytest.raises(ArtifactError, match="digest or length"):
store.put(b"changed", first)
second = store.put(b"def")
with pytest.raises(ArtifactError, match="capacity"):
store.put(b"g")
assert store.read(first) == b"abc" and store.read(second) == b"def"
(root / first.digest).write_bytes(b"xyz")
with pytest.raises(ArtifactError, match="digest or length"):
store.read(first)
with pytest.raises(ArtifactError, match="digest or length"):
store.put(b"abc") # Corrupt files are not silently repaired or overwritten.
assert (root / first.digest).read_bytes() == b"xyz"
finally:
store.close()
@pytest.mark.parametrize("attack", ["symlink", "hardlink", "fifo", "traversal"])
def test_links_and_non_regular_entries_refused(tmp_path, attack):
root = tmp_path / "artifacts"
store = ArtifactStore(root)
data = b"private bytes"
ref = ArtifactReference(digest=hashlib.sha256(data).hexdigest(), bytes=len(data))
private = tmp_path / "outside"
private.write_bytes(data)
private.chmod(0o600)
try:
if attack == "symlink":
(root / ref.digest).symlink_to(private)
elif attack == "hardlink":
os.link(private, root / ref.digest)
elif attack == "fifo":
os.mkfifo(root / ref.digest, 0o600)
else:
ref = ref.model_copy(update={"digest": "../outside"})
with pytest.raises(ArtifactError):
store.read(ref)
finally:
store.close()
def test_private_root_and_bounded_temporary_cleanup(tmp_path):
root = tmp_path / "root"
root.mkdir(mode=0o755)
with pytest.raises(ArtifactError, match="private"):
ArtifactStore(root)
linked = tmp_path / "linked"
linked.symlink_to(root)
with pytest.raises(ArtifactError, match="links"):
ArtifactStore(linked)
root.chmod(0o700)
store = ArtifactStore(root)
try:
old = root / (".tmp-" + uuid4().hex)
recent = root / (".tmp-" + uuid4().hex)
for path in (old, recent):
path.write_bytes(b"temporary")
path.chmod(0o600)
os.utime(old, (0, 0))
assert store.clean_temporary(limit=1) == 1
assert not old.exists() and recent.exists()
with pytest.raises(ArtifactError):
store.clean_temporary(min_age_seconds=0)
finally:
store.close()
def test_physical_quota_serializes_parallel_publishers(tmp_path):
root = tmp_path / "root"
first = ArtifactStore(root, max_bytes=6, max_files=10)
second = ArtifactStore(root, max_bytes=6, max_files=10)
async def run():
results = await asyncio.gather(
asyncio.to_thread(first.put, b"abcd"),
asyncio.to_thread(second.put, b"efgh"),
return_exceptions=True,
)
assert sum(isinstance(r, ArtifactReference) for r in results) == 1
assert sum(isinstance(r, ArtifactError) for r in results) == 1
assert sum(p.stat().st_size for p in root.iterdir() if p.name != ".lock") == 4
try:
asyncio.run(run())
finally:
first.close()
second.close()
def test_real_metadata_rollback_reference_fencing_and_collection(postgres_url, tmp_path):
store = ArtifactStore(tmp_path / "bytes")
artifacts = ArtifactRepository(store)
async def run():
engine = create_async_engine(postgres_url)
sessions = async_sessionmaker(engine, expire_on_commit=False)
ref = store.reference(b"rolled back")
try:
with pytest.raises(RuntimeError):
async with sessions.begin() as s:
await s.execute(text("SET LOCAL ROLE kf_oj_platform"))
await artifacts.put(s, b"rolled back")
raise RuntimeError("transaction aborted")
async with sessions() as s:
assert await s.get(Artifact, ref.digest) is None
assert store.read(ref) == b"rolled back" # Durable orphan, never dangling DB reference.
os.utime(tmp_path / "bytes" / ref.digest, (0, 0))
assert await artifacts.collect_orphans(sessions) == 1
with pytest.raises(ArtifactError):
store.read(ref)
async with sessions.begin() as s:
await s.execute(text("SET LOCAL ROLE kf_oj_platform"))
ref = await artifacts.put(s, b"durable unreferenced")
os.utime(tmp_path / "bytes" / ref.digest, (0, 0))
assert await artifacts.collect_orphans(sessions) == 0
async with sessions.begin() as s:
await artifacts.mark_deleting(s, ref.digest)
with pytest.raises(ArtifactError):
async with sessions.begin() as s:
await artifacts.put(s, b"durable unreferenced")
assert await artifacts.finish_deleting(sessions) == 1
async with sessions() as s:
assert list(await s.scalars(select(Artifact))) == []
finally:
await engine.dispose()
try:
asyncio.run(run())
finally:
store.close()
def test_bounded_metadata_collector_preserves_references_and_resumes(postgres_url, tmp_path):
from datetime import timedelta
from test_problems import import_fixture, platform_role
from kf_oj.modules.problems.service import ProblemService
root = tmp_path / "import"
_, policy = import_fixture(root)
store = ArtifactStore(tmp_path / "bytes")
repository = ArtifactRepository(store)
async def run():
engine = create_async_engine(postgres_url)
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
service = ProblemService(sessions, repository, policy)
version = await service.import_directory(root)
async with sessions.begin() as session:
orphan = await repository.put(session, b"committed without business reference")
await session.execute(
update(Artifact).values(created_at=func.now() - timedelta(days=2))
)
# After trusted fixture preparation, run collection with actual limited platform rights.
await engine.dispose()
platform_role(engine)
marked_total, cursor = 0, None
for _ in range(100):
marked, cursor = await repository.collect_metadata(sessions, limit=1, after=cursor)
marked_total += marked
if cursor is None:
break
else:
pytest.fail("bounded metadata scan failed to finish")
assert marked_total == 1
assert await repository.finish_deleting(sessions, limit=1) == 1
assert await repository.finish_deleting(sessions, limit=1) == 0
async with sessions() as session:
projected = await service.for_submission(session, version)
assert (
await repository.read(session, projected.cases[0].expected)
== b"hidden-expected\x00\xff"
)
assert await session.get(Artifact, orphan.digest) is None
finally:
await engine.dispose()
try:
asyncio.run(run())
finally:
store.close()
+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())
+5 -1
View File
@@ -19,7 +19,11 @@ def test_shared_wire_examples(path: Path) -> None:
if ".invalid-" in path.name:
with pytest.raises(ValidationError):
JudgeTaskNotification.model_validate(payload)
assert not validator.is_valid(payload)
if ".invalid-semantic-" in path.name:
# JSON Schema counts 1.0 as an integer; the wire decoder forbids coercion.
assert validator.is_valid(payload)
else:
assert not validator.is_valid(payload)
else:
notification = JudgeTaskNotification.model_validate(payload)
validator.validate(payload)

Some files were not shown because too many files have changed in this diff Show More