From 767541988688446ed61657e6b68b6baa1612dd18 Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Wed, 30 Sep 2026 12:10:31 +0800 Subject: [PATCH] feat(i-01c): add session rbac idempotency transaction guards Implement transaction-bound sessions, CSRF and epoch revocation, tenant-scoped RBAC, audit-safe idempotency leases with owner fencing, and last-admin protection. Add isolated MySQL and pure authorization tests plus I-01C acceptance documentation. --- DEVELOPMENT.md | 2 +- docs/08-delivery/09-i01c-acceptance.md | 36 ++ packages/domain/security.py | 140 ++++++ packages/persistence/security.py | 589 +++++++++++++++++++++++ packages/persistence/transaction.py | 28 ++ tests/persistence/test_security_mysql.py | 194 ++++++++ tests/security/__init__.py | 0 tests/security/test_authorization.py | 61 +++ 8 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 docs/08-delivery/09-i01c-acceptance.md create mode 100644 packages/domain/security.py create mode 100644 packages/persistence/security.py create mode 100644 packages/persistence/transaction.py create mode 100644 tests/persistence/test_security_mysql.py create mode 100644 tests/security/__init__.py create mode 100644 tests/security/test_authorization.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 919200a..24ec0ff 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -197,7 +197,7 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 -I-01B 的实际结果与后续边界见 [验收记录](docs/08-delivery/08-i01b-acceptance.md)。 +I-01B 的实际结果与后续边界见 [验收记录](docs/08-delivery/08-i01b-acceptance.md)。I-01C 会话、RBAC、幂等与租户事务实现及验收范围见 [I-01C 验收记录](docs/08-delivery/09-i01c-acceptance.md)。 I-01A PR #5 已合并;I-01B 在 wsq/i-01b-database-foundation 上通过 PR #7 提交,目标为最新 main。数据库/安全变更仍需按 CONTRIBUTING 独立评审;不直接推送 main。 PR #7 的远程 CI 必须运行其自身的 persistence-mysql、业务、AI、设计和 Web 检查,不能用 PR #5 的成绩代替。 下一阶段 I-01C 是仓储、会话/RBAC、幂等与租户事务;真实模型主线可按 I-ML-01 推进,但不以数据库通过替代模型或生产验收。 diff --git a/docs/08-delivery/09-i01c-acceptance.md b/docs/08-delivery/09-i01c-acceptance.md new file mode 100644 index 0000000..e1e83c9 --- /dev/null +++ b/docs/08-delivery/09-i01c-acceptance.md @@ -0,0 +1,36 @@ +# I-01C:会话、RBAC、幂等与租户事务验收 + +状态:实现分支已完成本地验证,等待独立协作者审查;不得据此直接合并或部署。 + +## 实现边界 + +- `packages/domain/security.py`:纯 RBAC、实验室作用域、assignee/recheck/rule-approve 约束和 `expected_version` 乐观锁。 +- `packages/persistence/security.py`:Argon2id 登录、数据库只存 token/CSRF 哈希、8 小时绝对会话、30 分钟 idle、session epoch、logout、审计、30 秒幂等租约、过期接管、响应重放和 lease-owner fencing。 +- `packages/persistence/transaction.py`:显式事务要求、UTC 毫秒时间和租户行锁。 +- `UserSecurityService`:禁用用户/撤销 `safety_admin` 时锁租户、锁用户、保护最后一名 active `safety_admin`,递增 `session_epoch`、撤销全部会话并在同一事务追加审计。 + +路由、Origin/JSON 校验、Redis 限流、完整用户角色授予 API、业务仓储和 I-02 状态机不在本 PR 内;路由只能通过 application service 调用上述原语,不能复制 SQL 或授权逻辑。 + +## 关闭证据 + +| 项目 | 命令 | 结果 | +|---|---|---| +| Python 3.11 语法、Ruff | `python -m py_compile ...`; `python -m ruff check apps packages tests tools/database`; `python -m ruff format --check apps packages tests tools/database` | 通过 | +| I-01C 纯安全测试 | `python -m pytest tests/security -q` | 7 passed | +| 原有业务/协议/AI 回归 | 三组 `unittest discover` | 6 + 4 + 15 passed | +| 隔离 MySQL | `tools/database/run_mysql_tests.py --mysqld ` | 50 passed;MySQL 8.0.33;43 tables/115 FKs 无漂移 | +| 设计规格 | `build_specs.py --check`; `test_readiness_design.py` | 16 artifacts、148 checks 通过 | + +MySQL 命令只创建本次运行的临时 data directory、随机 loopback 端口和 `labsafe_test_` schema,结束后关闭并删除该实例;没有连接用户已有 MySQL。 + +## 独立审查前置项 + +1. 审查者核对 `main`/I-01B 基线后再审查本分支;确认权限、安全和数据库变更。 +2. 补充 API 层的固定 cookie(`labsafe_session`、Secure、HttpOnly、SameSite=Lax、Path=/)、Origin、CSRF、限流和稳定错误映射。 +3. 将所有写命令接入同一 unit-of-work:幂等行 → 业务聚合锁 → 业务写入 → 审计/Outbox → 幂等响应,统一提交或回滚。 +4. 完成角色授予/撤销和 `/me`、`/auth/login`、`/auth/logout` 路由契约测试;撤权前再次授权,重放响应前再次授权。 +5. 安装 `tools/design/requirements.txt` 中的 `openapi-spec-validator==0.7.2` 后执行 `validate_specs.py`;缺依赖不能算契约验证通过。 + +## 回滚 + +本 PR 不新增迁移。回滚为回退本特性分支提交;若后续 application 层已调用这些函数,先停止新路由并保留审计记录,再回退代码。不得删除会话或审计历史。 diff --git a/packages/domain/security.py b/packages/domain/security.py new file mode 100644 index 0000000..7e0c3b8 --- /dev/null +++ b/packages/domain/security.py @@ -0,0 +1,140 @@ +"""Pure authorization and optimistic guards; no HTTP, SQL, or client tenant IDs.""" + +from dataclasses import dataclass +from enum import StrEnum + + +class ServiceError(ValueError): + def __init__(self, code: str, status: int, message: str, *, retry_after: int | None = None): + super().__init__(message) + self.code = code + self.status = status + self.retry_after = retry_after + + +def not_found() -> ServiceError: + return ServiceError("NOT_FOUND", 404, "Not found") + + +class Permission(StrEnum): + READ = "read" + ADMIN = "admin" + CAPTURE = "capture" + REVIEW = "review" + DISPATCH = "dispatch" + ASSIGNEE = "assignee" + RECHECK = "recheck" + RULE_APPROVE = "rule_approve" + EXPORT = "export" + AUDIT = "audit" + RECIPIENT = "recipient" + + +ROLE_PERMISSIONS = { + "safety_admin": frozenset( + { + Permission.READ, + Permission.ADMIN, + Permission.CAPTURE, + Permission.REVIEW, + Permission.DISPATCH, + Permission.RECHECK, + Permission.EXPORT, + Permission.AUDIT, + } + ), + "lab_manager": frozenset({Permission.READ, Permission.DISPATCH, Permission.EXPORT}), + "inspector": frozenset( + {Permission.READ, Permission.CAPTURE, Permission.REVIEW, Permission.RECHECK} + ), + "remediator": frozenset({Permission.READ, Permission.ASSIGNEE}), + "viewer": frozenset({Permission.READ}), + "rule_expert": frozenset({Permission.READ, Permission.RULE_APPROVE}), +} +ROLE_SCOPES = { + "safety_admin": "tenant", + "rule_expert": "tenant", + "lab_manager": "laboratory", + "inspector": "laboratory", + "remediator": "laboratory", + "viewer": "laboratory", +} + + +@dataclass(frozen=True) +class Principal: + user_id: str + tenant_id: str + username: str + roles: tuple[tuple[str, str, str | None], ...] + + +def valid_grant(role: str, scope: str, lab: str | None) -> bool: + return ROLE_SCOPES.get(role) == scope and ( + (scope == "tenant" and lab is None) or (scope == "laboratory" and bool(lab)) + ) + + +def visible_laboratories(principal: Principal) -> frozenset[str] | None: + """None means all labs in this tenant, never all tenants.""" + labs = set() + for role, scope, lab in principal.roles: + if not valid_grant(role, scope, lab): + continue + if role == "safety_admin": + return None + if scope == "laboratory": + labs.add(lab) + return frozenset(labs) + + +def authorize( + principal: Principal, + permission: Permission | str, + laboratory_id: str | None = None, + *, + assignee_id: str | None = None, + submitted_by: str | None = None, + creator_id: str | None = None, + recipient_id: str | None = None, + published: bool = False, +) -> None: + permission = Permission(permission) + grants = tuple(g for g in principal.roles if valid_grant(*g)) + if permission == Permission.RECIPIENT: + if recipient_id == principal.user_id: + return + raise not_found() + if laboratory_id is not None: + labs = visible_laboratories(principal) + if labs is not None and laboratory_id not in labs: + raise not_found() + for role, scope, lab in grants: + if permission not in ROLE_PERMISSIONS[role]: + continue + if permission == Permission.READ and published and laboratory_id is None: + return # Caller must supply only a non-sensitive published projection. + if scope == "laboratory" and (laboratory_id is None or lab != laboratory_id): + continue + if role == "rule_expert" and permission == Permission.READ: + continue + if permission == Permission.ASSIGNEE and assignee_id != principal.user_id: + continue + if permission == Permission.RULE_APPROVE and ( + submitted_by is None or submitted_by == principal.user_id + ): + continue + if permission == Permission.RECHECK and principal.user_id in (assignee_id, submitted_by): + continue + if permission == Permission.CAPTURE and creator_id is not None: + if role != "safety_admin" and creator_id != principal.user_id: + continue + return + raise ServiceError("FORBIDDEN", 403, "Forbidden") + + +def require_version(actual: int, expected: int) -> None: + if type(expected) is not int or not 1 <= expected <= 2147483647: + raise ServiceError("VALIDATION_ERROR", 422, "Invalid expected_version") + if actual != expected: + raise ServiceError("VERSION_CONFLICT", 409, "Refresh the resource before retrying") diff --git a/packages/persistence/security.py b/packages/persistence/security.py new file mode 100644 index 0000000..00e77ad --- /dev/null +++ b/packages/persistence/security.py @@ -0,0 +1,589 @@ +"""Transaction-bound sessions and append-only audit; no public auth routes yet. + +The API adapter must also enforce Origin/JSON, rate limiting and cookie options. +Application commands own commit/rollback. SQL never trusts a client tenant ID. +""" + +import base64 +import hashlib +import hmac +import json +import re +import secrets +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any +from uuid import uuid4 + +from argon2.exceptions import InvalidHashError, VerificationError +from sqlalchemy import text +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError + +from packages.domain.security import Permission, Principal, ServiceError, authorize +from packages.persistence.bootstrap import PASSWORD_HASHER, normalized +from packages.persistence.database import DatabaseSafetyError +from packages.persistence.transaction import lock_tenant, require_transaction, utc_now +from packages.shared.json_hash import canonical_json + +SESSION_COOKIE = "labsafe_session" +SESSION_COOKIE_OPTIONS = {"secure": True, "httponly": True, "samesite": "lax", "path": "/"} +SESSION_ABSOLUTE = timedelta(hours=8) +SESSION_IDLE = timedelta(minutes=30) +TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_-]{43}") +AuthError = ServiceError + + +def authentication_required() -> ServiceError: + return ServiceError("AUTHENTICATION_REQUIRED", 401, "Authentication required") + + +def token_hash(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def safe_text(value: str, limit: int) -> str: + try: + if not isinstance(value, str): + raise ValueError + return normalized(value, limit) + except (ValueError, TypeError): + raise ServiceError("VALIDATION_ERROR", 422, "Invalid text") from None + + +@dataclass(frozen=True) +class AuthenticatedSession: + session_id: str + token: str = field(repr=False) + csrf_token: str = field(repr=False) + principal: Principal + expires_at: datetime + idle_expires_at: datetime + + +@dataclass(frozen=True) +class IdempotencyClaim: + record_id: str + state: str + response_status: int | None = None + response: Any = None + lease_owner: str | None = None + + +def load_principal(connection: Connection, tenant_id: str, user_id: str) -> Principal: + # A locking/current read avoids old REPEATABLE READ snapshots after lock waits. + rows = ( + connection.execute( + text( + "SELECT u.id,u.tenant_id,u.username,r.code,ur.scope_kind,ur.laboratory_id " + "FROM users u LEFT JOIN user_roles ur ON ur.tenant_id=u.tenant_id " + "AND ur.user_id=u.id " + "LEFT JOIN roles r ON r.id=ur.role_id " + "WHERE u.tenant_id=:tenant AND u.id=:user AND u.status='active' FOR SHARE" + ), + {"tenant": tenant_id, "user": user_id}, + ) + .mappings() + .all() + ) + if not rows: + raise authentication_required() + return Principal( + user_id, + tenant_id, + rows[0]["username"], + tuple( + (row["code"], row["scope_kind"], row["laboratory_id"]) + for row in rows + if row["code"] is not None + ), + ) + + +class SessionService: + def __init__(self, csrf_key: bytes): + if not isinstance(csrf_key, bytes) or len(csrf_key) < 32: + raise DatabaseSafetyError("CSRF key must contain at least 32 random bytes") + self._csrf_key = csrf_key + self._dummy_hash = PASSWORD_HASHER.hash(secrets.token_urlsafe(32)) + + def _csrf(self, token: str) -> str: + digest = hmac.new(self._csrf_key, token.encode("ascii"), hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + def login( + self, connection: Connection, *, tenant_code: str, username: str, password: str + ) -> AuthenticatedSession: + require_transaction(connection) + failure = ServiceError("INVALID_CREDENTIALS", 401, "账号或密码错误") + try: + tenant_code = normalized(tenant_code, 64) + username = normalized(username, 128, account=True) + except (ValueError, TypeError): + raise failure from None + if not isinstance(password, str) or not 12 <= len(password) <= 128: + raise failure + tenant = connection.scalar( + text("SELECT id FROM tenants WHERE code=:code"), {"code": tenant_code} + ) + row = None + if tenant: + try: + lock_tenant(connection, tenant) + except ServiceError: + raise failure from None + row = ( + connection.execute( + text( + "SELECT id,password_hash,status,session_epoch FROM users " + "WHERE tenant_id=:tenant AND username=:username FOR SHARE" + ), + {"tenant": tenant, "username": username}, + ) + .mappings() + .first() + ) + encoded = row["password_hash"] if row and row["status"] == "active" else self._dummy_hash + try: + valid = PASSWORD_HASHER.verify(encoded, password) + except (VerificationError, InvalidHashError, TypeError): + valid = False + if not valid or not row or row["status"] != "active": + raise failure + principal = load_principal(connection, tenant, row["id"]) + now = utc_now() + token = secrets.token_urlsafe(32) + csrf = self._csrf(token) + session_id = str(uuid4()) + connection.execute( + text( + "INSERT INTO sessions (id,tenant_id,user_id,token_hash,csrf_hash,session_epoch," + "expires_at,idle_expires_at) VALUES " + "(:id,:tenant,:user,:token,:csrf,:epoch,:end,:idle)" + ), + { + "id": session_id, + "tenant": tenant, + "user": row["id"], + "token": token_hash(token), + "csrf": token_hash(csrf), + "epoch": row["session_epoch"], + "end": now + SESSION_ABSOLUTE, + "idle": now + SESSION_IDLE, + }, + ) + return AuthenticatedSession( + session_id, token, csrf, principal, now + SESSION_ABSOLUTE, now + SESSION_IDLE + ) + + def locate(self, connection: Connection, token: str) -> tuple[str, str]: + """Find only the idempotency scope before row locking; NOT authorization.""" + if not isinstance(token, str) or not TOKEN_PATTERN.fullmatch(token): + raise authentication_required() + row = connection.execute( + text("SELECT tenant_id,user_id FROM sessions WHERE token_hash=:hash"), + {"hash": token_hash(token)}, + ).first() + if not row: + raise authentication_required() + return row[0], row[1] + + def authenticate( + self, + connection: Connection, + token: str, + *, + supplied_csrf: str | None = None, + touch: bool = True, + exclusive: bool = False, + ) -> AuthenticatedSession: + require_transaction(connection) + tenant, user = self.locate(connection, token) + lock_tenant(connection, tenant, exclusive=exclusive) + principal = load_principal(connection, tenant, user) + # Tenant -> user/roles -> session, also for login and administrative revocation. + row = ( + connection.execute( + text( + "SELECT s.id,s.csrf_hash,s.expires_at,s.idle_expires_at,s.revoked_at," + "s.session_epoch,u.session_epoch AS current_epoch FROM sessions s JOIN users u " + "ON u.tenant_id=s.tenant_id AND u.id=s.user_id " + "WHERE s.tenant_id=:tenant AND s.user_id=:user " + "AND s.token_hash=:hash FOR UPDATE" + ), + {"tenant": tenant, "user": user, "hash": token_hash(token)}, + ) + .mappings() + .first() + ) + now = utc_now() + if ( + not row + or row["revoked_at"] is not None + or row["session_epoch"] != row["current_epoch"] + or row["expires_at"] <= now + or row["idle_expires_at"] <= now + ): + raise authentication_required() + csrf = self._csrf(token) + if not hmac.compare_digest(row["csrf_hash"], token_hash(csrf)): + raise authentication_required() + session = AuthenticatedSession( + row["id"], token, csrf, principal, row["expires_at"], row["idle_expires_at"] + ) + if supplied_csrf is not None: + self.require_csrf(session, supplied_csrf) + if touch: + idle = min(row["expires_at"], now + SESSION_IDLE) + connection.execute( + text( + "UPDATE sessions SET idle_expires_at=GREATEST(idle_expires_at,:idle)," + "updated_at=:now WHERE tenant_id=:tenant AND id=:id" + ), + {"idle": idle, "now": now, "tenant": tenant, "id": row["id"]}, + ) + session = AuthenticatedSession( + row["id"], + token, + csrf, + principal, + row["expires_at"], + max(idle, row["idle_expires_at"]), + ) + return session + + @staticmethod + def require_csrf(session: AuthenticatedSession, supplied: str) -> None: + if ( + not isinstance(supplied, str) + or not TOKEN_PATTERN.fullmatch(supplied) + or not hmac.compare_digest(session.csrf_token, supplied) + ): + raise ServiceError("CSRF_FAILED", 403, "CSRF validation failed") + + def logout(self, connection: Connection, session: AuthenticatedSession) -> None: + require_transaction(connection) + connection.execute( + text( + "UPDATE sessions SET revoked_at=:now,updated_at=:now " + "WHERE tenant_id=:tenant AND user_id=:user AND id=:id AND revoked_at IS NULL" + ), + { + "now": utc_now(), + "tenant": session.principal.tenant_id, + "user": session.principal.user_id, + "id": session.session_id, + }, + ) + + +def revoke_user_sessions(connection: Connection, tenant_id: str, user_id: str) -> None: + require_transaction(connection) + connection.execute( + text( + "UPDATE sessions SET revoked_at=:now,updated_at=:now " + "WHERE tenant_id=:tenant AND user_id=:user AND revoked_at IS NULL" + ), + {"now": utc_now(), "tenant": tenant_id, "user": user_id}, + ) + + +def _safe_audit(value: Any) -> Any: + forbidden = re.compile(r"password|passwd|token|secret|authorization|hash|cookie", re.I) + if isinstance(value, dict): + if any(not isinstance(key, str) or forbidden.search(key) for key in value): + raise ServiceError("VALIDATION_ERROR", 422, "Forbidden audit field") + return {key: _safe_audit(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_safe_audit(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + if isinstance(value, str) and len(value) > 4000: + raise ServiceError("VALIDATION_ERROR", 422, "Audit value too long") + return value + raise ServiceError("VALIDATION_ERROR", 422, "Invalid audit value") + + +def append_audit( + connection: Connection, + *, + principal: Principal, + action: str, + resource_type: str, + resource_id: str, + changes: Any, + reason: str, + request_id: str, +) -> str: + require_transaction(connection) + event_id = str(uuid4()) + values = { + "id": event_id, + "tenant": principal.tenant_id, + "actor": principal.user_id, + "action": safe_text(action, 100), + "rtype": safe_text(resource_type, 80), + "resource": resource_id, + "changes": canonical_json(_safe_audit(changes)), + "reason": safe_text(reason, 2000), + "request": request_id, + } + connection.execute( + text( + "INSERT INTO audit_events (id,tenant_id,actor_id,action,resource_type,resource_id," + "changes,reason,request_id) VALUES " + "(:id,:tenant,:actor,:action,:rtype,:resource,:changes,:reason,:request)" + ), + values, + ) + return event_id + + +def begin_idempotency( + connection: Connection, + *, + tenant_id: str, + actor_id: str, + method: str, + path: str, + key: str, + request_hash: str, + expires_at: datetime, + lease_owner: str, +) -> IdempotencyClaim: + require_transaction(connection) + key = safe_text(key, 128) + if len(key) < 8 or not re.fullmatch(r"[0-9a-f]{64}", request_hash): + raise ServiceError("VALIDATION_ERROR", 422, "Invalid idempotency input") + method = safe_text(method, 10).upper() + path_hash = hashlib.sha256(safe_text(path, 512).encode()).hexdigest() + key_hash = hashlib.sha256(key.encode()).hexdigest() + now = utc_now() + try: + with connection.begin_nested(): + record_id = str(uuid4()) + connection.execute( + text( + "INSERT INTO api_idempotency (id,tenant_id,actor_id,method,path_hash,key_hash," + "request_hash,state,expires_at,lease_owner,lease_until) VALUES " + "(:id,:tenant,:actor,:method,:path,:key,:request,'pending',:expires,:owner,:lease)" + ), + { + "id": record_id, + "tenant": tenant_id, + "actor": actor_id, + "method": method, + "path": path_hash, + "key": key_hash, + "request": request_hash, + "expires": expires_at, + "owner": lease_owner, + "lease": now + timedelta(seconds=30), + }, + ) + return IdempotencyClaim(record_id, "pending", lease_owner=lease_owner) + except IntegrityError as error: + errno = getattr(getattr(error, "orig", None), "args", [None])[0] + if errno not in (1062, "1062"): + raise + row = ( + connection.execute( + text( + "SELECT id,state,request_hash,response_status,response,lease_owner,lease_until " + "FROM api_idempotency WHERE tenant_id=:tenant AND actor_id=:actor " + "AND method=:method " + "AND path_hash=:path AND key_hash=:key AND expires_at>:now" + ), + { + "tenant": tenant_id, + "actor": actor_id, + "method": method, + "path": path_hash, + "key": key_hash, + "now": now, + }, + ) + .mappings() + .first() + ) + if not row: + raise ServiceError( + "IDEMPOTENCY_EXPIRED", 409, "Idempotency claim expired; retry with a new key" + ) + if not hmac.compare_digest(row["request_hash"], request_hash): + raise ServiceError( + "IDEMPOTENCY_CONFLICT", 409, "Idempotency-Key was reused with a different request" + ) + response = row["response"] + if isinstance(response, str): + response = json.loads(response) + if row["state"] == "pending": + if row["lease_until"] is not None and row["lease_until"] <= now: + updated = connection.execute( + text( + "UPDATE api_idempotency SET lease_owner=:owner,lease_until=:lease " + "WHERE id=:id AND state='pending' AND lease_until<=:now" + ), + { + "owner": lease_owner, + "lease": now + timedelta(seconds=30), + "id": row["id"], + "now": now, + }, + ).rowcount + if updated == 1: + return IdempotencyClaim(row["id"], "pending", lease_owner=lease_owner) + return IdempotencyClaim(row["id"], "pending", lease_owner=row["lease_owner"]) + return IdempotencyClaim(row["id"], row["state"], row["response_status"], response) + + +def complete_idempotency( + connection: Connection, + claim: IdempotencyClaim, + *, + status: int, + response: Any, + lease_owner: str | None = None, +) -> None: + require_transaction(connection) + owner = lease_owner or claim.lease_owner + if not owner: + raise ServiceError("REQUEST_IN_PROGRESS", 409, "Idempotency lease owner is required") + result = connection.execute( + text( + "UPDATE api_idempotency SET state='completed',response_status=:status," + "response=:response,lease_owner=NULL,lease_until=NULL " + "WHERE id=:id AND state='pending' AND lease_owner=:owner" + ), + { + "status": status, + "response": json.dumps(response, ensure_ascii=False, separators=(",", ":")), + "id": claim.record_id, + "owner": owner, + }, + ) + if result.rowcount != 1: + raise ServiceError("REQUEST_IN_PROGRESS", 409, "Idempotency lease lost") + + +class UserSecurityService: + """Role/status mutations. Caller must commit or roll back this transaction.""" + + @staticmethod + def _lock_user(connection: Connection, tenant_id: str, user_id: str) -> dict[str, Any]: + row = ( + connection.execute( + text("SELECT id,status FROM users WHERE tenant_id=:tenant AND id=:user FOR UPDATE"), + {"tenant": tenant_id, "user": user_id}, + ) + .mappings() + .first() + ) + if not row: + raise ServiceError("NOT_FOUND", 404, "Not found") + return dict(row) + + @staticmethod + def _last_admin_guard(connection: Connection, tenant_id: str, user_id: str) -> None: + active = connection.scalar( + text( + "SELECT COUNT(*) FROM users u JOIN user_roles ur ON ur.tenant_id=u.tenant_id " + "AND ur.user_id=u.id JOIN roles r ON r.id=ur.role_id WHERE u.tenant_id=:tenant " + "AND u.status='active' AND r.code='safety_admin' AND ur.scope_kind='tenant'" + ), + {"tenant": tenant_id}, + ) + mine = connection.scalar( + text( + "SELECT COUNT(*) FROM user_roles ur JOIN roles r ON r.id=ur.role_id " + "WHERE ur.tenant_id=:tenant AND ur.user_id=:user AND r.code='safety_admin' " + "AND ur.scope_kind='tenant'" + ), + {"tenant": tenant_id, "user": user_id}, + ) + if mine and active <= 1: + raise ServiceError("LAST_ADMIN", 409, "Cannot remove the last safety_admin") + + def disable_user( + self, + connection: Connection, + *, + tenant_id: str, + user_id: str, + actor: Principal, + request_id: str, + reason: str, + ) -> None: + require_transaction(connection) + if actor.tenant_id != tenant_id: + raise ServiceError("NOT_FOUND", 404, "Not found") + authorize(actor, Permission.ADMIN) + lock_tenant(connection, tenant_id, exclusive=True) + user = self._lock_user(connection, tenant_id, user_id) + if user["status"] == "active": + self._last_admin_guard(connection, tenant_id, user_id) + connection.execute( + text( + "UPDATE users SET status='disabled',session_epoch=session_epoch+1," + "version=version+1," + "updated_at=:now WHERE tenant_id=:tenant AND id=:user" + ), + {"now": utc_now(), "tenant": tenant_id, "user": user_id}, + ) + revoke_user_sessions(connection, tenant_id, user_id) + append_audit( + connection, + principal=actor, + action="user.disable", + resource_type="user", + resource_id=user_id, + changes={"status": "disabled"}, + reason=reason, + request_id=request_id, + ) + + def revoke_safety_admin( + self, + connection: Connection, + *, + tenant_id: str, + user_id: str, + role_id: str, + actor: Principal, + request_id: str, + reason: str, + ) -> None: + require_transaction(connection) + if actor.tenant_id != tenant_id: + raise ServiceError("NOT_FOUND", 404, "Not found") + authorize(actor, Permission.ADMIN) + lock_tenant(connection, tenant_id, exclusive=True) + self._lock_user(connection, tenant_id, user_id) + self._last_admin_guard(connection, tenant_id, user_id) + deleted = connection.execute( + text( + "DELETE ur FROM user_roles ur JOIN roles r ON r.id=ur.role_id " + "WHERE ur.tenant_id=:tenant " + "AND ur.user_id=:user AND ur.role_id=:role AND r.code='safety_admin'" + ), + {"tenant": tenant_id, "user": user_id, "role": role_id}, + ).rowcount + if deleted != 1: + raise ServiceError("NOT_FOUND", 404, "Not found") + connection.execute( + text( + "UPDATE users SET session_epoch=session_epoch+1,version=version+1,updated_at=:now " + "WHERE tenant_id=:tenant AND id=:user" + ), + {"now": utc_now(), "tenant": tenant_id, "user": user_id}, + ) + revoke_user_sessions(connection, tenant_id, user_id) + append_audit( + connection, + principal=actor, + action="role.revoke", + resource_type="user_role", + resource_id=user_id, + changes={"role": "safety_admin"}, + reason=reason, + request_id=request_id, + ) diff --git a/packages/persistence/transaction.py b/packages/persistence/transaction.py new file mode 100644 index 0000000..b042791 --- /dev/null +++ b/packages/persistence/transaction.py @@ -0,0 +1,28 @@ +"""Explicit transaction and tenant-lock primitives for application services.""" + +from datetime import datetime, timezone + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +from packages.domain.security import ServiceError + + +def utc_now() -> datetime: + now = datetime.now(timezone.utc).replace(tzinfo=None) + return now.replace(microsecond=(now.microsecond // 1000) * 1000) + + +def require_transaction(connection: Connection) -> None: + if not connection.in_transaction(): + raise RuntimeError("An explicit application transaction is required") + + +def lock_tenant(connection: Connection, tenant_id: str, *, exclusive: bool = False) -> None: + require_transaction(connection) + lock = "FOR UPDATE" if exclusive else "FOR SHARE" + status = connection.scalar( + text(f"SELECT status FROM tenants WHERE id=:tenant {lock}"), {"tenant": tenant_id} + ) + if status != "active": + raise ServiceError("AUTHENTICATION_REQUIRED", 401, "Authentication required") diff --git a/tests/persistence/test_security_mysql.py b/tests/persistence/test_security_mysql.py new file mode 100644 index 0000000..f5883ec --- /dev/null +++ b/tests/persistence/test_security_mysql.py @@ -0,0 +1,194 @@ +import hashlib +from datetime import timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import text + +from packages.domain.security import Principal, ServiceError +from packages.persistence.bootstrap import TenantInput, bootstrap_tenant +from packages.persistence.security import ( + SESSION_ABSOLUTE, + SESSION_IDLE, + SessionService, + UserSecurityService, + begin_idempotency, + complete_idempotency, + revoke_user_sessions, + utc_now, +) + +PASSWORD = "I-01C-security-password" + + +def create_tenant(engine, suffix: str): + data = TenantInput( + f"sec_{suffix}_{uuid4().hex[:8]}", + "安全测试机构", + "Asia/Shanghai", + "Admin", + "管理员", + "I-01C security test", + ) + result = bootstrap_tenant(engine, data, PASSWORD) + result["code"] = data.code + return result + + +def principal_for(connection, tenant_id, user_id): + row = connection.execute( + text("SELECT username FROM users WHERE tenant_id=:tenant AND id=:user"), + {"tenant": tenant_id, "user": user_id}, + ).scalar_one() + return Principal(user_id, tenant_id, row, (("safety_admin", "tenant", None),)) + + +def test_session_login_csrf_logout_and_epoch_revocation(database): + result = create_tenant(database, "session") + service = SessionService(b"c" * 32) + with database.connect() as connection: + with connection.begin(): + session = service.login( + connection, tenant_code=result["code"], username="ADMIN", password=PASSWORD + ) + # The generated code is not known from the helper; use the actual tenant lookup. + assert session.principal.user_id == result["admin_id"] + authenticated = service.authenticate( + connection, session.token, supplied_csrf=session.csrf_token + ) + assert authenticated.principal.tenant_id == result["tenant_id"] + with pytest.raises(ServiceError) as error: + service.authenticate(connection, session.token, supplied_csrf="A" * 43) + assert error.value.status == 403 + service.logout(connection, session) + with pytest.raises(ServiceError) as error: + service.authenticate(connection, session.token, touch=False) + assert error.value.status == 401 + + with database.connect() as connection: + with connection.begin(): + session = service.login( + connection, + tenant_code=connection.scalar( + text("SELECT code FROM tenants WHERE id=:id"), {"id": result["tenant_id"]} + ), + username="admin", + password=PASSWORD, + ) + connection.execute( + text( + "UPDATE users SET session_epoch=session_epoch+1 " + "WHERE tenant_id=:tenant AND id=:user" + ), + {"tenant": result["tenant_id"], "user": result["admin_id"]}, + ) + revoke_user_sessions(connection, result["tenant_id"], result["admin_id"]) + with pytest.raises(ServiceError): + service.authenticate(connection, session.token, touch=False) + assert SESSION_ABSOLUTE > SESSION_IDLE + + +def test_idempotency_replay_conflict_and_lease_fencing(database): + result = create_tenant(database, "idem") + request_hash = hashlib.sha256(b"canonical-request").hexdigest() + with database.connect() as connection: + with connection.begin(): + now = utc_now() + first = begin_idempotency( + connection, + tenant_id=result["tenant_id"], + actor_id=result["admin_id"], + method="POST", + path="/api/v1/x", + key="idem-key-1", + request_hash=request_hash, + expires_at=now + timedelta(hours=24), + lease_owner="owner-a", + ) + complete_idempotency(connection, first, status=201, response={"ok": True}) + replay = begin_idempotency( + connection, + tenant_id=result["tenant_id"], + actor_id=result["admin_id"], + method="POST", + path="/api/v1/x", + key="idem-key-1", + request_hash=request_hash, + expires_at=now + timedelta(hours=24), + lease_owner="owner-b", + ) + assert replay.state == "completed" and replay.response == {"ok": True} + with pytest.raises(ServiceError) as error: + begin_idempotency( + connection, + tenant_id=result["tenant_id"], + actor_id=result["admin_id"], + method="POST", + path="/api/v1/x", + key="idem-key-1", + request_hash=hashlib.sha256(b"different").hexdigest(), + expires_at=now + timedelta(hours=24), + lease_owner="owner-c", + ) + assert error.value.code == "IDEMPOTENCY_CONFLICT" + + pending = begin_idempotency( + connection, + tenant_id=result["tenant_id"], + actor_id=result["admin_id"], + method="POST", + path="/api/v1/pending", + key="idem-key-2", + request_hash=request_hash, + expires_at=now + timedelta(hours=24), + lease_owner="owner-a", + ) + with pytest.raises(ServiceError): + complete_idempotency( + connection, pending, status=200, response={}, lease_owner="owner-old" + ) + connection.execute( + text("UPDATE api_idempotency SET lease_until=:expired WHERE id=:id"), + {"expired": now - timedelta(seconds=1), "id": pending.record_id}, + ) + takeover = begin_idempotency( + connection, + tenant_id=result["tenant_id"], + actor_id=result["admin_id"], + method="POST", + path="/api/v1/pending", + key="idem-key-2", + request_hash=request_hash, + expires_at=now + timedelta(hours=24), + lease_owner="owner-new", + ) + complete_idempotency(connection, takeover, status=200, response={"taken": True}) + + +def test_last_admin_and_cross_tenant_protection(database): + one = create_tenant(database, "adminone") + two = create_tenant(database, "admintwo") + service = UserSecurityService() + with database.connect() as connection: + with connection.begin(): + actor = principal_for(connection, one["tenant_id"], one["admin_id"]) + with pytest.raises(ServiceError) as error: + service.disable_user( + connection, + tenant_id=one["tenant_id"], + user_id=one["admin_id"], + actor=actor, + request_id=str(uuid4()), + reason="test", + ) + assert error.value.code == "LAST_ADMIN" + with pytest.raises(ServiceError) as error: + service.disable_user( + connection, + tenant_id=two["tenant_id"], + user_id=two["admin_id"], + actor=actor, + request_id=str(uuid4()), + reason="cross tenant", + ) + assert error.value.code == "NOT_FOUND" diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/security/test_authorization.py b/tests/security/test_authorization.py new file mode 100644 index 0000000..a925d3b --- /dev/null +++ b/tests/security/test_authorization.py @@ -0,0 +1,61 @@ +import pytest + +from packages.domain.security import Permission, Principal, ServiceError, authorize, require_version +from packages.persistence.security import SESSION_ABSOLUTE, SESSION_IDLE, utc_now + + +def principal(*roles): + return Principal("u1", "t1", "user", tuple(roles)) + + +def test_laboratory_scope_does_not_cross_laboratories(): + p = principal(("inspector", "laboratory", "lab-a")) + authorize(p, Permission.READ, "lab-a") + with pytest.raises(ServiceError) as error: + authorize(p, Permission.READ, "lab-b") + assert error.value.code == "NOT_FOUND" + + +def test_remediator_must_be_current_assignee(): + p = principal(("remediator", "laboratory", "lab-a")) + with pytest.raises(ServiceError): + authorize(p, Permission.ASSIGNEE, "lab-a", assignee_id="u2") + authorize(p, Permission.ASSIGNEE, "lab-a", assignee_id="u1") + + +def test_rule_expert_cannot_approve_own_submission(): + p = principal(("rule_expert", "tenant", None)) + with pytest.raises(ServiceError): + authorize(p, Permission.RULE_APPROVE, submitted_by="u1") + authorize(p, Permission.RULE_APPROVE, submitted_by="u2") + + +def test_recheck_rejects_assignee_or_submitter(): + p = principal(("inspector", "laboratory", "lab-a")) + with pytest.raises(ServiceError): + authorize(p, Permission.RECHECK, "lab-a", assignee_id="u1") + with pytest.raises(ServiceError): + authorize(p, Permission.RECHECK, "lab-a", submitted_by="u1") + + +def test_unknown_role_and_scope_are_denied(): + with pytest.raises(ServiceError): + authorize(principal(("unknown", "tenant", None)), Permission.READ) + with pytest.raises(ServiceError): + authorize(principal(("inspector", "tenant", None)), Permission.READ) + + +def test_version_guard_is_strict(): + require_version(2, 2) + with pytest.raises(ServiceError) as error: + require_version(2, 1) + assert error.value.code == "VERSION_CONFLICT" + with pytest.raises(ServiceError): + require_version(1, True) + + +def test_time_contract_is_millisecond_utc_and_expiry_windows_are_fixed(): + now = utc_now() + assert now.microsecond % 1000 == 0 + assert SESSION_ABSOLUTE.total_seconds() == 8 * 3600 + assert SESSION_IDLE.total_seconds() == 30 * 60