Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ 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 推进,但不以数据库通过替代模型或生产验收。
Expand Down
36 changes: 36 additions & 0 deletions docs/08-delivery/09-i01c-acceptance.md
Original file line number Diff line number Diff line change
@@ -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 <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_<uuid>` 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 层已调用这些函数,先停止新路由并保留审计记录,再回退代码。不得删除会话或审计历史。
140 changes: 140 additions & 0 deletions packages/domain/security.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading