feat(identity): expose live maintenance capabilities and transaction authorization

This commit is contained in:
2026-10-11 12:31:08 +08:00
parent 3ed5b72459
commit a7b2324a3e
9 changed files with 315 additions and 22 deletions
+14 -3
View File
@@ -9,13 +9,24 @@ from kf_oj.api.dependencies import (
trusted_origin,
unsafe_principal,
)
from kf_oj.contracts.identity import CurrentUser, LoginRequest, LoginResponse
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,
@@ -58,7 +69,7 @@ async def login(
)
response.headers["Cache-Control"] = "no-store"
return LoginResponse(
user=CurrentUser(id=issued.principal.account_id, username=issued.principal.username),
user=current_user(issued.principal),
expires_at=issued.expires_at,
csrf_token=issued.csrf_token,
)
@@ -67,7 +78,7 @@ async def login(
@router.get("/me", response_model=CurrentUser)
async def me(response: Response, principal: Annotated[Principal, Depends(current_principal)]):
response.headers["Cache-Control"] = "no-store"
return CurrentUser(id=principal.account_id, username=principal.username)
return current_user(principal)
@router.post("/logout", status_code=204, dependencies=[Depends(unsafe_principal)])
+8 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Annotated
from typing import Annotated, Literal
from pydantic import Field, SecretStr
@@ -11,9 +11,16 @@ class LoginRequest(WireModel):
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):
+8 -2
View File
@@ -20,8 +20,14 @@
平台账号仅可插入普通账号列、更新启用状态,不能写管理员标记或授权版本;管理员的启用状态
需要独立本地运维身份。`kf_oj_identity_operator` 与 `kf_oj_problem_import` 为无登录的独立权限组,
口令与登录身份由运维另行配置,平台不继承运维组。导入组目前没有表权限,后续导入迁移只授予
队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员;管理员 CLI、公开能力端口
及管理 API 尚待接通。保留授权或撤销历史后拒绝回退这次迁移。
队列与候选所需权限,不授予评测状态写入权。迁移不创建默认管理员;管理员 CLI 和管理 API
尚待接通。保留授权或撤销历史后拒绝回退这次迁移。
当前主体与登录响应附带版本为 1 的 `capabilities`,其中维护能力和十进制授权版本来自当前
有效会话关联的账号;浏览器只展示这些信息,不能提交它们来获得授权。模块公开端口
`ProblemMaintenancePort.require_problem_maintenance` 在调用方写事务中重新检查账号、授权版本
与实时会话,并按账号→会话的顺序持有 SHARE 锁直到提交/回滚,防止验证之后撤权的竞态。
普通构造的 `Principal` 默认没有维护能力;会话摘要仅在服务端主体内传递,不进入 DTO 或 repr。
受控启动使用 `--no-proxy-headers`,避免服务器中间件预先替换 peer;反向代理后的用户共享
该连接预算,引入可信代理的独立限流策略之前不能直接打开客户端转发头信任。
+15 -1
View File
@@ -1,6 +1,7 @@
"""Only verified principals cross business module boundaries."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
@@ -8,6 +9,19 @@ from uuid import UUID
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):
@@ -51,19 +51,31 @@ async def account_by_username(session: AsyncSession, username: str) -> Account |
return await session.scalar(select(Account).where(Account.username == username))
async def current_session(session: AsyncSession, token_hash: str):
return (
await session.execute(
select(Session, Account)
.join(Account)
.where(
Session.token_hash == token_hash,
Session.revoked_at.is_(None),
Session.expires_at > func.now(),
Account.enabled.is_(True),
)
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),
)
).one_or_none()
)
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:
+44 -3
View File
@@ -18,7 +18,12 @@ from kf_oj.modules.identity.passwords import (
validate_account,
verify_password,
)
from kf_oj.modules.identity.public import AuthenticationFailed, LoginThrottled, Principal
from kf_oj.modules.identity.public import (
AuthenticationFailed,
LoginThrottled,
MaintenanceDenied,
Principal,
)
TOKEN = re.compile(r"[0-9a-f]{64}\Z")
@@ -107,7 +112,7 @@ class IdentityService:
expires_at=expires,
)
)
principal = Principal(account.id, account.username)
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:
@@ -123,7 +128,43 @@ class IdentityService:
record.csrf_hash, token_digest(csrf_token)
):
raise AuthenticationFailed("invalid request token")
return Principal(account.id, account.username)
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)
+155
View File
@@ -0,0 +1,155 @@
"""Real trusted control-plane identity checks; no grading/security VM claim."""
import asyncio
import secrets
from dataclasses import replace
import httpx
import pytest
from conftest import connect
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from test_identity import restrict_platform_role
from kf_oj.api.app import create_app
from kf_oj.contracts.identity import AccountCapabilities
from kf_oj.core.settings import Settings
from kf_oj.modules.identity.public import AuthenticationFailed, MaintenanceDenied, Principal
from kf_oj.modules.identity.service import IdentityService
def test_capability_wire_version_and_default_denial():
assert AccountCapabilities().model_dump() == {
"schema_version": 1,
"problem_maintenance": False,
"authorization_version": "1",
}
for invalid in (
{"schema_version": 2},
{"authorization_version": "0"},
{"authorization_version": 1},
{"role": "admin"},
):
with pytest.raises(ValueError):
AccountCapabilities.model_validate(invalid)
async def set_administrator(connection, account_id, enabled):
async with connection.transaction():
await connection.execute("SET LOCAL ROLE kf_oj_identity_operator")
await connection.execute(
"UPDATE accounts SET is_administrator=$1 WHERE id=$2", enabled, account_id
)
def test_live_http_capabilities_and_old_cookies_after_grant_and_revoke(postgres_url):
async def run():
app = create_app(Settings(_env_file=None, environment="test", database_url=postgres_url))
connection = await connect(postgres_url)
try:
async with app.router.lifespan_context(app):
restrict_platform_role(app.state.sessions.kw["bind"])
password = secrets.token_hex(16)
account = await app.state.identity.create_account("authority_user", password)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000"
) as client:
headers = {"Origin": "http://localhost:8000"}
body = {"username": account.username, "password": password}
ordinary = await client.post("/api/v1/auth/login", json=body, headers=headers)
assert ordinary.status_code == 200
assert ordinary.json()["user"]["capabilities"] == {
"schema_version": 1,
"problem_maintenance": False,
"authorization_version": "1",
}
await set_administrator(connection, account.account_id, True)
assert (await client.get("/api/v1/auth/me")).status_code == 401
fresh = await client.post("/api/v1/auth/login", json=body, headers=headers)
assert fresh.status_code == 200
user = fresh.json()["user"]
assert user["capabilities"]["problem_maintenance"] is True
assert user["capabilities"]["authorization_version"] == "2"
actual = await client.get(
"/api/v1/auth/me", headers={"X-Role": "admin", "X-User-ID": "invalid"}
)
assert actual.json() == user
assert actual.headers["cache-control"] == "no-store"
await set_administrator(connection, account.account_id, False)
assert (await client.get("/api/v1/auth/me")).status_code == 401
refused = await client.post(
"/api/v1/auth/login", json=body | {"role": "admin"}, headers=headers
)
assert refused.status_code == 422
ordinary = await client.post("/api/v1/auth/login", json=body, headers=headers)
assert ordinary.json()["user"]["capabilities"]["problem_maintenance"] is False
assert ordinary.json()["user"]["capabilities"]["authorization_version"] == "3"
finally:
await connection.close()
asyncio.run(run())
def test_write_transaction_rechecks_session_and_serializes_authority_change(postgres_url):
async def run():
engine = create_async_engine(postgres_url)
restrict_platform_role(engine)
sessions = async_sessionmaker(engine, expire_on_commit=False)
identity = IdentityService(sessions)
connection, other = await connect(postgres_url), await connect(postgres_url)
pending = None
try:
password = secrets.token_hex(16)
account = await identity.create_account("authority_user", password)
await set_administrator(connection, account.account_id, True)
issued = await identity.login(account.username, password, peer="authority-fixture")
principal = await identity.authenticate(issued.token)
assert principal.can_maintain_problems and principal.authorization_version == 2
assert principal.session_token_hash not in repr(principal)
async with sessions.begin() as session:
await identity.require_problem_maintenance(session, principal)
pending = asyncio.create_task(set_administrator(other, account.account_id, False))
await asyncio.sleep(0.1)
assert not pending.done() # Real PG SHARE lock holds through the write transaction.
await asyncio.wait_for(pending, timeout=5)
with pytest.raises(AuthenticationFailed):
await identity.authenticate(issued.token)
async with sessions.begin() as session:
with pytest.raises(MaintenanceDenied):
await identity.require_problem_maintenance(session, principal)
await set_administrator(connection, account.account_id, True)
issued = await identity.login(account.username, password, peer="authority-fresh")
principal = await identity.authenticate(issued.token)
async with sessions.begin() as session:
for forged in (
Principal(account.account_id, account.username, True),
replace(principal, session_token_hash="0" * 64),
replace(principal, authorization_version=1),
):
with pytest.raises(MaintenanceDenied):
await identity.require_problem_maintenance(session, forged)
await identity.logout(issued.token, issued.csrf_token)
async with sessions.begin() as session:
with pytest.raises(MaintenanceDenied):
await identity.require_problem_maintenance(session, principal)
issued = await identity.login(account.username, password, peer="authority-expiry")
principal = await identity.authenticate(issued.token)
# Use actual wall-clock expiry even inside an already-open transaction.
async with sessions.begin() as session:
await session.connection()
await connection.execute(
"UPDATE sessions SET expires_at=clock_timestamp()+interval '50 milliseconds' "
"WHERE token_hash=$1",
principal.session_token_hash,
)
await asyncio.sleep(0.1)
with pytest.raises(MaintenanceDenied):
await identity.require_problem_maintenance(session, principal)
finally:
if pending is not None and not pending.done():
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await connection.close()
await other.close()
await engine.dispose()
asyncio.run(run())
+27
View File
@@ -1,6 +1,30 @@
{
"components": {
"schemas": {
"AccountCapabilities": {
"additionalProperties": false,
"properties": {
"authorization_version": {
"default": "1",
"pattern": "^[1-9][0-9]{0,18}$",
"title": "Authorization Version",
"type": "string"
},
"problem_maintenance": {
"default": false,
"title": "Problem Maintenance",
"type": "boolean"
},
"schema_version": {
"const": 1,
"default": 1,
"title": "Schema Version",
"type": "integer"
}
},
"title": "AccountCapabilities",
"type": "object"
},
"ArtifactReference": {
"additionalProperties": false,
"properties": {
@@ -98,6 +122,9 @@
"CurrentUser": {
"additionalProperties": false,
"properties": {
"capabilities": {
"$ref": "#/components/schemas/AccountCapabilities"
},
"id": {
"format": "uuid",
"title": "Id",
+20
View File
@@ -233,6 +233,25 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AccountCapabilities */
AccountCapabilities: {
/**
* Authorization Version
* @default 1
*/
authorization_version: string;
/**
* Problem Maintenance
* @default false
*/
problem_maintenance: boolean;
/**
* Schema Version
* @default 1
* @constant
*/
schema_version: 1;
};
/** ArtifactReference */
ArtifactReference: {
/** Bytes */
@@ -282,6 +301,7 @@ export interface components {
};
/** CurrentUser */
CurrentUser: {
capabilities?: components["schemas"]["AccountCapabilities"];
/**
* Id
* Format: uuid