diff --git a/.env.example b/.env.example index e1af09b..b5ec26e 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,23 @@ API_PORT=8000 AI_INFERENCE_HOST=127.0.0.1 AI_INFERENCE_PORT=8001 REDIS_URL=redis://localhost:6379/0 -wsq/i-01b-database-foundation +# Identity and foundation APIs are opt-in; use local HTTPS with this exact origin. +API_IDENTITY_ENABLED=0 +PUBLIC_ORIGIN=https://labsafe.example.org +CSRF_KEY_FILE=.local-secrets/csrf-key DATABASE_URL_FILE=.local-secrets/database-url DATABASE_SCHEMA=labsafe_dev_local +# Dev/test upload grants, validation acceptance and image metadata only. Requires a private, +# versioned bucket, least-privilege API credentials and same-origin HTTPS:443. No consumer yet. +API_UPLOADS_ENABLED=0 +S3_ENDPOINT=http://127.0.0.1:9000 +S3_PUBLIC_ENDPOINT=https://labsafe.example.org +S3_BUCKET=labsafe-private +S3_REGION=us-east-1 +S3_ACCESS_KEY_FILE=.local-secrets/s3-api-access-key +S3_SECRET_KEY_FILE=.local-secrets/s3-api-secret-key + AI_MODE=mock AI_TOKEN_FILE=.local-secrets/ai-token AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe4dd37..3a62e52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: python -m pip check - run: python -m unittest discover -s tests/business -t . -v - run: python -m unittest discover -s tests/protocol -t . -v + - run: python -m pytest tests/business tests/domain tests/security -q - run: ruff check apps packages tests tools/database - run: ruff format --check apps packages tests tools/database @@ -49,7 +50,7 @@ jobs: - name: AI environment must not install business or model dependencies run: | - python -c "import importlib.util; names=['celery','redis','sqlalchemy','alembic','pymysql','argon2','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" + python -c "import importlib.util; names=['celery','redis','sqlalchemy','alembic','pymysql','argon2','boto3','botocore','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" - run: python -m unittest discover -s tests/ai -t . -v - run: python -m unittest discover -s tests/protocol -t . -v @@ -71,6 +72,13 @@ jobs: persistence-mysql: runs-on: ubuntu-latest services: + redis: + image: redis:7-alpine + ports: + - 6379/tcp + options: >- + --health-cmd="redis-cli ping" + --health-interval=5s --health-timeout=5s --health-retries=20 mysql: image: mysql:8.0.33 env: @@ -96,6 +104,7 @@ jobs: - run: python tools/database/run_mysql_tests.py --ci env: LABSAFE_CI_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} + LABSAFE_CI_REDIS_PORT: ${{ job.services.redis.ports[6379] }} web: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fca0d83..d7826ee 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,5 +1,5 @@ -# LabSafe 开发指南(I-01A / I-01B) +# LabSafe 开发指南(I-01A–I-02F2) ## 1. 本阶段边界 @@ -9,9 +9,16 @@ 已提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护,以及 I-01B 初始数据库迁移、租户初始化和真实 MySQL 测试。 -不包含用户会话、RBAC/仓储、业务 API、持久任务、真实图像处理、训练权重或生产部署。 - -API 的 /ready 只检查 I-01A 进程配置,不代表数据库或完整系统已就绪。 +I-01C 另提供会话、RBAC、幂等与租户事务原语;I-02A 增加 13 个纯领域命令守卫及正反例测试。 +I-02B 接通 7 个认证/用户 HTTP API,以及对应应用事务、用户仓储、Redis 限流和真实 MySQL 并发验证。 +I-02C 接通角色列表、授予、撤销 3 个 API,覆盖 scope、用户版本、全会话撤销与最后管理员并发保护。 +I-02D 接通学院、实验室、位置、模板创建/发布/克隆及巡检草稿 16 个 API;可通过真实数据库完成组织→模板→草稿链路。 +I-02E 接通巡检项列表、详情 2 个 API;提供同源守卫探测基础,未实现的写动作不显示为可执行能力。 +I-02F1 新增可选 createUpload;实现上传授权、权限/额度守卫、真实 SDK 本地签名和固定版本读取/SHA 适配基础。 +I-02F2 新增 completeUpload/getImage,累计 31 个公共业务 API;上传完成只受理为 validating,并原子登记验证任务/TaskDispatch。图片下载、图像验证消费及 ready 提交仍未实现。 +尚不包含完整巡检业务仓储/API、持久任务闭环、真实图像处理、训练权重或生产部署。 + +未开启身份 API 时,/ready 只检查 I-01A 进程配置;开启后还检查迁移版本和 Redis 连通性,但仍不是完整系统就绪。开启上传也不会使 /ready 检查真实桶、IAM、签名请求或图像验证。 所有后端进程和前端构建均拒绝 production;AI_MODE 只接受 mock。 ## 2. 安装依赖 @@ -42,8 +49,8 @@ $env:APP_ENV = 'dev' .\.venv-business\Scripts\python.exe -m apps.api.run ~~~ -API 监听 127.0.0.1:8000;GET /health 为存活,GET /ready 返回开发骨架就绪范围。 -不提供假的登录、业务成功或数据库就绪结果。 +API 监听 127.0.0.1:8000;GET /health 为存活,GET /ready 明确返回当前就绪范围。 +默认 API_IDENTITY_ENABLED=0;认证/用户路径返回 503,不提供假的登录成功。真实身份 API 的启用步骤见 3.6。 ### 3.2 AI 终端 @@ -158,21 +165,117 @@ Remove-Item Env:LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE 本机自动验证不需要配置或连接已有库: ~~~powershell +$env:LABSAFE_TEST_REDIS_SERVER = 'D:/Tool/Redis/redis-server.exe' .\.venv-business\Scripts\python.exe tools/database/run_mysql_tests.py --mysqld 'D:/SQL/MySQL/MySQL Server 8.0/bin/mysqld.exe' ~~~ 把路径替换为实际 mysqld 可执行文件。脚本创建临时 data directory、随机回环端口和随机凭据,核对实例身份后才建库;结束后只关闭本次子进程并清理临时数据。 不注册或修改 Windows 服务。Linux 本机模式需已安装 mysqld,并以允许运行 mysqld 的非 root 用户执行;CI 使用隔离 MySQL 容器模式,不依赖本机服务。 +I-02B 测试还需要 redis-server(位于 PATH 或上述显式路径);测试夹具启动随机回环端口的独立 Redis 子进程并核对 PID,不复用或清空已有 Redis。CI 使用该 job 的隔离 Redis 容器。 + +### 3.6 开启身份管理和基础业务 API(I-02B/C/D/E) + +先完成 3.5 的专用 dev/test 数据库迁移、verify 和租户初始化。API 使用该库的独立运行账户,只授予运行所需的 SELECT/INSERT/UPDATE/DELETE,不复用具有 DDL 权限的迁移账户。将该运行账户连接串放入仅当前用户可读的 `.local-secrets/api-database-url`;仍要求回环地址、准确库名、mysql+pymysql、无 URL query 参数。 + +首次生成独立 CSRF 秘密文件;已存在时不要重建,否则现有会话会失效。以下命令使用排他创建,意外覆盖会失败。 + +~~~powershell +.\.venv-business\Scripts\python.exe -c "import pathlib,secrets; p=pathlib.Path('.local-secrets/csrf-key'); p.parent.mkdir(exist_ok=True); f=p.open('xb'); f.write(secrets.token_bytes(32)); f.close()" +$env:APP_ENV = 'dev' +$env:API_IDENTITY_ENABLED = '1' +$env:DATABASE_SCHEMA = 'labsafe_dev_local' +$env:DATABASE_URL_FILE = (Resolve-Path .local-secrets/api-database-url).Path +$env:CSRF_KEY_FILE = (Resolve-Path .local-secrets/csrf-key).Path +$env:PUBLIC_ORIGIN = 'https://labsafe.localhost:8443' +$env:REDIS_URL = 'redis://127.0.0.1:6379/0' +.\.venv-business\Scripts\python.exe -m apps.api.run +~~~ + +Redis URL 必须指向操作者已准备好的专用开发实例;上例是变量格式,不代表程序会创建或隔离该实例。不要使用生产/共享 Redis。CSRF 文件是原始随机字节而不是待解码的 base64,至少 32 bytes,只给 API 读取,不能复用 AI bearer 令牌。 + +另行配置本地 HTTPS 反向代理及可信开发证书:在同一 `PUBLIC_ORIGIN` 提供前端,并将 `/api/v1`、`/health`、`/ready` 转发到 `127.0.0.1:8000`。PUBLIC_ORIGIN 必须与浏览器 Origin 精确一致,不含尾斜线、路径或凭据。当前仓库未提供反代配置或登录 UI,本批验证采用 HTTPS base URL 的 TestClient,不声称已完成真实浏览器 TLS 联调。不能把 Cookie 改为不安全模式来绕过 HTTPS。 + +登录 `POST /api/v1/auth/login` 发送 JSON `{tenant_code,username,password}` 与 Origin;后续请求携带服务端 session cookie。`GET /api/v1/me` 获取 CSRF;创建用户/禁用/退出还需单个 X-CSRF-Token、Idempotency-Key、Origin 和 application/json。具体字段、响应和错误使用 [公共契约](contracts/public-api-v1.yaml),不要将密码或 cookie 粘贴到日志/PR。 + +本阶段不信任 X-Forwarded-For;反代后的 IP 限流按直接 peer 计数,不声称已识别真实终端 IP。身份及基础小请求暂限 64 KiB,模板创建为 2 MiB,以容纳 100 项完整 Unicode/转义正文;这不是未来上传/import 限额。/ready 只核对 `0001_initial` 与 Redis ping;模式结构仍必须用 CLI verify,存储、Worker、完整业务流程和真实模型均未纳入该门禁。生产/远端数据库仍被拒绝。 + +### 3.7 组织→模板→巡检草稿(I-02D) + +共用 3.6 的开关和安全请求头;默认未配置时返回 503,不使用内存数据伪造成功。按下列顺序接入,字段完整约束见 [I-02D 验收](docs/08-delivery/13-i02d-foundation-api.md)及公共契约: + +1. 管理员创建学院 `{name,code}`,以返回 id 创建实验室 `{college_id,name,code}`。 +2. 向 `/laboratories/{id}/locations` 创建 room:`{parent_id:null,type:"room",label}`;area 引用 room,shelf/cabinet 引用 room 或 area。 +3. 创建模板 `{name,items}`,每项必须含 `{id,code,title,capture_hint,sort_order,required}`;items 为 1–100,id/code/sort_order 分别唯一。 +4. 向 `/templates/{id}/publish` 发送 `{expected_version,reason}`,version 使用刚查询/创建得到的源模板 version;发布后不能修改正文。clone 接收同一命令结构,生成同 family 下一 revision 的新 draft/新子项 ID。 +5. 授予目标用户实验室 inspector 后让其重新登录;以 `{laboratory_id,template_id,location_ids}` 创建巡检。模板必须 published,位置必须同实验室 active,子项×位置≤100。 +6. `GET /inspections/{id}` 返回草稿和真实 item_ids;巡检项查询见 3.8,可选上传与验证受理见 3.9/3.10。尚无图像验证消费/执行推理/巡检完成或取消接口,不将草稿呈现为已完成巡检。 + +新意图使用新 Idempotency-Key;网络重发保留 key 和原正文。状态/版本冲突由客户端刷新后重新决定,不静默覆盖。学院/模板列表不接收 laboratory_id;实验室/巡检列表按当前授权实验室过滤后再分页计数。 + +### 3.8 巡检项列表与详情(I-02E) + +携带登录后的 Secure session cookie,使用 `GET /api/v1/inspections/{inspection_id}/items` 查询列表,支持 page/page_size/laboratory_id;`GET /api/v1/inspection-items/{item_id}` 查询详情且不接受 query。读请求无需写用 CSRF 或 Idempotency-Key,错误仍为统一 envelope,所有响应 no-store。 + +列表先验证父巡检范围再计数;不存在/越权父巡检为 404,可见但无子项为 200 空页。laboratory_id 越权或不存在为 404,可见但不匹配父巡检实验室为 200 空页。COUNT 与页数据为单请求同一 RR 快照,不承诺跨页冻结。 + +当前所有状态的 `allowed_actions=[]`:未接入 submit/retry/complete 巡检项写路由,不根据 status 伪造按钮;createUpload 不改变这一能力集。领域探测复用 submit/retry/complete 原守卫,但完整可信上下文加载器和对应命令事务尚未接入;不能只改能力开关放行。精确边界与后续关闭清单见 [I-02E 验收](docs/08-delivery/14-i02e-item-query-api.md)。 + +### 3.9 可选上传授权(I-02F1) + +本节说明 `POST /api/v1/uploads`;同一开关也控制 3.10 的完成/查询接口,默认 `API_UPLOADS_ENABLED=0`。先安装当前业务 requirements(含 boto3),完成 3.6 的身份/数据库/Redis 配置;不要给 AI 环境安装对象存储或数据库依赖。 + +启用前由操作者另行准备私有、已开启版本化的 `labsafe-private` 桶、staging 生命周期、API 最小权限账户和两份仅 API 可读的凭据文件。不要挂载 MinIO 管理员或 cleanup 专用凭据。仓库尚未提供完整部署/IAM 自动配置,以下变量不是已完成真实存储联调的证明。 + +~~~powershell +$env:API_UPLOADS_ENABLED = '1' +$env:PUBLIC_ORIGIN = 'https://labsafe.localhost' +$env:S3_PUBLIC_ENDPOINT = $env:PUBLIC_ORIGIN +$env:S3_ENDPOINT = 'http://127.0.0.1:9000' +$env:S3_BUCKET = 'labsafe-private' +$env:S3_REGION = 'us-east-1' +$env:S3_ACCESS_KEY_FILE = (Resolve-Path .local-secrets/s3-api-access-key).Path +$env:S3_SECRET_KEY_FILE = (Resolve-Path .local-secrets/s3-api-secret-key).Path +.\.venv-business\Scripts\python.exe -m apps.api.run +~~~ + +上传规范要求公共同源 HTTPS **443**;3.6 仅身份示例的 `:8443` 不能直接用于上传。必须同步调整前端/反代证书和 Origin,按[部署规范 3.1](docs/07-quality-operations/02-deployment-operations.md)代理 `/labsafe-private/`,保持 Host、Content-Type、原始 path/query,PUT 限制 15 MiB,并关闭签名 query 日志。示例回环 9000 仅适用于受控本地开发,不得暴露公网或代替生产内网隔离。 + +凭据只从显式 secret 文件读取,不回退 AWS 环境凭据、profile 或 metadata。公共端点必须与 PUBLIC_ORIGIN 字符串相同,无路径、尾斜线、query 或凭据;不符合则启动失败。文件和 URL 不写入 Git、终端共享记录或日志,`.env` 仍不自动加载。 + +1. 登录并取得 CSRF,用 3.7/3.8 返回的可采集 item ID 请求 grant;携带 session cookie、Origin、X-CSRF-Token、Idempotency-Key 和 application/json。 +2. 正文为 `{owner_type,owner_id,filename,mime_type,size_bytes,sha256,captured_at}`。owner_type 为 inspection_item/remediation_task;文件 1–15 MiB、JPEG/PNG/WebP,SHA 是拟上传字节的 SHA-256,时间必须带 RFC3339 时区。 +3. 201 返回 `{data:{upload_id,object_key,put_url,required_content_type,expires_at,max_bytes},request_id}`。在已配置并验收的代理上,用返回 URL PUT 原始文件字节和精确 required_content_type;不要用 multipart/form-data,不自行拼 key、改 Host 或签名 query。 +4. 同用户独立 10 次/分钟 grant 限流,并最多 10 个未到期 granted;429 遵循 Retry-After。网络重发保持 key/正文,返回原 URL/到期时间,不续期;过期后新的上传意图使用新 key。 + +PUT 成功不等于图像有效或业务完成。后续完成/查询见 3.10,客户端不得自行标记 ready;固定版本 GET/SHA helper 尚未接入 Worker。授权的权限、锁序及该批历史测试见 [I-02F1 验收](docs/08-delivery/15-i02f1-upload-grant-api.md)。 + +回退设 `API_UPLOADS_ENABLED=0` 后重启,不影响既有身份/查询;不删 uploads/审计/幂等历史,也不通过数据库降级清空数据。已发出的预签名 URL 不因 API 开关关闭或用户会话撤销立即失效,按原 600 秒到期;实际应急凭据撤销必须另行评估,不承诺开关具备对象存储撤销能力。 + +### 3.10 上传完成受理与图像元数据(I-02F2) + +沿用 3.9 的开关、依赖、secret 和同源配置,无新增迁移或启动变量。当前用于受控 dev/test 接口联调,不是可供业务使用的完整采集流程。 + +1. 取得 grant 并成功 PUT 后,向 `POST /api/v1/uploads/complete` 发送 `{upload_id,sha256}`;带 Origin、session cookie、X-CSRF-Token、Idempotency-Key 和 application/json,不传对象 key/version/owner 或 query。 +2. 首次成功返回 202,`data` 是 Image:`status=validating`,analysis_sha256/width/height 均为空。内部 HEAD 的准确版本被固定;image、validate_image 任务、初始 TaskDispatch、upload 状态、审计和幂等响应在同一事务提交。既有巡检项仍为 draft,不能按受理结果放行提交。 +3. 用 `GET /api/v1/images/{id}` 读取元数据;需要当前 owner.read 权限,无写用 CSRF/幂等头,不接 query。只返回契约字段,不返回内部 key/version、签名 URL、凭据或任务 payload。 +4. 同 key/正文重发返回原响应和 request_id;新 key 对已完成 upload 返回同一图像的当前元数据,不再 HEAD、不另建任务。原 grant 过期或 owner 状态推进不重复执行首次创建前置,但始终重验当前权限;不同 hash 不可更换原图。 + +首次完成要求 grant 未过期、owner 仍可采集、请求 hash 与 grant 声明一致、HEAD 大小/MIME 匹配。HEAD 元数据中的 SHA 不可信;validating 时的 original_sha256 仍为声明值,真实字节 SHA/解码由后续 Worker 验证。文件被重新 PUT 不会改写已固定的版本。 + +本批没有 publisher/sweeper/验证 consumer,任务停留在数据库 ready、图像停留 validating,不应持续轮询期待本版本自行完成。staging 的 24 小时生命周期仍可能使未消费输入过期;启用真实验证消费前仅使用可丢弃合成测试对象,不能积压实际业务证据。关闭 API 开关不删除已登记任务;没有实现后台取消或清理功能。 + +验收、精确重放语义及后续关闭清单见 [I-02F2 验收](docs/08-delivery/16-i02f2-upload-completion-api.md)。真实 IAM/MinIO/nginx/TLS 联调仍待完成,TestClient/Stubber 不替代这些证据。 ## 4. 验证 -根目录、业务环境执行: +根目录、业务环境执行:领域守卫也使用业务环境,不向 AI 环境引入业务授权或持久化依赖。 ~~~powershell .\.venv-business\Scripts\python.exe -m unittest discover -s tests/business -t . -v .\.venv-business\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v .\.venv-business\Scripts\python.exe -m pytest tests/persistence/test_unit.py --tb=short +.\.venv-business\Scripts\python.exe -m pytest tests/business tests/domain tests/security -q .\.venv-business\Scripts\python.exe -m ruff check apps packages tests tools/database .\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests tools/database @@ -188,7 +291,7 @@ AI 环境执行: I-01A 的业务/AI/协议测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。 包含实际 API/AI 子进程 HTTP smoke,不只是 import 非空检查。 -I-01B 的真实数据库测试必须运行 3.5 的隔离启动器;直接 pytest tests/persistence 会跳过未配置的 MySQL 用例,不能把跳过算作数据库通过。 +I-01B–I-02F2 的真实数据库测试必须运行 3.5 的隔离启动器;直接 pytest tests/persistence 会跳过未配置的 MySQL 用例,不能把跳过算作数据库通过。上传测试的本地 SDK 签名/Stubber 不连接真实 S3,也不能替代 UP-03/SEC-03 部署验收。 设计校验使用独立工具环境,安装 tools/design/requirements.txt 后执行: @@ -209,7 +312,12 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 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 推进,但不以数据库通过替代模型或生产验收。 +截至 2026-10-01,I-01A PR #5、I-01B PR #7、I-01C PR #8 均已合并。本轮基于 #8 合并后的 main 进入 I-02;数据库/安全变更仍需独立评审,不直接推送 main。 +I-02A 的领域守卫边界见 [领域命令守卫验收](docs/08-delivery/10-i02a-domain-guards.md);本批最新身份 API、事务和故障验证见 [I-02B 验收](docs/08-delivery/11-i02b-identity-api.md)。本批未推送,不能将旧 PR 的 CI 成绩作为新分支已通过。 +I-02C 的角色接口、作用域、版本与会话语义见 [角色 API 验收](docs/08-delivery/12-i02c-role-api.md)。角色请求的 expected_version 来自目标 User.version,laboratory_id 必须显式传 UUID/null;成功改权会撤销目标全部会话,包括操作者修改自身角色时的当前会话。三接口与 I-02B 共用开关和安全请求头,无新增配置。 +I-02D 的接口、事务与本轮会话锁修复见 [基础 API 验收](docs/08-delivery/13-i02d-foundation-api.md)。实施计划的 I-0 表格同步记录本地验证状态,不把未提交工作记作远程 CI 或独立批准。 +I-02E 的两个巡检项查询、同源探测基础与能力门禁见 [巡检项查询验收](docs/08-delivery/14-i02e-item-query-api.md);这不代表完整写动作投影已接通。 +I-02F1 的上传授权、S3 配置/签名、固定版本适配与实际测试见 [上传授权验收](docs/08-delivery/15-i02f1-upload-grant-api.md),默认关闭,仅 dev/test;CI 的 AI 依赖隔离清单同时禁止 boto3/botocore,本地通过不代表远程 CI 已运行。 +I-02F2 的完成受理/图像查询和原子任务写入见 [完成受理验收](docs/08-delivery/16-i02f2-upload-completion-api.md);不把初始 task/dispatch 记录等同于持久任务闭环。 +下一批优先 I-03A:落地 publisher、任务领取/lease/fencing、重试和 sweeper 共用基础,再由 I-02F3 接入 general 图像验证消费及真实存储联调;此前不标 ready。后续接提交/重试/完成及事实、评估、复核与整改完整事务。真实模型主线按 I-ML-01 推进,不以基础 API、查询或纯领域测试代替模型和生产验收。 diff --git a/apps/api/app/foundations.py b/apps/api/app/foundations.py new file mode 100644 index 0000000..f484a9c --- /dev/null +++ b/apps/api/app/foundations.py @@ -0,0 +1,172 @@ +"""Strict public organization, template and inspection-draft adapters.""" + +from typing import Annotated, Literal +from uuid import UUID + +from fastapi import APIRouter, Request +from pydantic import AfterValidator, Field + +from apps.api.app.identity import Body, CanonicalJSONResponse, VersionCommand, pagination +from packages.application.foundations import FoundationApplication +from packages.domain.security import ServiceError +from packages.persistence.security import SESSION_COOKIE + + +def canonical_uuid(value): + parsed = str(UUID(value)) + if len(value) != 36 or parsed != value.lower(): + raise ValueError("Invalid UUID") + return parsed + + +Identifier = Annotated[str, AfterValidator(canonical_uuid)] +Name = Annotated[str, Field(min_length=1, max_length=200)] +Code = Annotated[str, Field(min_length=1, max_length=64)] + + +class CollegeCreate(Body): + name: Name + code: Code + + +class LaboratoryCreate(CollegeCreate): + college_id: Identifier + + +class LocationCreate(Body): + parent_id: Identifier | None + type: Literal["room", "area", "shelf", "cabinet"] + label: Name + + +class TemplateItem(Body): + id: Identifier + code: Code + title: Name + capture_hint: Annotated[str, Field(min_length=1, max_length=1000)] + sort_order: Annotated[int, Field(ge=0, le=2147483647)] + required: bool + + +class TemplateCreate(Body): + name: Name + items: Annotated[list[TemplateItem], Field(min_length=1, max_length=100)] + + +class InspectionCreate(Body): + laboratory_id: Identifier + template_id: Identifier + location_ids: Annotated[list[Identifier], Field(min_length=1, max_length=100)] + + +def mount_foundations(app, identity): + app.state.foundations = FoundationApplication(identity) if identity is not None else None + router = APIRouter(prefix="/api/v1") + + def context(request, allowed=()): + if any( + key not in allowed or len(request.query_params.getlist(key)) != 1 + for key in request.query_params + ): + raise ServiceError("VALIDATION_ERROR", 422, "Unexpected or duplicate query parameter") + if app.state.foundations is None: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Foundation service not configured") + return app.state.foundations + + def read(request, kind, resource_id=None, laboratory_id=None, *, scoped=False): + allowed = () if resource_id else ("page", "page_size") + if scoped and resource_id is None: + allowed += ("laboratory_id",) + service = context(request, allowed) + page, size = pagination(request) + return service.read( + kind, + request.cookies.get(SESSION_COOKIE), + request.state.request_id, + resource_id=str(resource_id) if resource_id else None, + laboratory_id=str(laboratory_id) if laboratory_id else None, + page=page, + page_size=size, + ) + + def write(request, kind, body, *, operation="create", resource_id=None, laboratory_id=None): + status, response = context(request).write( + kind, + operation, + request.cookies.get(SESSION_COOKIE), + request.headers["x-csrf-token"], + request.headers["idempotency-key"], + body.model_dump(), + request.state.request_id, + resource_id=str(resource_id) if resource_id else None, + laboratory_id=str(laboratory_id) if laboratory_id else None, + ) + return CanonicalJSONResponse(response, status_code=status) + + @router.get("/colleges", operation_id="listColleges") + def colleges(request: Request): + return read(request, "college") + + @router.get("/colleges/{resource_id}", operation_id="getCollege") + def college(resource_id: UUID, request: Request): + return read(request, "college", resource_id) + + @router.post("/colleges", status_code=201, operation_id="createCollege") + def create_college(body: CollegeCreate, request: Request): + return write(request, "college", body) + + @router.get("/laboratories", operation_id="listLaboratories") + def laboratories(request: Request, laboratory_id: UUID | None = None): + return read(request, "laboratory", laboratory_id=laboratory_id, scoped=True) + + @router.get("/laboratories/{resource_id}", operation_id="getLaboratory") + def laboratory(resource_id: UUID, request: Request): + return read(request, "laboratory", resource_id) + + @router.post("/laboratories", status_code=201, operation_id="createLaboratory") + def create_laboratory(body: LaboratoryCreate, request: Request): + return write(request, "laboratory", body) + + @router.get("/laboratories/{laboratory_id}/locations", operation_id="listLocations") + def locations(laboratory_id: UUID, request: Request): + return read(request, "location", laboratory_id=laboratory_id) + + @router.post( + "/laboratories/{laboratory_id}/locations", status_code=201, operation_id="createLocation" + ) + def create_location(laboratory_id: UUID, body: LocationCreate, request: Request): + return write(request, "location", body, laboratory_id=laboratory_id) + + @router.get("/templates", operation_id="listTemplates") + def templates(request: Request): + return read(request, "template") + + @router.get("/templates/{resource_id}", operation_id="getTemplate") + def template(resource_id: UUID, request: Request): + return read(request, "template", resource_id) + + @router.post("/templates", status_code=201, operation_id="createTemplate") + def create_template(body: TemplateCreate, request: Request): + return write(request, "template", body) + + @router.post("/templates/{resource_id}/publish", operation_id="publishTemplate") + def publish_template(resource_id: UUID, body: VersionCommand, request: Request): + return write(request, "template", body, operation="publish", resource_id=resource_id) + + @router.post("/templates/{resource_id}/clone", status_code=201, operation_id="cloneTemplate") + def clone_template(resource_id: UUID, body: VersionCommand, request: Request): + return write(request, "template", body, operation="clone", resource_id=resource_id) + + @router.get("/inspections", operation_id="listInspections") + def inspections(request: Request, laboratory_id: UUID | None = None): + return read(request, "inspection", laboratory_id=laboratory_id, scoped=True) + + @router.get("/inspections/{resource_id}", operation_id="getInspection") + def inspection(resource_id: UUID, request: Request): + return read(request, "inspection", resource_id) + + @router.post("/inspections", status_code=201, operation_id="createInspection") + def create_inspection(body: InspectionCreate, request: Request): + return write(request, "inspection", body) + + app.include_router(router) diff --git a/apps/api/app/identity.py b/apps/api/app/identity.py new file mode 100644 index 0000000..420699b --- /dev/null +++ b/apps/api/app/identity.py @@ -0,0 +1,302 @@ +"""Public identity adapter; no SQL, no client-supplied tenant authority.""" + +import os +from pathlib import Path +from typing import Annotated, Literal +from urllib.parse import urlsplit +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field, field_validator +from redis import Redis +from sqlalchemy.exc import SQLAlchemyError +from starlette.exceptions import HTTPException + +from packages.application.identity import IdentityApplication +from packages.application.rate_limit import RateLimits +from packages.domain.security import ServiceError +from packages.persistence.database import database_engine +from packages.persistence.security import SESSION_COOKIE, SESSION_COOKIE_OPTIONS, SessionService +from packages.shared.json_hash import canonical_json + + +class CanonicalJSONResponse(JSONResponse): + def render(self, content): + return canonical_json(content).encode("utf-8") + + +class Body(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + +class Login(Body): + tenant_code: Annotated[str, Field(min_length=1, max_length=64)] + username: Annotated[str, Field(min_length=1, max_length=128)] + password: Annotated[str, Field(min_length=1, max_length=256)] + + +class UserCreate(Body): + username: Annotated[str, Field(min_length=1, max_length=128)] + display_name: Annotated[str, Field(min_length=1, max_length=100)] + initial_password: Annotated[str, Field(min_length=12, max_length=256)] + + +class VersionCommand(Body): + expected_version: Annotated[int, Field(ge=1, le=2147483647)] + reason: Annotated[str, Field(min_length=1, max_length=2000, pattern=r"\S")] + + +class RoleCommand(Body): + expected_version: Annotated[int, Field(ge=1, le=2147483647)] + role: Literal["safety_admin", "lab_manager", "inspector", "remediator", "viewer", "rule_expert"] + scope_kind: Literal["tenant", "laboratory"] + laboratory_id: str | None + + @field_validator("laboratory_id") + @classmethod + def laboratory_uuid(cls, value): + if value is None: + return None + if len(value) != 36: + raise ValueError("Invalid laboratory UUID") + parsed = str(UUID(value)) + if parsed != value.lower(): + raise ValueError("Invalid laboratory UUID") + return parsed + + +def pagination(request): + try: + page, size = ( + int(request.query_params.get(k, default)) + for k, default in (("page", "1"), ("page_size", "20")) + ) + if not 1 <= page <= 2147483647 or not 1 <= size <= 100: + raise ValueError + except ValueError: + raise ServiceError("VALIDATION_ERROR", 422, "Invalid pagination") from None + return page, size + + +def configured_identity(environment): + origin = os.environ.get("PUBLIC_ORIGIN", "") + parsed = urlsplit(origin) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + ): + raise ValueError("PUBLIC_ORIGIN must be an HTTPS origin without path or credentials") + secret = os.environ.get("CSRF_KEY_FILE") + if not secret: + raise ValueError("CSRF_KEY_FILE is required") + key = Path(secret).read_bytes() + sessions = SessionService(key) + redis = Redis.from_url(os.environ["REDIS_URL"], socket_connect_timeout=1, socket_timeout=1) + return IdentityApplication(database_engine(), sessions, RateLimits(redis), environment), origin + + +def error_response(request, error): + aliases = { + "AUTHENTICATION_REQUIRED": "UNAUTHENTICATED", + "INVALID_CREDENTIALS": "UNAUTHENTICATED", + "CSRF_FAILED": "FORBIDDEN", + "LAST_ADMIN": "STATE_CONFLICT", + "IDEMPOTENCY_EXPIRED": "STATE_CONFLICT", + } + code = aliases.get(error.code, error.code) + headers = {"Cache-Control": "no-store"} + if error.retry_after is not None: + headers["Retry-After"] = str(error.retry_after) + return JSONResponse( + status_code=error.status, + headers=headers, + content={ + "request_id": request.state.request_id, + "error": { + "code": code, + "message": str(error), + "retryable": code + in {"DEPENDENCY_UNAVAILABLE", "RATE_LIMITED", "REQUEST_IN_PROGRESS"}, + "details": [], + }, + }, + ) + + +def mount_identity(app, service, origin): + app.state.identity = service + + @app.middleware("http") + async def boundary(request: Request, call_next): + request.state.request_id = str(uuid4()) + try: + if request.url.path.startswith("/api/v1"): + if request.method not in {"GET", "HEAD", "OPTIONS"}: + if not origin or request.headers.getlist("origin") != [origin]: + raise ServiceError("FORBIDDEN", 403, "Same-origin request required") + media = request.headers.get("content-type", "").split(";")[0].lower().strip() + if media != "application/json": + raise ServiceError("UNSUPPORTED_MEDIA_TYPE", 415, "JSON request required") + if request.url.path != "/api/v1/auth/login": + for header in ("idempotency-key", "x-csrf-token"): + if len(request.headers.getlist(header)) != 1: + raise ServiceError( + "VALIDATION_ERROR", + 422, + "Required write header missing or duplicated", + ) + # A maximal 100-item Unicode template exceeds the identity body's 64 KiB. + limit = 2097152 if request.url.path == "/api/v1/templates" else 65536 + data = bytearray() + async for chunk in request.stream(): + data.extend(chunk) + if len(data) > limit: + raise ServiceError("VALIDATION_ERROR", 422, "Request is too large") + request._body = bytes(data) + request.state.identity_body = bytes(data) + response = await call_next(request) + except ServiceError as error: + response = error_response(request, error) + except Exception: + # Never return request bodies, exception details, URLs, SQL, or credentials. + response = error_response( + request, ServiceError("INTERNAL_ERROR", 500, "Internal error") + ) + response.headers["Cache-Control"] = "no-store" + return response + + @app.exception_handler(ServiceError) + async def service_error(request, error): + return error_response(request, error) + + @app.exception_handler(RequestValidationError) + async def invalid_request(request, error): + return error_response(request, ServiceError("VALIDATION_ERROR", 422, "Invalid request")) + + @app.exception_handler(SQLAlchemyError) + async def database_error(request, error): + return error_response( + request, ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Database unavailable") + ) + + @app.exception_handler(HTTPException) + async def http_error(request, error): + status = 404 if error.status_code == 404 else 422 + return error_response( + request, + ServiceError( + "NOT_FOUND" if status == 404 else "VALIDATION_ERROR", + status, + "Request not supported", + ), + ) + + def context(request, allowed_query=()): + if any( + key not in allowed_query or len(request.query_params.getlist(key)) != 1 + for key in request.query_params + ): + raise ServiceError("VALIDATION_ERROR", 422, "Unexpected or duplicate query parameter") + if app.state.identity is None: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Identity service not configured") + return app.state.identity + + def write(request, operation, body, user_id=None): + service = context(request) + status, response = service.write( + operation, + request.cookies.get(SESSION_COOKIE), + request.headers["x-csrf-token"], + request.headers["idempotency-key"], + body, + request.state.request_id, + user_id=user_id, + ) + return CanonicalJSONResponse(response, status_code=status) + + router = APIRouter(prefix="/api/v1") + + @router.post("/auth/login", operation_id="login") + def login(body: Login, request: Request): + response, token = context(request).login( + body.model_dump(), + request.client.host if request.client else "unknown", + request.state.request_id, + ) + result = JSONResponse(response) + result.set_cookie(SESSION_COOKIE, token, max_age=8 * 3600, **SESSION_COOKIE_OPTIONS) + return result + + @router.get("/me", operation_id="getMe") + def me(request: Request): + return context(request).read( + "me", request.cookies.get(SESSION_COOKIE), request.state.request_id + ) + + @router.post("/auth/logout", operation_id="logout") + def logout(request: Request): + if request.state.identity_body.strip() not in (b"", b"{}"): + raise ServiceError("VALIDATION_ERROR", 422, "Logout takes no request fields") + result = write(request, "logout", {}) + result.delete_cookie(SESSION_COOKIE, **SESSION_COOKIE_OPTIONS) + return result + + @router.get("/users", operation_id="listUsers") + def users(request: Request): + service = context(request, ("page", "page_size")) + page, size = pagination(request) + return service.read( + "list", + request.cookies.get(SESSION_COOKIE), + request.state.request_id, + page=page, + page_size=size, + ) + + @router.get("/users/{user_id}", operation_id="getUser") + def user(user_id: UUID, request: Request): + return context(request).read( + "get", + request.cookies.get(SESSION_COOKIE), + request.state.request_id, + user_id=str(user_id), + ) + + @router.post("/users", status_code=201, operation_id="createUser") + def create_user(body: UserCreate, request: Request): + return write(request, "create", body.model_dump()) + + @router.post("/users/{user_id}/disable", operation_id="disableUser") + def disable_user(user_id: UUID, body: VersionCommand, request: Request): + return write(request, "disable", body.model_dump(), str(user_id)) + + @router.get("/users/{user_id}/roles", operation_id="listUserRoles") + def user_roles(user_id: UUID, request: Request, laboratory_id: UUID | None = None): + service = context(request, ("page", "page_size", "laboratory_id")) + page, size = pagination(request) + return service.read( + "roles", + request.cookies.get(SESSION_COOKIE), + request.state.request_id, + user_id=str(user_id), + page=page, + page_size=size, + laboratory_id=str(laboratory_id) if laboratory_id is not None else None, + ) + + @router.post("/users/{user_id}/roles", operation_id="grantRole") + def grant_role(user_id: UUID, body: RoleCommand, request: Request): + return write(request, "grant_role", body.model_dump(), str(user_id)) + + @router.post("/users/{user_id}/revoke-role", operation_id="revokeRole") + def revoke_role(user_id: UUID, body: RoleCommand, request: Request): + return write(request, "revoke_role", body.model_dump(), str(user_id)) + + app.include_router(router) diff --git a/apps/api/app/item_queries.py b/apps/api/app/item_queries.py new file mode 100644 index 0000000..00633dc --- /dev/null +++ b/apps/api/app/item_queries.py @@ -0,0 +1,50 @@ +"""Read-only public inspection item endpoints.""" + +from uuid import UUID + +from fastapi import APIRouter, Request + +from apps.api.app.identity import pagination +from packages.application.item_queries import ItemQueryApplication +from packages.domain.security import ServiceError +from packages.persistence.security import SESSION_COOKIE + + +def mount_item_queries(app, identity): + app.state.item_queries = ItemQueryApplication(identity) if identity is not None else None + router = APIRouter(prefix="/api/v1") + + def service(request, allowed=()): + if any( + key not in allowed or len(request.query_params.getlist(key)) != 1 + for key in request.query_params + ): + raise ServiceError("VALIDATION_ERROR", 422, "Unexpected or duplicate query parameter") + if app.state.item_queries is None: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Item query service not configured") + return app.state.item_queries + + @router.get("/inspections/{inspection_id}/items", operation_id="listInspectionItems") + def items(inspection_id: UUID, request: Request, laboratory_id: UUID | None = None): + application = service(request, ("page", "page_size", "laboratory_id")) + page, size = pagination(request) + return application.read( + "list", + request.cookies.get(SESSION_COOKIE), + request.state.request_id, + resource_id=str(inspection_id), + page=page, + page_size=size, + laboratory_id=str(laboratory_id) if laboratory_id is not None else None, + ) + + @router.get("/inspection-items/{item_id}", operation_id="getInspectionItem") + def item(item_id: UUID, request: Request): + return service(request).read( + "get", + request.cookies.get(SESSION_COOKIE), + request.state.request_id, + resource_id=str(item_id), + ) + + app.include_router(router) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index d93df13..45aa56b 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,13 +1,60 @@ -"""Development-only API shell. Business routes arrive in I-01C/I-02.""" +"""Development-only business API with opt-in upload validation acceptance.""" + +import os +from contextlib import asynccontextmanager from fastapi import FastAPI +from apps.api.app.foundations import mount_foundations +from apps.api.app.identity import configured_identity, mount_identity +from apps.api.app.item_queries import mount_item_queries +from apps.api.app.uploads import mount_uploads from packages.shared.environment import development_environment -def create_app() -> FastAPI: +def create_app(*, identity=None, public_origin=None, storage=None) -> FastAPI: environment = development_environment() - app = FastAPI(title="LabSafe API", version="0.1.0-dev") + enabled = os.getenv("API_IDENTITY_ENABLED", "0") + if enabled not in {"0", "1"}: + raise ValueError("API_IDENTITY_ENABLED must be 0 or 1") + upload_enabled = os.getenv("API_UPLOADS_ENABLED", "0") + if upload_enabled not in {"0", "1"}: + raise ValueError("API_UPLOADS_ENABLED must be 0 or 1") + owned = identity is None and enabled == "1" + if owned: + identity, public_origin = configured_identity(environment) + owns_storage = upload_enabled == "1" and storage is None + try: + if upload_enabled == "1" and identity is None: + raise ValueError("Upload grants require configured identity") + if owns_storage: + from packages.storage.s3 import S3Settings, S3Storage + + storage = S3Storage(S3Settings.from_environment(public_origin)) + except Exception: + if owned: + identity.engine.dispose() + identity.limits.redis.close() + raise + + @asynccontextmanager + async def lifespan(app): + try: + yield + finally: + try: + if owns_storage: + storage.close() + finally: + if owned: + identity.engine.dispose() + identity.limits.redis.close() + + app = FastAPI(title="LabSafe API", version="0.1.0-dev", lifespan=lifespan) + mount_identity(app, identity, public_origin) + mount_foundations(app, identity) + mount_item_queries(app, identity) + mount_uploads(app, identity, storage) @app.get("/health") def health() -> dict[str, str | bool]: @@ -15,6 +62,18 @@ def health() -> dict[str, str | bool]: @app.get("/ready") def ready() -> dict[str, str | bool]: + if app.state.identity is not None: + app.state.identity.readiness() + return { + "status": "ready", + "environment": environment, + "is_simulated": True, + "scope": ( + "I-02B/C identity, I-02D foundations, I-02E item queries " + "and optional I-02F1/F2 upload signing and validation acceptance only; " + "storage, workflow and real-model readiness not implemented" + ), + } return { "status": "ready", "environment": environment, diff --git a/apps/api/app/uploads.py b/apps/api/app/uploads.py new file mode 100644 index 0000000..23f1cea --- /dev/null +++ b/apps/api/app/uploads.py @@ -0,0 +1,75 @@ +"""Opt-in upload grants, validation acceptance, and read-only image metadata.""" + +from typing import Annotated, Literal +from uuid import UUID + +from fastapi import APIRouter, Request +from pydantic import Field + +from apps.api.app.foundations import Identifier +from apps.api.app.identity import Body, CanonicalJSONResponse +from packages.application.uploads import UploadGrantApplication +from packages.domain.security import ServiceError +from packages.domain.uploads import MAX_BYTES +from packages.persistence.security import SESSION_COOKIE + + +class UploadRequest(Body): + owner_type: Literal["inspection_item", "remediation_task"] + owner_id: Identifier + filename: Annotated[str, Field(min_length=1, max_length=255)] + mime_type: Literal["image/jpeg", "image/png", "image/webp"] + size_bytes: Annotated[int, Field(ge=1, le=MAX_BYTES)] + sha256: Annotated[str, Field(pattern=r"^[a-f0-9]{64}$")] + captured_at: str + + +class UploadComplete(Body): + upload_id: Identifier + sha256: Annotated[str, Field(pattern=r"^[a-f0-9]{64}$")] + + +def mount_uploads(app, identity, storage): + app.state.uploads = ( + UploadGrantApplication(identity, storage) + if identity is not None and storage is not None + else None + ) + router = APIRouter(prefix="/api/v1") + + def service(request): + if request.query_params: + raise ServiceError("VALIDATION_ERROR", 422, "Upload endpoints take no query parameters") + if app.state.uploads is None: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Uploads not configured") + return app.state.uploads + + @router.post("/uploads", status_code=201, operation_id="createUpload") + def create(body: UploadRequest, request: Request): + status, response = service(request).create( + request.cookies.get(SESSION_COOKIE), + request.headers["x-csrf-token"], + request.headers["idempotency-key"], + body.model_dump(), + request.state.request_id, + ) + return CanonicalJSONResponse(response, status_code=status) + + @router.post("/uploads/complete", status_code=202, operation_id="completeUpload") + def complete(body: UploadComplete, request: Request): + status, response = service(request).complete( + request.cookies.get(SESSION_COOKIE), + request.headers["x-csrf-token"], + request.headers["idempotency-key"], + body.model_dump(), + request.state.request_id, + ) + return CanonicalJSONResponse(response, status_code=status) + + @router.get("/images/{image_id}", operation_id="getImage") + def image(image_id: UUID, request: Request): + return service(request).get_image( + request.cookies.get(SESSION_COOKIE), str(image_id), request.state.request_id + ) + + app.include_router(router) diff --git a/apps/api/run.py b/apps/api/run.py index b9f44e8..da3eb0f 100644 --- a/apps/api/run.py +++ b/apps/api/run.py @@ -12,6 +12,7 @@ def main() -> None: host=os.getenv("API_HOST", "127.0.0.1"), port=int(os.getenv("API_PORT", "8000")), workers=1, + proxy_headers=False, # Do not trust arbitrary X-Forwarded-For for login limits. ) diff --git a/docs/02-architecture/04-coding-baseline.md b/docs/02-architecture/04-coding-baseline.md index a2fa77a..f233a54 100644 --- a/docs/02-architecture/04-coding-baseline.md +++ b/docs/02-architecture/04-coding-baseline.md @@ -17,6 +17,7 @@ | packages/domain | 状态、guards、权限动作、不变事实类型 | 通用 status setter | | packages/application | 命令处理器、查询、unit_of_work;每命令唯一入口 | 路由另写业务逻辑 | | packages/persistence | SQLAlchemy 2、MySQL 8.0.16+、Alembic、Outbox repository | 省略租户条件 | +| packages/storage | 业务环境的 S3 SDK 适配;同源本地签名、内网固定版本读取 | 默认凭据链、事务内网络操作、将摘要匹配当成图像 ready | | packages/rules | evaluate(facts,bundle,reference_date) 纯函数 | 网络、系统时间、动态 eval | | contracts / tests | 生成协议;contract/unit/integration/e2e/fault/evaluation 分层 | 模拟成绩冒充真实评测 | @@ -34,6 +35,18 @@ 统一锁序:幂等 → inspection → item → run → finding → remediation_task → task_run;同类多行按 ID 排序。数据库死锁最多重跑事务 3 次,重新检查守卫;不能重复执行外部副作用。 +I-02B 身份事务补充:短事务认证预检结束后再调用 Redis,提交事务内必须重新认证、授权和校验 CSRF,预检结果不作为提交凭据。用户列表在显式 REPEATABLE READ 事务中完成 COUNT 与分页。禁用用户先定位 session 的租户范围(不是授权),取得 tenant 排他锁,再 claim 幂等记录、重验会话并锁目标 user;这是认证排他锁前置,不改变上述业务聚合锁顺序。原因是幂等 INSERT 的租户/用户外键会隐式取得共享锁,之后升级 tenant 排他锁会引起并发禁用死锁。最后管理员保护采用当前锁定读,不能依赖等待前的旧快照。实现与故障证据见 [I-02B 验收](../08-delivery/11-i02b-identity-api.md)。 + +I-02C 的 grantRole/revokeRole 复用上述身份排他锁顺序:tenant → 幂等 → 重新认证 → 目标 user/role assignment → 撤会话与审计/幂等响应。User.version 与 session_epoch 在同一事务递增,不能仅修改 user_roles 而保留旧会话。角色列表同样使用一致性快照与租户范围校验;授权后的权限投影容量在提交前检查。见 [I-02C 验收](../08-delivery/12-i02c-role-api.md)。 + +I-02D 组织/模板/巡检草稿沿用事务模板,新增事务内白名单资源投影与父对象共享锁;clone 按 family 根→源模板锁定,再当前读分配下一 revision,同 family 不同源版本也必须串行。重放先重验会话/动作/结果可见性,再返回原响应,不重复版本/状态前置。会话认证保持 user/roles/epoch 共享当前读,仅 session 行 FOR UPDATE;禁止以 sessions JOIN users FOR UPDATE 将已有用户共享锁隐式升级,避免同用户多会话死锁。见 [I-02D 验收](../08-delivery/13-i02d-foundation-api.md)。 + +I-02E 巡检项查询:预检结束后进行事务外读限流,再于 RR 事务内重新认证;先授权父巡检/资源及可选实验室,再 COUNT/分页。业务投影使用同一快照,不混用业务锁定读。GET 不改业务状态/版本/审计/任务(既有会话 idle touch 除外)。allowed_actions 只复用原命令守卫,当前写命令未实现所以能力集为空;后续必须同批接通真实命令和完整可信上下文加载。见 [I-02E 验收](../08-delivery/14-i02e-item-query-api.md)。 + +I-02F1 上传授权补充:预检结束后执行普通写限流与独立上传限流,提交事务锁序为 tenant 共享锁 → 当前 user 排他锁(用户配额 mutex)→ 幂等 claim → 重新认证/CSRF → owner → uploads 配额/插入 → 审计/幂等响应。user 排他锁必须早于认证或幂等外键的 user 共享锁,避免同用户多会话 S→X 升级死锁;其他命令不得照搬排他认证。Item owner 按 inspection→item 锁定,Task owner 锁 remediation_task。配额用当前锁定读,避免等待 mutex 后沿用旧 RR 快照。SDK presigning 使用显式静态凭据,仅本地密码学、无网络/凭据刷新,因此可随响应在事务内保存;HEAD/GET/PUT 等对象网络操作仍必须在事务外。重放重验当前权限/owner,返回原 URL/expiry/request_id,不能重签或续期。见 [I-02F1 验收](../08-delivery/15-i02f1-upload-grant-api.md)。 + +I-02F2 上传完成:准备阶段按与 F1 相同的 tenant 共享锁→当前 user 排他锁→幂等→重新认证/CSRF 顺序,定位 upload 后先锁 owner,再锁 upload/image。首次未完成时抛内部准备信号使整个事务回滚(包括 pending 幂等行),退出所有数据库锁后执行 HEAD;提交阶段重新执行相同授权/锁序并检查 grant 未过期、准备快照未变化、HEAD 匹配。只在最终短事务原子写入 image/任务/TaskDispatch/upload/审计/幂等,不在网络等待期间持有事务。并发胜者已登记图像时复用胜者记录,不能用迟到 HEAD 覆盖固定版本。getImage 使用 RR 快照、当前 owner.read 和白名单元数据,不做对象 I/O。详见 [I-02F2 验收](../08-delivery/16-i02f2-upload-completion-api.md)。 + ## 4. 类型与不变量 - 资源 ID 默认 UUID v4 小写;确定性检测/裁剪 ID 按 AI 文档使用 UUIDv5。时间为 UTC RFC3339 毫秒,DB DATETIME(3)。reference_date 在提交时按 tenant.timezone 生成并冻结。 diff --git a/docs/03-interfaces/01-api-contract.md b/docs/03-interfaces/01-api-contract.md index 1a7ca5d..d79bb38 100644 --- a/docs/03-interfaces/01-api-contract.md +++ b/docs/03-interfaces/01-api-contract.md @@ -25,6 +25,8 @@ 幂等作用域 (tenant,actor,method,规范路径,key_hash),保留24小时。request_hash基于规范JSON;同key同body返回原状态/响应(request_id仍使用原响应),无任何重复审计/事件;同key不同body409 IDEMPOTENCY_CONFLICT。授权在重放响应前仍检查,已撤权不能获得缓存数据。 +I-02B 身份写命令将上述规范 JSON 的 UTF-8 字节以服务端秘密做 HMAC-SHA256 后存入 request_hash,避免 createUser 的初始密码被无密钥离线枚举;不是将明文密码或无密钥密码摘要写入幂等表。Idempotency-Key 是 8–128 个可见 ASCII 字符的 opaque 值,不进行文本规范化。重放采用统一键序 JSON,保留原 HTTP 状态、响应与 request_id;logout 成功已撤销会话,随后持该旧会话重放仍为 401,不绕过授权返回缓存 200。 + API事务用行锁保证同key串行;pending lease=30秒,用于请求进程中断恢复。由于业务和响应同事务提交,崩溃无业务提交时可重新执行,有提交则有完整响应。外部上传/导出仅创建意图,不在事务内执行。另一个请求仍在执行返回409 REQUEST_IN_PROGRESS+Retry-After:2,客户端可用原key重试。 每次用户新意图生成新key;网络重发保留原key。expected_version 指向 [状态机](../02-architecture/02-domain-model.md)定义聚合;冲突需用户刷新并重新决定,不能后台自动把新版本填进去重发。认证login无幂等保证,以安全限流防滥用。 @@ -63,8 +65,49 @@ API事务用行锁保证同key串行;pending lease=30秒,用于请求进程 | readNotification | 校验recipient,首次设置read_at、version+1;其他人404 | | replayJob | 只允许当前资源且终止任务,重新检查制品/owner;见持久任务,非“覆盖旧结果” | +### 5.1 角色管理补充(I-02C) + +- grantRole/revokeRole 的 expected_version 指向目标 User.version;laboratory_id 为必填 nullable 字段。tenant 角色只允许 safety_admin/rule_expert 且 laboratory_id=null,其余四角色必须 laboratory scope 并引用同租户实验室。 +- 授予仅面向 active 用户和 active 实验室;撤销可清理 disabled 用户/archived 实验室的已有精确 assignment。新 key 重复授权返回 STATE_CONFLICT,精确 assignment 不存在的撤销返回 NOT_FOUND;版本不匹配优先返回 VERSION_CONFLICT。 +- 每次成功变更只递增 User.version/session_epoch 一次并撤销目标全部会话。请求 schema 无 reason 字段,审计使用固定操作原因,不擅自加入请求字段。 +- listUserRoles 的 laboratory_id 仅筛选匹配的实验室 assignment,跨租户在计数前为 404。授予不得使去重后的 Session.permissions 超出既有 200 项容量,超出原子回滚,不能截断。详细接入与证据见 [I-02C 验收](../08-delivery/12-i02c-role-api.md)。 + +### 5.2 组织、模板和巡检草稿补充(I-02D) + +- College 无 published 状态;同租户 active 学院的既有白名单响应是任一有效角色可读的组织选择器公共投影,archived 仅 admin 可见。该解释不扩展到实验室业务或其他配置。模板普通角色仍仅可读 published,admin 可读全部状态。 +- TemplateItem.id 按创建 Schema 由请求提供;其 id/code/sort_order 在模板内分别唯一。模板顶层 ID 由服务端生成,新 family 的 family_id=该 ID、revision=1。clone 为同 family 的 max revision+1、新 draft/version=1、全新子项 ID,不更新源模板;expected_version 检查源模板 version,不是 revision。publish/clone 保留操作者 reason。 +- createInspection 原子建立全部模板项×位置草稿子项,含 required=false 项;容量与位置唯一性在写入前检查。重放以当前权限/结果可见性为准,不再次执行原状态前置条件。 +- 本地接入、并发修复和测试见 [I-02D 验收](../08-delivery/13-i02d-foundation-api.md);该批不含巡检项查询。后续查询/上传授权/完成受理分别见 5.4/5.5/5.6;图像验证消费、巡检完成/取消和任务闭环仍未实现。 + +### 5.3 导出快照 + CSV列固定:inspection_id,item_id,laboratory_id,location_id,run_id,fact_revision_id,rule_bundle_id,finding_id,rule_id,severity,status,explanation,task_status,review_outcome,snapshot_at;每finding一行,零finding item一行且finding列空;日期UTC。用户文本以=,+,-,@或tab/CR开头时前置单引号,防公式注入。PDF同快照,按lab→inspection→item分组,附证据缩略图、版本、人工意见、无法判断提示;不能展示原图GPS或无授权身份数据。 +### 5.4 巡检项查询与阶段能力(I-02E) + +- listInspectionItems 接受 page/page_size/laboratory_id,先授权父巡检再计数;getInspectionItem 不接受 query。父巡检不存在/不可见为 404;可见且无子项为 200 空页。筛选 lab 不存在/不可见为 404,同时可见但不匹配父巡检 lab 为 200 空页。 +- I-02E 接通这两个 GET 时累计 28 个公共业务 API;I-02F1/F2 新增上传授权/完成/图像查询后为 31 个。尚无 submit/retry/complete 巡检项写命令,因此 allowed_actions 固定为空能力集的投影,不能按状态补出前端按钮。 +- 领域动作探测直接复用 submit_item/retry_item/complete_item 守卫;未加载上下文与已核验空集合不同。启用动作必须同时具备真实命令事务、服务器完整可信上下文及同源回归;不是仅修改 capability。 +- 精确字段白名单、快照/权限和后续关闭条件见 [I-02E 验收](../08-delivery/14-i02e-item-query-api.md)。不修改公共 Schema、operationId 或生成源。 + +### 5.5 上传授权与阶段能力(I-02F1) + +- I-02F1 接通 POST /api/v1/uploads(createUpload,201);API_UPLOADS_ENABLED 默认 0,依赖真实身份配置。未配置为 503,仅 dev/test。I-02F2 的 completeUpload/getImage 见 5.6;图片下载及解码/Worker 尚未接通,不能在获得 grant 或完成 PUT 后展示 image ready。 +- 请求严格为 owner_type、owner_id、filename、mime_type、size_bytes、sha256、captured_at,无 tenant_id/object_key/query;大小 1–15 MiB,JPEG/PNG/WebP,SHA 为 64 位小写十六进制。captured_at 为带时区 RFC3339,落库为 UTC 毫秒;不擅自添加新鲜度/未来窗口,不将客户端时间当可信证据。 +- Item:当前 capture 权限及原创建者/admin,父巡检 draft/in_progress,子项 draft/uploaded/needs_retake/needs_review/failed。Task:当前受派人及 assignee 权限,in_progress/rejected;admin 不自动代替受派人,父巡检完成不阻断合法整改证据。 +- 独立 10 次/分钟上传授权限流叠加普通写限流;每用户同时至多 10 个尚未过期的 granted,使用数据库锁保护。过期 grant 即使尚未被 sweeper 改状态也不占额度;Redis 不可用时拒绝而非绕过限流。 +- grant 的 staging key 由服务端生成;PUT URL 同源 HTTPS:443、有效期 600 秒并签入 Content-Type/Host。相同 key/正文重放返回原响应/原到期时间,仍重验当前权限;过期 URL 不通过重放续签,新意图用新 key。 +- HEAD 已在 I-02F2 接到完成受理;固定版本 GET/SHA helper 仍未接到 Worker。真实桶版本化、IAM、反代大小限制和 MinIO 签名拒绝需另行实测,不能用 SDK/Stubber 成绩替代。授权实现、锁序、配置与历史证据见 [I-02F1 验收](../08-delivery/15-i02f1-upload-grant-api.md)。 + +### 5.6 上传完成受理和图像元数据(I-02F2) + +- POST /api/v1/uploads/complete 只接收 `{upload_id,sha256}`,202 返回 Image;GET /api/v1/images/{id} 返回同一 Image 白名单元数据。不接 query/客户端对象引用,共用上传开关、no-store 和既有鉴权错误边界。 +- 首次完成先重验采集权限/owner、grant 未过期和声明 hash,退出并回滚准备事务后才 HEAD;取得准确版本/大小/MIME 后,提交事务再次认证并锁 owner→upload→image→task。期间撤会话、owner 变为不可采集、grant 过期或正文元数据变化均拒绝,不以预检结果提交。 +- 同事务写入 image=validating、upload=validating、validate_image task_runs=ready、TaskDispatch Outbox=pending、审计和幂等响应。无上传完成领域事件新枚举,不提前发 ImageValidated,不直接发 Redis。original_sha256 在 validating 阶段仍是待验证声明值。 +- 同 key/正文重放返回原 202/原响应/request_id;新 key 命中已完成 upload 返回同一 image 当前投影,不新建/不再 HEAD。两者都重验当前 capture/assignee 权限和声明 hash,不因原 grant 过期或 owner 状态已推进而重复创建;已删除 image 的完成请求 404,不复活资源。 +- getImage 只要求 owner.read,读权限不等于采集/受派权限;已删除图像可返回契约中的 deleted 元数据墓碑,但这不授予任何下载能力。内部 key/version、upload_id、tenant_id、legal_hold 和 task payload 均不公开。 +- 本批没有发布/领取/消费/图像 ready 提交;真实字节 SHA、解码、O/A 生成和固定版本缺失处理仍属于后续 Worker。实现、故障证据和依赖关闭清单见 [I-02F2 验收](../08-delivery/16-i02f2-upload-completion-api.md)。 + ## 6. 命令实现映射和变更 所有领域写请求遵循状态机和统一事务模板;返回值从本事务写后的资源投影生成。字段Schema不能替代guard。当前设计不支持通用删除组织/改密码等未列出的端点;相关需求只能走后续契约评审,禁止开发人员自行扩API。 diff --git a/docs/08-delivery/01-implementation-plan.md b/docs/08-delivery/01-implementation-plan.md index 59e11c1..fb66cd4 100644 --- a/docs/08-delivery/01-implementation-plan.md +++ b/docs/08-delivery/01-implementation-plan.md @@ -1,24 +1,33 @@ # 08.1 实施计划与首批任务 -状态:已进入I-01A骨架实现与验收;其余任务仍未实现,不能以骨架通过代替业务/模型/发布验收。阶段标准见[阶段门禁](05-stage-gates.md),当前入口见[开发指南](../../DEVELOPMENT.md)。 +状态(2026-10-01):I-01A/B/C 已合并;I-02A/B/C/D/E/F1/F2 已通过本地相应验证,本次按用户授权提交特性分支并发起 PR。整个 I-02 尚未完成;业务闭环、真实模型和生产发布不得以局部成绩代替验收。阶段标准见[阶段门禁](05-stage-gates.md),当前入口见[开发指南](../../DEVELOPMENT.md)。 ## 1. 从“只有文档”开始 不要求用户先提供代码、训练后权重或运行报告。先创建受控开发环境和最小业务链路;真实模型/数据准备可以与业务代码并行,但任务仍按各自依赖验收。 -| 编号 | 任务与输入 | 必须交付 | 完成检查 / 失败处理 | -|---|---|---|---| -| I-01A | 由编码基线创建apps/api、worker、ai_inference、web和共享包;固定APP_ENV=dev | 项目骨架、各进程入口、环境变量样例、顶层依赖约束、CI入口 | 按声明依赖在干净Python3.11环境安装后import/HTTP smoke/build通过;缺框架须失败、不装真实模型可启动同协议mock;失败先修环境不改业务协议 | -| I-01B | 设计DDL与字典;专用空MySQL数据库 | 初始Alembic迁移、bootstrap-tenant/roles CLI、DB结构核对测试 | 空库upgrade到head;43表/列/FK/索引与字典一致,实际版本记日志;失败在可丢弃测试库重建,不触碰用户已有库 | -| I-01C | 数据约束和安全规范 | 仓储、session/RBAC、幂等、跨租户拒绝测试 | DB-01/02、API-02/03、SEC-01/02;失败事务回滚且无旁路权限 | -| I-02 | 状态机与公共契约 | 巡检、上传、事实、人工复核、整改等命令/查询 | 每个guard有正/反例;相同payload重发无重复副作用 | -| I-03 | 持久任务设计 | Outbox/inbox/publisher/sweeper、Worker、fixture-v1 AI服务 | JOB-01/02/03故障注入;断Redis仍从DB恢复;不能用同步路由替代持久任务 | -| I-ML-01 | ML-BASE-02固定提交/配方,不依赖完整API或已训练项目权重 | D-FINE四类初始化开发checkpoint、数据映射/安全加载/qmax、固定ONNX导出、训练/CPU/CUDA lock和合成smoke | 分类头未训必须标development;核对RGB/W/H/300query及设备,不冒充实训效果;不兼容形成ADR | -| I-ML-02 | 数据来源授权、标注规范 | 授权台账、100张标注试行、完整数据/split/审核记录 | 类别和scene不泄漏、样本不足标待补充;无授权不抓取替代真实数据 | -| I-ML-03/04 | I-ML-01/02数据和导出原型、验收policy | D-FINE四类训练/校准、训练后50图PyTorch/ORT CPU/CUDA一致性、真实集成和资源报告 | AI-05至AI-09、MODEL-02;检测GPU/OCR CPU固定;不达标不发布,不把初始化模型或mock当生产模型 | -| I-04 | 稳定API和fixture服务 | 前端采集/复核/整改/admin/report,显式开发环境标识 | UI-01、EXPORT-01、FLOW闭环;旧轮询不能覆盖新run | -| I-05 | 已实现各服务 | Compose、secret挂载、探针、日志/指标、备份恢复脚本 | 隔离栈闭环;失败按服务/数据层定位,不改证据记录掩盖失败 | -| R-01 | 机构批准policy/规则、固定真实模型与test | 真实业务评测、恢复/断公网演练、双人发布记录 | EVAL/OPS/Security全部通过;缺批准或指标不足禁止production | +| 编号 | 任务与输入 | 必须交付 | 当前进度与证据 | 完成检查 / 失败处理 | +| ---------- | --------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| I-01A | 编码基线;APP_ENV=dev;Python 3.11 独立业务/AI 环境 | API、Worker、AI、Web 骨架与进程入口、环境样例、依赖约束、CI | 已合并 PR #5;见[骨架验收](07-i01a-acceptance.md),不是完整业务服务 | 干净环境 import/HTTP smoke/build;缺框架必须失败,不安装真实模型也可启动同协议 fixture | +| I-01B | 设计 DDL;专用可丢弃 MySQL | Alembic 初始迁移、bootstrap CLI、结构核对 | 已合并 PR #7;43 表、115 外键、迁移循环及约束验证,见[数据库验收](08-i01b-acceptance.md) | 空库 upgrade/verify;失败仅处理专用测试库,不触碰已有库 | +| I-01C | 数据约束和安全规范 | 基础仓储/租户事务、session/RBAC、幂等、安全原语测试 | 已合并 PR #8;见[安全原语验收](09-i01c-acceptance.md);HTTP/并发端到端证据在 I-02 分批补齐 | DB-01/02、API-02/03、SEC-01/02 按实际覆盖记证;失败回滚,无旁路权限 | +| I-02 | 状态机与公共契约 | 巡检、上传、事实、人工复核、整改等命令/查询及认证管理 API | **进行中**;A/B/C/D/E/F1/F2 本地完成,累计注册 31 个公共业务 API;本次提交 PR,待远程 CI 和独立评审,完整业务 API 未完成 | 每个 guard 正/反例;锁定完整写集;相同请求重发无重复副作用;不能以身份/草稿/查询/上传接口验收整个 I-02 | +| I-02A | 领域状态机与守卫 | 13 个纯领域命令守卫、权限/版本/状态/证据测试 | 本地完成;542 个领域用例,见[领域守卫验收](10-i02a-domain-guards.md) | 后续应用事务加载可信完整聚合;纯守卫放行不等于数据库已提交 | +| I-02B | 认证和用户契约、安全原语 | 登录/会话/退出、用户列表/查询/创建/禁用,共 7 个 API;事务、限流、审计/幂等与故障验证 | 本地完成;597 项单元/边界、67 项持久化套件(有重叠),见[身份 API 验收](11-i02b-identity-api.md) | 同 key/版本并发、撤权后重放拒绝、最后管理员保护、失败原子回滚;仅 dev/test | +| I-02C | 角色契约与 I-02B 事务链路 | 角色列表/授予/撤销 3 个 API;精确 scope、version/epoch、撤会话、最后管理员及幂等保护 | 本地完成;612 项累计单元/边界、93 项持久化套件,其中新增 26 项角色集成测试;见[角色 API 验收](12-i02c-role-api.md) | 六角色 scope、跨租户/实验室拒绝、同 key/版本竞争、双管理员撤权、失败回滚与权限投影容量守卫 | +| I-02D | 组织、模板与巡检创建契约 | 学院/实验室/位置、模板创建/发布/克隆、巡检草稿 16 个 API;原子子项与作用域隔离 | 本地完成;691 项累计单元/边界、127 项持久化套件;本批新增 79 项单元/边界与 34 项数据库用例,见[基础 API 验收](13-i02d-foundation-api.md) | 父类型/active、发布和 family revision、≤100 项、同 key/多会话竞争、权限计数与全事务回滚;不包括上传/完成/取消 | +| I-02E | 巡检项查询契约与同源领域守卫 | 列表/详情 2 个 GET;白名单投影、授权后分页、动作探测基础与能力门禁 | 本地完成;961 项累计单元/边界、154 项持久化套件;新增 270 项单元/边界与 27 项数据库用例,见[巡检项查询验收](14-i02e-item-query-api.md) | 租户/实验室隔离、RR 快照、无业务副作用、未知上下文不放行;未接写命令,allowed_actions 暂为空 | +| I-02F1 | 上传授权契约、采集权限与同源 S3 规范 | createUpload、双额度守卫、原子 grant/审计/幂等;真实 SDK 本地签名及固定版本/SHA 适配基础 | 本地完成;1227 项累计单元/边界、178 项持久化套件(157 项真实 MySQL);新增 266 项单元/边界与 24 项数据库用例,见[上传授权验收](15-i02f1-upload-grant-api.md);默认关闭,仅 dev/test | 同用户并发不超额、重放不续签、撤权拒绝、失败全回滚;未实现 complete/解码/ready 或真实存储联调 | +| I-02F2 | F1 与持久任务/图像契约 | completeUpload/getImage、固定 staging version、原子 image+validate_image 任务/dispatch | 本地完成;1283 项主套件、213 项完整持久化回归(192 项真实 MySQL + 21 项重叠单元),本批新增 35 项数据库用例,见[上传完成验收](16-i02f2-upload-completion-api.md);默认关闭 | 重复 complete 不重复建图/任务,外部 HEAD 在事务外、提交前重验;对接 general 消费、图像变换和真实存储验收后方可 ready | +| I-03 | 持久任务设计 | Outbox/inbox/publisher/sweeper、Worker、fixture-v1 AI 服务执行闭环 | 未完成;仅已有 I-01A Worker 入口与同协议 AI fixture,不具备持久任务闭环 | JOB-01/02/03 故障注入;断 Redis 后从 DB 恢复;不能用同步路由或 echo 任务替代 | +| I-ML-01 | ML-BASE-02 固定提交/配方;不依赖完整 API 或项目权重 | D-FINE 四类开发 checkpoint、映射/安全加载/qmax、ONNX、训练/CPU/CUDA lock 与 smoke | 未完成;当前仅有模型设计与合成 fixture,不是 D-FINE-N 实际执行 | 分类头未训必须 development;RGB/W/H/300 query/设备核对;不兼容形成 ADR | +| I-ML-02 | 数据来源授权与标注规范 | 授权台账、100 张标注试行、完整数据/split/审核 | 未完成;无本阶段真实数据验收证据 | 类别/scene 不泄漏;不足标待补充,无授权不抓取替代 | +| I-ML-03/04 | I-ML-01/02 与验收 policy | 四类训练/校准、训练后 50 图 PyTorch/ORT CPU/CUDA 一致性与资源报告 | 未开始验收;依赖真实数据、制品和固定环境 | AI-05 至 AI-09、MODEL-02;检测 GPU/OCR CPU;不以初始化模型或 mock 代替 | +| I-04 | 稳定 API 和 fixture 服务 | 前端采集/复核/整改/admin/report,开发环境标识 | 未完成;只有开发 Web 骨架,尚无完整登录、角色管理或业务 UI 联调 | UI-01、EXPORT-01、FLOW 闭环;旧轮询不能覆盖新 run | +| I-05 | 已实现各服务 | Compose、secret 挂载、探针、日志/指标、备份恢复 | 未完成;开发入口/局部探针不等于完整部署 | 隔离栈闭环、恢复验证;按服务/数据定位,不改证据掩盖失败 | +| R-01 | 机构批准 policy/规则、固定真实模型与 test | 真实业务评测、恢复/断公网演练、双人发布记录 | 未进入;production 仍禁止 | EVAL/OPS/Security 全部通过;缺批准或指标不足禁止发布 | + +状态口径:已合并表示对应历史 PR 已合并;本地完成不代表已提交、远程 CI 通过或独立审查批准。I-02A/B/C/D/E/F1/F2 为 I-02 的子批次,不改变总体验收标准。统计中的套件重叠范围见各批验收记录,不累加成唯一测试数;旧批成绩保留当时口径。F1 只开放上传授权;F2 接通完成受理和图像元数据,但没有 publisher/worker/ready 闭环。巡检项 allowed_actions 仍为空。 ## 2. 数据库验证的精确任务边界 @@ -26,7 +35,7 @@ 测试至少包括:跨tenant FK、错item current pointer、角色scope重复、owner两列同时空/同时非空、多crop同图合法/同run同crop重复、旧lease回写拒绝。不能只看到CREATE TABLE成功就算通过。首个迁移尚无生产数据,可以在测试环境验证downgrade和重新upgrade;上线后优先备份与前向修复,不复用开发库“重建全部表”策略。 -这些验证可在完整应用之前完成,但不要求在当前文档交付阶段先安装/启动数据库。 +这些验证可在完整应用之前完成。I-01B/C 与 I-02B/C/D/E/F1/F2 均使用独立临时 MySQL 验证;新批次仍须重跑,不能用历史成绩替代。 ## 3. 模拟与真实环境 @@ -40,4 +49,4 @@ IRR六项设计前置按[关闭清单](06-implementation-readiness.md)执行。 ## 5. 协作和Push -设计基线已通过文档PR形成提交。I-01A按用户授权在特性分支完成修正、验证后Push并提交PR;不直接推送main,不自动合并或替代独立审查。推送前依CONTRIBUTING核对远程基线、变更范围、生成一致性和保密要求;数据库/权限/模型变更仍须双人评审。 +设计基线与 I-01A/B/C 已通过各自 PR 合并。I-02A/B/C/D/E/F1/F2 本次按用户授权从 `wsq/i-02a-auth-api` 提交 PR;不直接推送 main,不自动合并或替代独立审查。各批验收中的未提交状态是当时的历史记录;当前交付状态以本节及 GitHub PR 为准。远程 CI 和数据库/权限变更的协作者审查仍为合并前置。 diff --git a/docs/08-delivery/10-i02a-domain-guards.md b/docs/08-delivery/10-i02a-domain-guards.md new file mode 100644 index 0000000..3226c38 --- /dev/null +++ b/docs/08-delivery/10-i02a-domain-guards.md @@ -0,0 +1,68 @@ +# I-02A 验收:领域命令守卫 + +日期:2026-09-30。基线:已合并的 I-01B PR #7、I-01C PR #8(`7fe593f`)。 +状态:本批领域守卫已通过本地验证;整个 I-02 尚未完成。未提交、未推送,未取得本分支远程 CI 或独立评审结论。 + +## 1. 范围和实现入口 + +本批按[领域状态机](../02-architecture/02-domain-model.md)与[公共契约](../../contracts/public-api-v1.yaml)实现 13 个命令守卫,不重新定义状态机、权限或 API。入口是 [workflow.py](../../packages/domain/workflow.py)。 + +| 公共 operationId | 领域函数 | 判定与边界 | +|---|---|---| +| submitInspectionItem | submit_item | capture + 创建者/admin;开放巡检;1–3 张唯一 ready、可用、同 owner/location 图;首图 overview,detail 指向首图;当前 confirmed/dispatched/closed 均阻止重提 | +| retryInspectionItem | retry_item | review;item failed、当前 run failed;原图可用;返回原 pinned inputs 和 replay_of,不接受新模型/新规则参数 | +| completeInspectionItem | complete_item | review;当前评估 completed;无 needs_review finding;服务端 U/C 决定三种互斥结论 | +| confirmFinding / rejectFinding / cannotdetermineFinding | confirm_finding / reject_finding / cannot_determine_finding | 仅 current needs_review;reason 必填;确认要求可用证据;无法判断要求契约内 reason_code | +| dispatchRemediation | dispatch_finding | dispatch;current confirmed;同租户同实验室有效 remediator、未来期限;返回 finding dispatched + task pending_dispatch | +| acceptRemediation / submitevidenceRemediation | accept_task / submit_evidence | 实际 assignee;正确前置状态;提交 1–10 张唯一 ready 且归属当前 task 的可用图片 | +| recheckRemediation / rejectRemediation | recheck_task / reject_task | pending_recheck;请求、加载证据与 latest_evidence_id 三者一致;复查者不是 assignee 或证据提交人,包括 admin | +| cannotremediateRemediation / reassignRemediation | cannot_remediate / reassign_task | admin / dispatch;限定前置状态;原因及受派人/期限守卫;finding 保持 dispatched,不伪装销项 | + +各命令都调用现有 `authorize`、`require_version`,不接收 `is_admin/is_reviewer/is_assignee` 权限开关,也不提供通用 status setter。不可见资源为 404,权限不足 403,输入非法 422,状态或版本冲突 409。 + +`RetryDecision` 保留原模型、词典、规则 bundle、pipeline、设备、reference_date 及按序的图片角色/父图清单;`RemediationDecision` 同时携带 task/finding 的目标状态。它们只是不可变判定结果,不代表数据库已提交。 + +## 2. 应用层接入要求 + +1. 从有效会话加载 `Principal`,在带 tenant/laboratory 条件的查询中加载资源,禁止将请求体直接构造成可信快照。角色变更和禁用仍必须在事务边界重新验证。 +2. 按既有幂等与锁序加载 `Item/Finding/Task` 和关联对象。`current_findings` 必须是服务端完整集合,不能用分页、前端选中集合或缓存代替;评估和 findings 必须对应当前 run/evaluation。`Item.creator_id` 是所属 Inspection 创建者。 +3. `Image.available`、`Finding.evidence_ready`、`FailedRun.configuration_available`、`assignee_active` 都是服务端校验结果,不是用户可提交字段。图片可用性需对应已保存的不可变对象版本/哈希;重试不得只确认某个同名 key 存在。原有 image/run_images 版本与哈希仍由应用层核对并复制。 +4. 调用相应守卫。版本不匹配时返回冲突,不能自动补入新版本重试。传入 timezone-aware 的服务端 `now`,领域函数不读取系统时间。 +5. 应用事务按命令完整写集落库:创建 run/task/evidence、更新 current 指针与版本、保留旧证据、审计、Outbox、幂等响应必须一起提交或回滚。领域判定函数本身不生成 ID、不递增版本、不写库。 +6. guard 的放行不是绕过锁、授权或对象校验的凭据。查询投影、allowed_actions 与 HTTP schema 校验需要在下一批接入同一组守卫,不复制分支逻辑。 + +## 3. 验证与证据级别 + +Python 3.11;业务与 AI 使用原有独立环境。测试均为 synthetic 数据,不使用真实实验室图像、模型或业务库。 + +| 检查 | 可复现命令 | 本地结果 | +|---|---|---| +| 领域与授权 | `python -m pytest tests/domain tests/security -q` | 549 passed,其中 542 个领域用例、7 个原有安全用例 | +| 持久化单元回归 | 在上一条 pytest 命令追加 `tests/persistence/test_unit.py` | 共 570 passed;新增回归 21 个原有单元用例,不连接 MySQL | +| 静态检查 | `python -m ruff check apps packages tests tools/database`;`python -m ruff format --check apps packages tests tools/database` | 全部通过;61 个 Python 文件格式检查通过 | +| 业务/协议回归 | `python -m unittest discover -s tests/business -t . -v`;协议目录同命令 | 6 + 4 passed | +| AI 独立环境回归 | AI 环境运行 `python -m unittest discover -s tests/ai -t . -v`;协议目录同命令 | 15 + 4 passed;协议用例在两个环境分别验证,不重复计为新增用例 | +| 生成与设计 | `python -B tools/design/build_specs.py --check`;`test_readiness_design.py`;`validate_specs.py` | 16 份生成物无漂移、148 项就绪检查、完整设计校验 PASS(含 2 份 OpenAPI、162 个文档链接) | + +最终集中执行:2026-09-30,Python 3.11.4。设计完整校验时间为 06:49 UTC(14:49 Asia/Shanghai)。首次完整设计校验因业务环境缺少 `openapi_spec_validator` 未能运行,随后通过已有独立设计依赖目录提供工具依赖,已重跑成功;未安装新依赖、未修改 requirements、未刷新生成报告来掩盖漂移。 + +测试包含每个命令的全状态矩阵、权限/租户/实验室/版本拒绝、120 组三值组合、已废弃或旧 run/evaluation、图片容量/归属/选择、原配置重试、自复查、错误 evidence、未来期限、不可变输入和数据库/OpenAPI 枚举对齐。 + +CI 的 business job **已配置**执行同一领域/安全命令;本分支尚未推送,因此没有宣称远程 CI 已通过。未改迁移或持久化代码;本批没有重新运行真实 MySQL,不复用 I-01C 的数据库成绩冒充本批验证。 + +## 4. 尚未完成的 I-02 工作 + +以下为下一批实施清单,不是本批已交付功能: + +- 应用 Unit of Work、完整聚合仓储和并发测试:同 key 并发无重复副作用、旧版本竞争、幂等 pending/lease 接管与 fencing、失败事务回滚。 +- 登录/会话/用户与角色 HTTP 接入:Origin/CSRF、cookie、Redis 限流、稳定错误响应、撤权后重放拒绝。不能将 I-01C 原语直接当成完整安全 API。 +- 巡检创建/完成/取消、上传 grant/complete 及对象验证、人工事实完整快照与重评估、业务查询和上述 13 命令的事务/API 适配。 +- Outbox/inbox、Worker 租约/回收/重放及独立 AI 执行闭环按 I-03 实施;真实 D-FINE-N/CUDA 验证走模型主线。 + +因此,FLOW-02/04/05/06 等目前只有本批相应守卫的单测证据,不代表完整端到端验收、API-02/03 并发验收或 production 放行。 + +## 5. 协作与回退 + +在特性分支保留改动,不直接提交或推送 main,不自动合并。原 `01-implementation-plan.md` 的本地排版改动完全保留,不纳入本批交付。 + +本批无数据库迁移、无外部状态修改。未来提交后可按协作规范 revert 本批代码/测试/CI/文档;若后续应用已依赖这些命令,必须连同依赖适配协调回退,不删除业务、审计或历史证据。状态机与授权相关实现仍需独立协作者审查。 diff --git a/docs/08-delivery/11-i02b-identity-api.md b/docs/08-delivery/11-i02b-identity-api.md new file mode 100644 index 0000000..5188922 --- /dev/null +++ b/docs/08-delivery/11-i02b-identity-api.md @@ -0,0 +1,154 @@ +# I-02B 验收:认证、用户 API 与事务链路 + +日期:2026-09-30。基线:I-01B PR #7、I-01C PR #8 已合并,当前特性分支 `wsq/i-02a-auth-api`;承接 I-02A 领域守卫。 + +状态:I-02B 本批实现已通过本地回归。本文不把整个 I-02、生产部署或真实模型验收标记为完成。当前改动尚未提交、Push 或创建新 PR;旧 PR 的 CI 不代表本批 CI。 + +## 1. 交付范围 + +### 1.1 已实现接口 + +字段、枚举和响应投影以 [公共契约](../../contracts/public-api-v1.yaml) 为准。全部路径均以 `/api/v1` 开头。 + +| 方法与路径 | operationId | 权限与结果 | +|---|---|---| +| POST /auth/login | login | 同源 JSON;有效账号登录,200 Session + 新 session cookie | +| GET /me | getMe | 有效会话;200 Session,含 CSRF、权限和服务端环境 | +| POST /auth/logout | logout | 有效会话、CSRF、幂等键;撤销当前会话并清 cookie,200 Ack | +| GET /users | listUsers | 同租户 admin;一致性快照 COUNT + 分页,200 UserPage | +| GET /users/{id} | getUser | 同租户 admin;200 User,不可见或跨租户 ID 为 404 | +| POST /users | createUser | admin;用户名规范化、初始密码验证,201 User;不会自动授予角色 | +| POST /users/{id}/disable | disableUser | admin;expected_version、active 状态、最后管理员保护;200 更新后的 User | + +列表默认 `page=1,page_size=20`,最多 100 条,按 `created_at DESC,id DESC` 排序。未知/重复查询参数(包括用户列表不适用的 laboratory_id)返回 422。User 投影不包含 password_hash、session_epoch、租户内部字段或会话凭据。 + +### 1.2 实现入口 + +| 代码 | 唯一职责 | +|---|---| +| [HTTP 适配](../../apps/api/app/identity.py) | 输入 schema、Origin/写头校验、cookie、公开错误和稳定 JSON 响应 | +| [应用服务](../../packages/application/identity.py) | 预检、事务内重验、事务/锁顺序、审计与幂等响应原子提交 | +| [限流器](../../packages/application/rate_limit.py) | Redis 原子计数、登录成功释放、读降级限流和就绪检查 | +| [用户仓储](../../packages/persistence/users.py) | 租户限定查询、账号规范化、创建/禁用、User 白名单投影 | +| [安全原语](../../packages/persistence/security.py) | 会话、RBAC、HMAC 请求摘要、幂等租约、最后管理员保护 | +| [应用入口](../../apps/api/app/main.py) | 显式启用、身份就绪范围、关闭自建数据库/Redis 资源 | + +HTTP 路由不自行写 SQL,也不构造来自客户端 tenant_id 的可信 Principal。没有新增或改写数据库迁移、模型协议、状态机枚举或依赖清单。 + +## 2. 安全与事务约束 + +### 2.1 请求、密码与会话 + +- 写请求必须为 application/json 且单一 Origin 与 PUBLIC_ORIGIN 完全匹配;除 login 外要求单个 Idempotency-Key 与 X-CSRF-Token。login 同样防登录 CSRF,不接受跨站登录。 +- Pydantic 使用 strict + extra forbid;版本 bool 不能冒充整数。错误不回显密码、请求正文、SQL、数据库 URL 或内部异常文本。全部响应设置 no-store。 +- 本批身份 JSON 设 64 KiB 防护上限,超限为 422 VALIDATION_ERROR;它不是图片大小限制。当前中间件作用于已实现的 /api/v1 路径;后续新增普通 API/import 前应按安全规范拆分 2 MiB/10 MiB 的路由限额。 +- 用户名 NFKC + casefold 后唯一,display_name 使用 NFC/trim;密码保持原文、不截断,12–128 字符,Argon2id 参数沿用 I-01C。契约初始密码字段的传输上限为 256,129–256 在业务安全策略层返回 422,而不是放宽密码策略。 +- Cookie 为 Secure/HttpOnly/SameSite=Lax/Path=/,绝对有效期 8 小时、空闲期限 30 分钟。没有不安全 HTTP cookie 开关。禁用用户增加 version/session_epoch 并撤销全部会话。 +- 不处理未经信任的 X-Forwarded-For,Uvicorn `proxy_headers=False`;限流使用直接连接 IP。受控反代部署的可信代理 allowlist 尚未落地,因此此阶段反代后不能声称按最终终端 IP 限流。 + +### 2.2 事务顺序 + +1. HTTP 完成结构边界校验。短认证预检校验会话、必要的 admin 权限和 CSRF,然后结束事务。 +2. Redis 限流在数据库事务外执行。预检结果只用于计数主体,不能跳过提交事务内的认证或授权。 +3. 提交事务显式使用 REPEATABLE READ。通过 session 定位 tenant/actor(仅定位,不授权),claim 幂等记录,然后重新认证、校验 CSRF/权限与目标资源可见性。 +4. 用户禁用例外:先取得认证 tenant 排他锁,再 claim 幂等记录,随后重新认证并锁目标 user。否则幂等 INSERT 的外键隐式共享锁升级为 tenant 排他锁,会引起并发死锁。业务聚合仍在幂等之后锁定。 +5. 校验 expected_version、active 状态和最后管理员保护;当前锁定读读取角色和活跃管理员,禁止使用等待锁之前的旧快照。写业务状态、审计、完整幂等响应后原子提交。任何失败回滚整个命令。 +6. MySQL 1213 最多执行 3 次完整事务,每次重新认证/校验,不变更 expected_version,也不重复 Redis 外部操作;锁等待 1205/NOWAIT 3572 返回 409 REQUEST_IN_PROGRESS + Retry-After:2。 + +本批没有对应契约事件的用户/会话 Outbox 消息,不臆造 UserCreated 等事件。巡检事务的业务写入 + 审计 + Outbox/任务 + 幂等响应仍需后续实现,不能以本批身份事务替代整个 Unit of Work。 + +### 2.3 幂等与撤权 + +- 作用域 tenant、actor、POST、规范化路径、key_hash;key 为 8–128 个可见 ASCII 字符,按 opaque 值处理,不 trim/NFC。记录保留 24 小时,pending 租约 30 秒。 +- 身份请求正文采用规范 JSON + HMAC-SHA256 摘要,使用服务端秘密防止无密钥离线猜测 initial_password;不在幂等表或审计表存密码。 +- 同 key 同正文重放原状态、原 request_id 和统一键序 JSON 字节;同 key 不同正文为 409 IDEMPOTENCY_CONFLICT。活 pending 不允许第二请求冒领 owner;过期 owner 无法 complete,新 owner 接管受到 fencing 校验。 +- 缓存重放前仍重新认证/授权与检查资源可见性;禁用或撤权后不能读取缓存成功响应。logout 首次成功撤销当前会话,旧会话重放应为 401,不承诺再次返回缓存 200。 +- CSRF 密钥同时用于身份幂等 HMAC;开发期间必须保持稳定。更换该密钥会使旧会话校验失败,并可能让旧幂等键返回正文冲突;本批不提供无损密钥轮换。 + +### 2.4 限流和依赖故障 + +| 场景 | 实际行为 | +|---|---| +| 登录 | 按 tenant + 规范账号 + 直接 IP,固定 15 分钟窗口,最多 5 个失败/在途占位;成功释放本次占位;第 6 次为 429 | +| 已认证用户 | 普通 120/min,写请求另外 60/min;Redis Lua 原子计数与 TTL | +| Redis 计数失败 | login/write 为 503,不进入业务写事务;GET 降级为进程内最多 30/min、最多 10000 个主体的保守计数,并限频告警 | +| 登录提交后 Redis 释放失败 | 503 且不下发 cookie;MySQL 已有一个未交付、会过期的 session 和登录审计,不能声称 Redis/MySQL 原子提交 | +| 数据库连接失败/迁移版本不匹配 | 身份 /ready 为 503;SQL/连接串不透传,/health 仍只表示进程存活 | +| 审计或幂等写入失败 | 同事务业务写入回滚,不返回伪成功 | + +错误码按 [错误目录](../../contracts/error-codes.json) 暴露:底层 AUTHENTICATION_REQUIRED/INVALID_CREDENTIALS 映射 UNAUTHENTICATED,CSRF_FAILED 映射 FORBIDDEN,LAST_ADMIN/IDEMPOTENCY_EXPIRED 映射 STATE_CONFLICT。 + +## 3. 接入与运行 + +准确命令见 [开发指南 3.5–3.6](../../DEVELOPMENT.md)。默认 API_IDENTITY_ENABLED=0;设为 1 时要求: + +1. Python 3.11 业务环境,APP_ENV=dev/test;不能以此批代码开启 production。 +2. 专用回环 MySQL 8.x,迁移版本 0001_initial,CLI verify 通过;运行账户与迁移账户分离,连接串通过 DATABASE_URL_FILE 读取。 +3. 已准备的专用开发 Redis,显式 REDIS_URL;运行模式不自动创建临时数据库或 Redis,只有测试启动器创建隔离实例。 +4. CSRF_KEY_FILE 指向仅 API 可读的至少 32 个原始随机 bytes;首次排他创建,不在重启时覆盖,不复用 AI 令牌。 +5. PUBLIC_ORIGIN 为准确 HTTPS origin;本地反代在同 origin 提供前端与 API,并使用可信开发证书。仓库尚未提供登录 UI/TLS 代理联调交付。 + +/ready 在未启用时只报告 process-only 范围;开启后只核对迁移版本和 Redis ping,不代替 schema verify、不检查对象存储/Worker/巡检业务/真实模型。返回中仍有开发环境与 is_simulated 标识,不能拿它作为生产健康门禁。 + +## 4. 测试证据 + +### 4.1 可复现入口 + +| 层次 | 命令 | +|---|---| +| 单元/HTTP/领域/安全 | `python -m pytest tests/business tests/domain tests/security tests/persistence/test_unit.py -q --tb=short` | +| 真实数据库与身份集成 | 设置 LABSAFE_TEST_REDIS_SERVER 后,`python tools/database/run_mysql_tests.py --mysqld ` | +| 代码质量 | `python -m ruff check apps packages tests tools/database`;`python -m ruff format --check apps packages tests tools/database` | +| 业务/协议进程回归 | `python -m unittest discover -s tests/business -t . -v`;tests/protocol 同命令 | +| AI 独立环境回归 | AI Python 执行 `python -m unittest discover -s tests/ai -t . -v`;tests/protocol 同命令 | +| 设计/生成契约 | 独立设计依赖环境执行 `python -B tools/design/build_specs.py --check`、`test_readiness_design.py`、`validate_specs.py` | + +本地数据库启动器使用临时 data directory、随机回环端口、随机凭据和明确可丢弃库。Redis 测试夹具启动自己的临时进程,核对 PID;结束只关闭自己的进程,不连接/停止/清空操作者已有服务。 + +### 4.2 覆盖内容与修复记录 + +- 7 个 HTTP 接口与 User/UserPage/Session/Ack 响应 schema、登录 cookie、非法 Origin、CSRF、未知输入、跨租户、未授权访问、密码不回显。 +- 同 key 10 并发只创建一名用户、一条审计和一条幂等记录;成功响应字节一致。同 version 禁用竞争仅一次成功,另一请求 VERSION_CONFLICT。 +- 两名 admin 同时禁用自己或互相禁用,最终至少保留一名活跃管理员;等待后的身份和角色重新验证。 +- preflight 后撤销 session/权限,提交事务分别拒绝 401/403且不残留已提交幂等记录;已撤销会话不能重放缓存成功结果。 +- 审计失败回滚、Redis 故障无新增业务写入/会话、GET 保守降级、就绪失败、账号规范化冲突、密码上限、pending/过期 owner fencing。 +- 明确测试 Redis 调用不发生在应用数据库事务内;事务实际隔离级别为 REPEATABLE READ;登录提交后释放故障不交付 cookie,但不会假报 MySQL 回滚。 + +首轮真实数据库回归发现两项失败:MySQL JSON 重排造成重放字节不同,以及外键共享锁升级造成禁用竞争死锁。已分别修复为统一规范 JSON 响应、tenant 排他锁前置与完整事务死锁重试;不是放宽测试断言来掩盖冲突。 + +### 4.3 最终结果 + +2026-09-30 在 Python 3.11.4、独立业务/AI 环境完成最终回归,所有下列命令退出码均为 0: + +| 检查 | 本地结果与证据边界 | +|---|---| +| 单元/HTTP/领域/安全/持久化单元 | 597 passed;不连接外部服务 | +| 隔离 MySQL/Redis 持久化套件 | 67 passed,无 skipped;其中 21 个持久化单元用例与上一行重叠、46 个真实 MySQL 用例;其中 17 个为本批身份/HTTP/故障/并发集成用例 | +| 数据库结构和迁移 | MySQL 8.0.33,0001_initial,43 表、115 外键;升级/降级/重升及结构差异检测通过,最终 errors=[] | +| Ruff check / format --check | 全部通过,69 个 Python 文件格式通过 | +| 业务/协议回归 | 6 + 4 passed;6 个业务用例已包含在 597 中,不重复计为新增 | +| AI 独立环境 | 15 个 AI + 4 个协议用例通过;未引入业务/数据库/模型依赖,仍仅为开发 fixture 验收 | +| 设计生成物 | 16 份无漂移;94 个公共 operation、154 个 schema,与既有契约保持一致 | +| 就绪设计/完整设计校验 | 148 项 synthetic 检查通过;完整 PASS,2 份 OpenAPI、174 个文档链接;不能替代真人审批或真实模型性能验收 | + +完整设计校验时间 07:44 UTC(15:44 Asia/Shanghai)。设计依赖使用已有独立工具目录;未安装新依赖、未改 requirements、未刷新生成报告来掩盖差异。单元/集成套件均报告一个 Starlette/AnyIO 第三方弃用警告,不是测试失败;后续升级依赖时统一处理,不在本批擅自换版本。 + +CI 已增加 pytest 业务/领域/安全入口和独立 Redis service,并向持久化启动器传递本 job 的 Redis 随机端口;**仅完成配置与本地验证,尚无本分支远程 CI 结果**。原用户实施计划文件 SHA256 与任务前一致;未纳入提交,暂存区为空。 + +## 5. 下一批关闭清单 + +| 顺序 | 尚未交付 | 进入下一批的可执行验收条件 | +|---|---|---| +| 1 | 角色列表/授予/撤销 HTTP API | scope/tenant/laboratory 校验、重复 grant、最后管理员、改权全会话撤销、幂等重放与并发测试;复用当前认证锁序 | +| 2 | 组织/实验室/位置与巡检基础 API | tenant 过滤、父子关联、版本/状态守卫、审计、完整只读投影与受限分页 | +| 3 | 上传 grant/complete 与图片可用性 | 对象版本/SHA、真实 MIME/容量校验、不可变引用、拒绝未完成/跨 owner/跨租户图片;不在数据库事务内做对象外部调用 | +| 4 | 巡检项/事实/评估/整改命令适配 | 将 I-02A 守卫接入锁定的完整聚合写集;current 指针、版本、审计/任务/幂等原子性与查询 allowed_actions 同源 | +| 5 | I-03 持久任务闭环 | Outbox/inbox、租约/回收/fencing、Worker→AI→数据库闭环;不能用 fixture echo 替代 | + +真实 D-FINE-N/GPU 性能和识别验收仍按 I-ML-01 模型主线推进;本批未运行模型、MinIO、真实图片或生产环境。前端联调、受控反代终端 IP、生产依赖账户/密钥轮换和资源留存清理仍需相应阶段关闭。 + +## 6. 协作与回退 + +本批在现有特性分支保留代码、测试、CI 和文档;没有自动提交/Push/建 PR,也没有修改 main。用户原有 `01-implementation-plan.md` 本地表格排版未改、不纳入本批交付。后续提交与 PR 遵循 [协作规范](../../CONTRIBUTING.md),安全/权限/数据库变更仍需独立协作者审查。 + +无新迁移。开发回退先关闭 API_IDENTITY_ENABLED 并重启,只会停止该 API,不删除用户、session、幂等或审计数据;不能把关闭开关视为已撤销数据库中的会话。若未来需要代码 revert,应协调依赖该原语的后续功能;不删除审计记录,不对已有业务库执行测试 downgrade。 diff --git a/docs/08-delivery/12-i02c-role-api.md b/docs/08-delivery/12-i02c-role-api.md new file mode 100644 index 0000000..d70e421 --- /dev/null +++ b/docs/08-delivery/12-i02c-role-api.md @@ -0,0 +1,112 @@ +# I-02C 验收:角色列表、授予与撤销 API + +日期:2026-09-30。承接 I-02A/B,基线为已合并 PR #8 后的 `7fe593f`。当前分支 `wsq/i-02a-auth-api`,本批没有提交、Push、新建 PR 或合并 main。 + +状态:角色 API 与真实数据库验收通过;整个 I-02 仍在实施。本轮按用户要求同步更新[实施计划](01-implementation-plan.md)的 I-0 进度表,保留原任务验收含义,并区分已合并、本地完成与未完成。 + +## 1. 本批交付 + +| 方法与路径(前缀 /api/v1) | operationId | 请求/响应 | +|---|---|---| +| GET /users/{id}/roles | listUserRoles | page、page_size、可选 laboratory_id → 200 RoleAssignmentPage | +| POST /users/{id}/roles | grantRole | RoleGrant → 200 User | +| POST /users/{id}/revoke-role | revokeRole | RoleRevoke → 200 User | + +沿用[公共契约](../../contracts/public-api-v1.yaml),没有新增字段、响应包装、role enum 或数据库迁移。三接口均要求当前有效的 tenant 级 safety_admin;不能使用旧会话权限缓存或客户端 tenant_id 授权。加上 I-02B,身份管理现有 10 个公开接口;不是完整业务 API。 + +实现位置: + +- [HTTP 适配](../../apps/api/app/identity.py):RoleCommand 严格类型、六角色枚举、实验室 UUID、请求头/Origin、分页查询白名单与三条路由。 +- [应用服务](../../packages/application/identity.py):复用限流、事务内再认证、租户排他锁、幂等与目标用户可见性检查。 +- [角色仓储](../../packages/persistence/roles.py):角色作用域、用户版本、assignment 写入/精确删除、epoch/会话撤销及审计。 +- [权限投影](../../packages/domain/security.py):getMe/login 与授权容量守卫共用去重后的 action/laboratory 投影,避免新增授权导致用户无法登录。 +- [数据库测试](../../tests/persistence/test_roles_mysql.py)、[HTTP 边界测试](../../tests/business/test_role_boundary.py):本批回归入口;身份/Redis 夹具集中到 tests/persistence/conftest.py,仍只使用可丢弃测试实例。 + +## 2. 精确行为 + +### 2.1 请求和资源版本 + +RoleGrant 与 RoleRevoke 均要求四个字段:expected_version、role、scope_kind、laboratory_id。laboratory_id 即使为 null 也必须显式传入;expected_version 必须为 1–2147483647 的整数,不能是 bool 或字符串。路径 user_id、实验室 UUID 由适配层规范化为标准字符串。 + +expected_version 指向目标 **User.version**,不是 RoleAssignment.version。客户端先查询目标用户的版本;成功授予/撤销各递增 User.version 和 session_epoch 一次。不得自动把最新版本填入冲突请求。新 RoleAssignment 自身 version=1,撤销精确删除该 assignment,不将用户其他作用域授权一起删除。 + +两种请求都没有 reason 字段;本批不擅自改变既有契约。审计由服务端写入固定操作原因 Grant role / Revoke role,changes 包含 user_id、role、scope_kind、laboratory_id,resource_id 是对应 assignment 的 ID。未来若要求操作者填写原因,应单独评审契约变更。 + +### 2.2 作用域和状态 + +| 角色 | 合法 scope_kind | laboratory_id | +|---|---|---| +| safety_admin、rule_expert | tenant | 必须 null | +| lab_manager、inspector、remediator、viewer | laboratory | 必须同租户实验室 UUID | + +- 角色/scope 组合不合法为 422;不存在或跨租户的目标用户/实验室为 404。不存在的角色配置不是自动补建,返回 503。 +- 授予仅允许 active 用户、active 实验室;disabled 用户或 archived 实验室新增授权为 409。撤销仍可清理禁用用户或归档实验室的已有角色,但不恢复用户状态。 +- 新 key 对重复 assignment 发起授权返回 409 STATE_CONFLICT;撤销不存在的精确 assignment 返回 404。先检查 User.version,旧版本仍为 409 VERSION_CONFLICT。失败不递增版本、不撤销会话、不提交幂等或审计。 +- 同租户有效 admin 能查看禁用用户/归档实验室的实际授权,以便清理。可选 laboratory_id 仅筛选该实验室 assignment,不混入 tenant 级角色;跨租户筛选在 COUNT 前拒绝为 404。 +- 列表默认 page=1、page_size=20,最大 100,排序 created_at DESC,id DESC;总数和页数据在同一 REPEATABLE READ 快照,未知/重复 query 为 422,不承诺跨页冻结。 +- 授予后的去重 Session.permissions 最多 200 个 action/laboratory 对;超出返回 409 并原子回滚,不能截断权限清单,也不能仅限制 assignment 数量代替投影容量。 + +### 2.3 锁、幂等与撤权 + +1. 沿用 I-02B 的有效会话/CSRF/admin 短预检与事务外 Redis 限流。 +2. 提交事务先定位 tenant/actor,取得 tenant 排他锁,再 claim 幂等记录;避免幂等外键共享锁升级排他锁的死锁。随后重新认证/授权/CSRF,并锁定目标用户。所有操作在同一个应用事务中提交或回滚。 +3. completed 幂等记录也要重验有效会话、admin 与目标用户可见性;只有合法重放才返回原 HTTP 状态、request_id 和规范 JSON 响应。不同正文仍为 IDEMPOTENCY_CONFLICT。 +4. 新命令检查目标用户版本、合法作用域、assignment 状态和最后管理员约束,再写角色、version/epoch、撤销该用户全部会话、追加审计、保存幂等响应。 +5. 最后一名 active safety_admin 不能被撤销;tenant 排他锁与当前锁定读用于防止两名管理员同时自撤权、互相撤权导致零管理员。等待锁后 actor 权限也须重新加载。 +6. 沿用完整事务死锁最多 3 次重试与锁超时 409/Retry-After:2,不重发 Redis 外部操作,不篡改客户端版本。没有契约对应的角色 Outbox 事件,本批不臆造消息。 + +修改自身角色时,当前 session 也会被撤销。首次成功可以返回更新后的 User,但旧 cookie 的任何后续访问或缓存重放都为 401;必须重新登录。重新登录后仍具 admin 权限的同 actor 可重放原 key,不再修改角色;已丧失 admin 的 actor 不得取得缓存成功响应。 + +tenant 排他锁刻意串行化该租户的身份管理写入;这是正确性保护,不是高并发吞吐证明。远端数据库、生产吞吐、可信反代、密钥轮换与完整服务探针均未在本批放行。 + +## 3. 接入方式 + +沿用[开发指南 3.6](../../DEVELOPMENT.md)的 API_IDENTITY_ENABLED=1、dev/test、专用回环 MySQL/Redis、HTTPS PUBLIC_ORIGIN 和 CSRF_KEY_FILE,无新增环境变量/依赖。没有独立角色开关;仍遵守 Secure Cookie、同源 JSON、单一 X-CSRF-Token 和 Idempotency-Key。 + +调用顺序:管理员登录 → GET /users/{id} 读取目标版本 → POST /users/{id}/roles 或 /revoke-role → 使用返回的 User.version 作为后续新意图的版本。重试同一网络请求保留 key 和正文;同一角色在另一实验室是独立 assignment。跨实验室测试用的组织数据来自显式 synthetic fixture,本批没有偷偷实现组织创建 API。 + +前端可用 GET /users/{id}/roles 展示实际 scope;它不是 Session.permissions 的替代或客户端授权凭据。当前仓库尚未实现角色管理 UI。 + +## 4. 验证与证据 + +### 4.1 执行命令 + +~~~powershell +python -m pytest tests/business tests/domain tests/security tests/persistence/test_unit.py -q --tb=short +$env:LABSAFE_TEST_REDIS_SERVER = '<本机 redis-server 可执行文件>' +python tools/database/run_mysql_tests.py --mysqld '<本机 mysqld 可执行文件>' +python -m ruff check apps packages tests tools/database +python -m ruff format --check apps packages tests tools/database +~~~ + +MySQL 启动器创建临时目录、随机回环端口与凭据,并核对可丢弃 schema;Redis fixture 核对自己启动的 PID。结束只关闭自己创建的进程,不操作已有服务。CI 继续复用原 persistence job 和独立 Redis 容器;未取得本分支远程 CI 结果。 + +### 4.2 本地结果 + +| 检查 | 实际结果 | +|---|---| +| 累计单元/HTTP边界/领域/安全/持久化单元 | 612 passed;相对 I-02B 新增 15 个用例 | +| 隔离持久化套件 | 93 passed,无 skipped;含原 67 项和新增 26 项角色集成用例 | +| 真实数据库证据 | MySQL 8.0.33、0001_initial、43 表、115 外键,结构 errors=[];93 项中 21 项为与上一行重叠的单元用例、72 项为真实 MySQL 用例 | +| 静态检查 | Ruff check 全部通过;format --check 为 72 个 Python 文件通过 | +| 业务进程/协议 | 6 个业务进程/启动 smoke 已包含在上述 612 项内;业务环境另跑 4 个协议用例通过 | +| AI 独立环境 | 15 个 AI fixture + 4 个协议用例通过,含独立 HTTP 进程及不导入业务/模型依赖检查;不声称真实模型运行 | +| 设计/生成契约 | 16 份生成物无漂移;148 项 synthetic 就绪检查;完整设计校验 PASS,2 份 OpenAPI、193 个文档链接 | + +覆盖六种角色、八种非法 role/scope 组合、跨租户/实验室、无 admin 权限、分页排序/筛选、精确删除、禁用/归档清理、登录权限更新、全部旧会话撤销、同 key 重放/正文冲突、新 key 重复授权、过期版本、同 key 十并发与同版本双并发、双 admin 自撤/互撤、审计故障回滚,以及 200 个权限投影容量边界。 + +最终集中检查日期为 2026-09-30,Python 3.11.4;完整设计校验时间 08:23 UTC(16:23 Asia/Shanghai)。所有记录为本轮实际执行且退出码 0 的结果;两个套件均含 21 个持久化单元用例,不能将 612 与 93 直接相加作为唯一用例数。设计检查的 synthetic 数据不是实际 GPU、对象存储、反向代理或真人审批证据。 + +本批曾因工具自动审批服务返回 503,导致两次数据库命令与一次文档 patch **未执行**;权限服务恢复后重新执行。最终 93 项隔离持久化套件完整通过,未绕过审批或改为连接用户现有数据库。第三方 Starlette/AnyIO 弃用警告与 I-02B 相同,没有擅自变更依赖版本。 + +## 5. 未完成范围与下一步 + +整个 I-02 仍未完成。下一批优先组织/实验室/位置与巡检基础 API,随后实现上传 grant/complete、对象版本/SHA 验证及事实/评估/整改完整事务适配。I-02A 守卫必须接入服务端加载的完整可信聚合和同源 allowed_actions,不能以本批角色 CRUD 替代业务闭环。 + +Outbox/inbox、持久任务与 Worker→AI 闭环仍属 I-03;D-FINE-N/GPU 真实运行和识别验收仍属 I-ML 主线。角色安全 API 本地通过不等于生产批准、真人独立安全评审或前端联调完成。 + +## 6. 回退与协作 + +没有新迁移。紧急开发回退可关闭 API_IDENTITY_ENABLED 并重启,停止整个身份 API;这不会自动恢复撤销的角色或会话,也不能代替数据补偿。角色恢复必须由仍有效的 admin 以当前版本执行明确的新授权意图,产生新审计;不能恢复旧会话 token、删除审计或手动回拨 epoch。 + +本轮已只读 fetch origin,HEAD 与 origin/main 无提交差异;本地 I-02A/B/C 改动仍未提交。实施计划原有排版此次按用户授权统一表格并新增进度列,不再宣称该文件保持任务前哈希。后续提交/Push/PR 需用户授权,遵守[协作规范](../../CONTRIBUTING.md),安全变更须独立协作者审查。 diff --git a/docs/08-delivery/13-i02d-foundation-api.md b/docs/08-delivery/13-i02d-foundation-api.md new file mode 100644 index 0000000..faa5b19 --- /dev/null +++ b/docs/08-delivery/13-i02d-foundation-api.md @@ -0,0 +1,158 @@ +# I-02D 验收:组织、位置、模板与巡检草稿 API + +日期:2026-09-30。承接 I-02A/B/C,基线为 `7fe593f`,仍在 `wsq/i-02a-auth-api`。本批没有提交、Push、新建 PR 或合并 main。 + +状态:本批实现及本地回归完成,最终成绩见第 5 节;未提交或取得本批远程 CI/独立评审。整个 I-02 尚未完成,不将巡检草稿当作上传、推理、复核和整改闭环。 + +## 1. 交付边界 + +### 1.1 本批 16 个接口 + +所有路径带 `/api/v1` 前缀;沿用[公共契约](../../contracts/public-api-v1.yaml),没有修改生成的 Schema、错误码目录或数据库迁移。 + +| 方法与路径 | operationId | 成功响应 | 权限 | +|---|---|---|---| +| GET /colleges | listColleges | 200 CollegePage | read,公共投影 | +| GET /colleges/{id} | getCollege | 200 College | read,公共投影 | +| POST /colleges | createCollege | 201 College | admin | +| GET /laboratories | listLaboratories | 200 LaboratoryPage | read,按实验室范围 | +| GET /laboratories/{id} | getLaboratory | 200 Laboratory | read,按实验室范围 | +| POST /laboratories | createLaboratory | 201 Laboratory | admin | +| GET /laboratories/{id}/locations | listLocations | 200 LocationPage | read,按路径实验室 | +| POST /laboratories/{id}/locations | createLocation | 201 Location | admin | +| GET /templates | listTemplates | 200 TemplatePage | read,普通角色仅 published | +| GET /templates/{id} | getTemplate | 200 Template | read,普通角色仅 published | +| POST /templates | createTemplate | 201 Template | admin | +| POST /templates/{id}/publish | publishTemplate | 200 Template | admin | +| POST /templates/{id}/clone | cloneTemplate | 201 Template | admin | +| GET /inspections | listInspections | 200 InspectionPage | read,按实验室范围 | +| GET /inspections/{id} | getInspection | 200 Inspection | read,按实验室范围 | +| POST /inspections | createInspection | 201 Inspection | capture,按实验室范围 | + +结合 I-02B/C 的 10 个身份/角色接口,目前实现 26 个公共业务接口;不包含进程探针,也不代表完整公共契约已实现。 + +### 1.2 代码职责 + +| 层次 | 实现文件 | 职责 | +|---|---|---| +| HTTP | [foundations.py](../../apps/api/app/foundations.py) | 严格 DTO、UUID、query 白名单、路由及响应 | +| 应用 | [foundations.py](../../packages/application/foundations.py) | 认证/限流、明确命令分发、事务、幂等和重放授权 | +| 领域 | [foundations.py](../../packages/domain/foundations.py) | 位置父类型、active、模板唯一性、巡检容量守卫 | +| 持久化 | [organizations.py](../../packages/persistence/organizations.py) | 组织与位置的白名单读投影和创建 | +| 持久化 | [templates.py](../../packages/persistence/templates.py) | 模板及子项、发布、family 版本分配与克隆 | +| 持久化 | [inspections.py](../../packages/persistence/inspections.py) | 巡检与全部草稿子项的原子创建 | +| 公共 SQL 辅助 | [foundations.py](../../packages/persistence/foundations.py) | 固定 SQL 的范围过滤、分页、文本和日期投影 | + +HTTP 不执行 SQL;表名/列名只来自服务端固定集合,客户端不能指定任意表或 tenant。身份事务、Redis 限流、幂等和审计复用前几批,不另建一套会话协议。 + +## 2. 输入、权限和可见性 + +### 2.1 严格输入 + +- 所有 DTO 禁止未知字段和隐式类型转换;`required` 必须 bool,`sort_order` 是 0–2147483647 的整数,不能用 bool 或字符串代替。 +- UUID 正文采用规范带连字符形式并转小写;`LocationCreate.parent_id` 即使为 null 也必须显式传入。模板子项 ID 按既有契约由创建请求传入;模板、组织、位置、巡检及巡检项 ID 由服务器生成。 +- `TemplateCreate.items` 为 1–100 项,id/code/sort_order 在模板内分别唯一;文本 NFC 规范化并去除首尾空白后再次检查 code 唯一性。空文本、控制字符或超长输入为 422。 +- 同租户重复学院/实验室 code,或与已有模板子项主键碰撞,为 409 STATE_CONFLICT;不能改写已有资源。失败回滚业务行、子项、审计和幂等 claim。 +- publish/clone 使用 `VersionCommand`:expected_version 指向**所选源模板的 version**,不是 family revision;reason 保存在审计中。 + +### 2.2 授权查询 + +任一有效角色可读取同租户 active 学院的 College 白名单字段,将其作为组织选择器公共投影;没有发布动作的学院以 active 表示可公开,archived 仅 admin 可读。无角色用户不能读取该投影。该规则不外推到用户信息、实验室业务数据或其他租户配置。 + +普通角色只能读取 published 模板;admin 可读取 draft/published/retired。rule_expert 的 tenant 角色只获得这些公共投影,不因此拥有全实验室读取权限。实验室、位置和巡检始终先按当前授权范围过滤,再做 COUNT 和分页;未知/跨租户/范围外资源为 404,没有任何实验室授权的无过滤列表为 403。 + +学院、模板列表拒绝 laboratory_id;实验室、巡检列表可按 laboratory_id 筛选;位置列表使用路径中的实验室,不接受同名 query 覆盖。未知/重复 query 为 422。默认 page=1、page_size=20,最大 100;created_at DESC,id DESC,COUNT 与页数据同一 REPEATABLE READ 快照,但不同页请求不冻结成同一视图。 + +## 3. 精确命令行为 + +### 3.1 组织与位置 + +创建实验室要求同租户 active 学院;创建位置要求 active 实验室及同实验室 active 父位置。父类型只有以下组合合法:room 无 parent;area 的 parent 为 room;shelf/cabinet 的 parent 为 room 或 area。 + +本批没有移动、改父节点或删除位置接口。新 UUID 仅能引用已有且类型合法的父节点,因此不能形成环;后续如果增加移动接口,必须单独实现完整祖先链校验,不能沿用“新建不会成环”的证明。 + +### 3.2 模板 + +1. create 建立新 family:family_id=新模板 id、revision=1、version=1、status=draft,并一次写入全部子项。 +2. publish 锁定源模板,校验 expected_version、draft 状态和子项,再改为 published、version+1,保存用户 reason。正文不变。 +3. clone 先根据源模板取得 family_id,锁 family 根,再锁源模板并校验 expected_version;当前锁定读 family revision 后分配 max+1。 +4. clone 创建新 draft、version=1,复制正文并生成全部新的子项 ID;源模板的正文、status、version 均不变化。同 family 从不同源 revision 并发克隆也共享根锁。revision 超出公共整数上限则 409。 + +已发布模板不改正文;本批无模板编辑/删除/retire API。需要新的采集模板时创建新 draft,或按现有 clone 契约建立同正文新 revision;不能假定已有未实现的编辑端点。 + +### 3.3 巡检草稿 + +服务器从当前 session 确定 inspector_id,从模板和位置记录加载可信状态;客户端不能设置巡检状态、创建人或任意子项。 + +创建前验证 capture 权限、active 实验室、published 模板、1–100 个互异位置及全部位置同实验室且 active;模板项数×位置数必须 ≤100。非 admin 看不到未发布模板时返回 404,admin 使用 draft 模板创建则为 409;重复位置或超容量为 422。 + +同一个事务创建一个 draft inspection 与 template_items×location_ids 的全部 draft inspection_items,submission_revision=0,current run/fact/evaluation 和人工结论均为空。包括 required=false 的模板项,不自动省略可选项。Inspection.item_ids 与真实子项一致,巡检固定引用本次模板 ID,不追随 family 的后续 clone。 + +本批只返回 Inspection 契约。尚未实现 InspectionItem 查询,因此不伪造 allowed_actions;完成和取消巡检也未接入 HTTP。 + +## 4. 事务、幂等与故障修复 + +### 4.1 提交边界 + +短事务认证/CSRF 预检 → 事务外 Redis 限流 → 提交事务定位 tenant/actor 并 claim 幂等 → 重新认证、CSRF 与当前权限 → 锁定业务对象/校验 → 业务及全部子项、审计、幂等响应一并提交。数据库失败整事务回滚;Redis 写限流不可用则 503,不开始业务写入。 + +本批普通基础命令仅持 tenant 共享认证锁,模板 clone 以 family 根为业务互斥锁,不将所有业务写入升级成 tenant 排他锁。改权/禁用仍沿用前批 tenant 排他锁和当前权限重载。基础创建没有对应已定义的 Outbox 事件,不臆造任务;I-03 持久任务仍未完成。 + +同 key/正文重放保留原状态、request_id 和规范 JSON 字节,不重复资源或审计。重放前验证当前会话、动作权限和结果资源可见性;重放授权采用当前锁定读,避免等待竞争请求提交后仍从旧 RR 快照读取“资源不存在”。 + +重放不再次执行版本/状态前置条件;例如原巡检创建成功后模板被退休,只要该 actor 仍具有对应实验室 capture/read 权限,旧 key 返回原成功响应,新 key 创建仍拒绝。撤权后旧 session 为 401,重新登录后无 capture/范围则不得取缓存成功响应。 + +死锁最多重跑完整事务 3 次;锁超时/正在执行返回 409 REQUEST_IN_PROGRESS、Retry-After:2,保留原 key 重试,不能自动刷新 expected_version。 + +### 4.2 本轮发现并修复的会话锁竞争 + +首次四会话并发克隆测试出现部分 REQUEST_IN_PROGRESS。原因是 authenticate 先 FOR SHARE 读用户权限,随后 sessions JOIN users FOR UPDATE 又把同一用户行升级为排他锁,多会话持 S 后竞争升级造成死锁。 + +修复为用户/角色及 epoch 保持共享当前读,只有本次 session 行 FOR UPDATE;tenant→user/roles→session 的顺序和改权排他保护不变。新增同步屏障测试强制四个事务同时持用户共享锁,然后继续认证,不能靠偶然串行通过;并回归全部身份、撤权、最后管理员竞争用例。 + +## 5. 验证与证据 + +### 5.1 执行入口 + +~~~powershell +python -m ruff check apps packages tests tools/database +python -m ruff format --check apps packages tests tools/database +python -m pytest tests/business tests/domain tests/security tests/persistence/test_unit.py -q --tb=short +$env:LABSAFE_TEST_REDIS_SERVER = '<本机 redis-server 可执行文件>' +python tools/database/run_mysql_tests.py --mysqld '<本机 mysqld 可执行文件>' +python -B tools/design/build_specs.py --check +python -B tools/design/test_readiness_design.py +python -B tools/design/validate_specs.py +~~~ + +设计检查使用独立工具依赖环境。数据库启动器只创建随机端口、随机凭据、独立临时目录中的可丢弃 MySQL;Redis fixture 同样核对自建子进程 PID,不接管、清空或关闭已有服务。CI 仍复用现有持久化测试入口。 + +### 5.2 本地成绩 + +| 检查 | 实际结果 | +|---|---| +| 累计单元/HTTP边界/领域/安全/持久化单元 | 691 passed;相对 I-02C 新增 79 项,其中领域 47 项、HTTP 边界 32 项 | +| 隔离持久化套件 | 127 passed、无 skipped;相对 I-02C 新增 34 项,最终完整执行退出码 0 | +| 真实数据库范围 | MySQL 8.0.33、0001_initial、43 表、115 外键,结构 errors=[];127 项中 106 项为真实 MySQL 用例,21 项为重复的持久化单元用例 | +| 静态检查 | Ruff check 全部通过;format --check 为 82 个 Python 文件通过 | +| 进程与协议 | 业务环境协议 4 项通过;独立 AI 环境 fixture 15 项、协议 4 项通过;业务进程 smoke 已计入 691 项 | +| 契约与文档 | 16 份生成物无漂移、148 项 synthetic 就绪检查、完整设计校验 PASS、2 份 OpenAPI、207 个文档链接 | +| 实际路由与 Git | 实际注册 26 个公共业务 operationId;git diff --check 通过;暂存区为空、未提交/Push/新 PR | + +本轮为 2026-09-30、Python 3.11.4 的实际本地执行结果。两个主套件重叠 21 个持久化单元用例,不能直接将 691 与 127 相加作为唯一测试数;协议用例也在业务/AI 两环境重复验证。未运行远程 CI、真实 GPU 或完整生产部署。 + +本批覆盖父类型矩阵/归档父节点、完整创建链路及公开响应 Schema、普通角色/无角色/跨租户/跨实验室拒绝、授权后统计、分页同时间排序与 COUNT 后并发插入的快照一致性、模板重复字段/规范化冲突、发布/克隆版本与审计原因、100 项容量、子项/审计故障全回滚、三类资源同 key 十并发、同/不同源 revision 的四会话并发克隆、四会话同步认证、预检后撤会话拒绝提交、撤权后重放拒绝及 Redis 写失败封闭。 + +测试入口为 `tests/domain/test_foundations.py`、`tests/business/test_foundation_boundary.py`、`tests/persistence/test_foundations_mysql.py`;已有身份/角色/结构/安全套件同时回归。首次会话锁竞争按 4.2 修复;扩展测试另修正了测试夹具误用 NullPool 不存在的 checkedout 接口,改为直接跟踪应用事务活动数,未改弱事务外限流断言。最终所有 127 项重新完整通过。保留第三方 Starlette/AnyIO 已有弃用警告,未擅自升级依赖。 + +## 6. 接入、剩余工作和回退 + +沿用[开发指南 3.6](../../DEVELOPMENT.md)的 dev/test、API_IDENTITY_ENABLED=1、PUBLIC_ORIGIN、CSRF_KEY_FILE 与专用 MySQL/Redis,无新增开关、依赖或迁移。默认不开启;未配置的基础接口明确 503。/ready 仍仅是局部数据库版本及 Redis 检查,不声称对象存储、完整业务、Worker 或真实模型就绪。 + +浏览器仍需同源 HTTPS、Secure session cookie;写请求需要 Origin、JSON、单个 X-CSRF-Token 和 Idempotency-Key。模板创建正文上限为 2 MiB,以容纳合法 100 项完整 Unicode/转义 JSON;其他本批小请求仍为 64 KiB。超过该临时开发请求预算按既有 VALIDATION_ERROR/422 拒绝,不借用专属图片的 IMAGE_TOO_LARGE 错误码;未来上传/import 单独实现契约。 + +调用顺序:管理员登录 → 建学院/实验室/room 和所需子位置 → 创建模板 → 用返回 version 发布 → 为采集用户授权 inspector 并重新登录 → 使用已发布 template_id 和同实验室 location_ids 创建巡检 → 查询草稿及 item_ids。前端完整采集 UI 尚未实现。 + +下一批优先补巡检项查询与服务端同源 allowed_actions,再实现上传 grant/complete 及对象版本/SHA 校验。巡检 complete/cancel 必须联动完整聚合、run/task fencing 与历史确认记录,不作为本批草稿创建的隐含能力。Outbox/Worker→独立 AI 属 I-03,D-FINE-N/GPU 属 I-ML,均未由本批放行。 + +开发回退可关闭 API_IDENTITY_ENABLED 并重启,停止身份及基础接口;不会删除已建组织/模板/巡检,不回拨版本,不恢复旧 session,也不删除审计。无新迁移,不需要改 schema。后续提交、Push、PR 需用户授权,遵守[Git 协作规范](../../CONTRIBUTING.md),本轮安全修复需独立评审。 diff --git a/docs/08-delivery/14-i02e-item-query-api.md b/docs/08-delivery/14-i02e-item-query-api.md new file mode 100644 index 0000000..a2b20cd --- /dev/null +++ b/docs/08-delivery/14-i02e-item-query-api.md @@ -0,0 +1,129 @@ +# I-02E 巡检项查询与动作投影基础验收 + +状态(2026-09-30):本批已通过本地实现与验证,尚未提交、Push 或新建 PR。本批仅交付两个查询接口及同源守卫探测基础,不代表整个 I-02 或完整动作适配已经完成。 + +## 1. 交付范围 + +| operationId | 方法与路径 | 返回 data | 查询参数 | +|---|---|---|---| +| listInspectionItems | GET /api/v1/inspections/{inspection_id}/items | InspectionItemPage | page、page_size、laboratory_id | +| getInspectionItem | GET /api/v1/inspection-items/{item_id} | InspectionItem | 无 | + +路由、字段、枚举沿用[公共契约](../../contracts/public-api-v1.yaml),不新增或修改生成契约、数据库表或迁移。列表成功 200、默认 page=1/page_size=20、page≤2147483647、page_size≤100,按 created_at DESC,id DESC 排序。未知或重复 query、非法 UUID/分页为 422;详情不接受任何 query。可选 laboratory_id 不能被忽略,也不作为授权依据。 + +新增两个 operationId 后,累计注册 28 个公共业务 API:身份/用户 7、角色 3、基础业务 16、巡检项查询 2。上传、提交、重试、完成巡检项以及完成/取消巡检均未接入,本批没有写命令、对象调用或推理任务。 + +## 2. 实现入口与调用顺序 + +| 层 | 文件 / 入口 | 职责 | +|---|---|---| +| HTTP | apps/api/app/item_queries.py / mount_item_queries | 严格 query、UUID 规范化、session cookie、request_id,分发查询 | +| 应用 | packages/application/item_queries.py / ItemQueryApplication.read | 身份预检、事务外读限流、权威读事务与有界死锁重试 | +| 仓储 | packages/persistence/inspection_items.py / InspectionItemRepository | tenant/父巡检/实验室授权、COUNT/分页快照、公开字段白名单 | +| 领域 | packages/domain/item_actions.py / allowed_item_actions | 在明确启用且上下文齐备时,调用原 submit/retry/complete 守卫 | + +执行顺序:短事务身份预检结束 → Redis 读限流 → 新建 REPEATABLE READ 事务 → 重新认证并加载当前角色 → 查询/授权父巡检或巡检项 → 可选实验室筛选授权 → COUNT 与页数据 → 白名单投影。详情同样在重新认证后的事务中查询,不复用预检的角色快照。 + +认证继续使用 I-02D 的 tenant/user/roles/epoch 共享当前读及当前 session 行锁;业务父巡检、子项、筛选实验室采用普通一致性读,不混用业务锁定读与旧 RR 快照。成功读取可更新既有会话空闲时间,但不得改巡检、子项、版本、事实、评估、Finding、审计、幂等、Outbox 或任务。 + +事务外 Redis 不可用沿用受限本地读限流;不把此降级扩展到写接口。预检后会话被撤销,读事务必须返回 401。1213 死锁最多重试三个完整读事务,每次重新认证,不重复收费;1205/3572 沿用 409 REQUEST_IN_PROGRESS、Retry-After:2。数据库异常按共享边界脱敏为 503,未预期内部异常为 500,不回显 SQL、连接串或调用栈。 + +## 3. 可见性、分页与公开字段 + +### 3.1 授权和空页的区别 + +- tenant 仅来自服务器 session;客户端 tenant_id query 为 422。跨 tenant ID、无实验室范围、不存在的父巡检/子项均为 404。 +- safety_admin 可读同 tenant;inspector、lab_manager、remediator、viewer 仅可读已授权实验室。只有 rule_expert 或无角色不能因此读取实验室巡检项。 +- 列表先查父巡检并授权,再做 COUNT;不能以 item 列表为空隐藏父对象不存在,也不能先返回未授权 total。JOIN 同时约束父子 tenant、inspection_id 和 laboratory_id。 +- laboratory_id 指向不存在/不可见实验室为 404;同时可见但与父巡检实验室不一致为 200 空页。筛选不能扩展父巡检范围。 +- 已授权的空父巡检和超过最后一页均为 200 空页,后者保留真实 total。当前创建 API 始终原子创建子项,空父仅由标注的合成数据库夹具验证查询语义。 +- 九种子项状态以及 completed/cancelled 父巡检仍可按权限读取;状态终结不等于删除历史。 + +COUNT、页数据及状态使用同一请求内快照;COUNT 后其他事务插入/更新不影响本次结果,下个请求可见新状态。分页不是跨请求冻结快照;100 项容量、同时间 id 降序、页中间位置与末尾空页分别验证。 + +### 3.2 严格响应投影 + +仅返回 id、created_at、updated_at、version、inspection_id、laboratory_id、location_id、status、submission_revision、current_run_id、current_fact_revision_id、review_outcome、allowed_actions。日期使用 UTC RFC3339 毫秒。 + +不得泄漏 tenant_id、template_item_id、current_evaluation_id、inspector_id/creator_id、父巡检状态、reviewed_by/at 或数据库其他列。current run/fact 可为真实 nullable 指针;测试同时验证 null 草稿及合法外键历史非 null 投影。合成历史不是实际模型执行或人工完成命令的验收证据。 + +## 4. allowed_actions 的精确阶段语义 + +### 4.1 本批 HTTP 始终返回空数组 + +当前未注册任何巡检项写路由;仓储固定传入空 capability 集和未加载上下文,因此所有状态都返回 `allowed_actions=[]`。这是服务当前没有可执行写能力,不表示该状态永远禁止业务动作。前端不得从状态自行补按钮,也不得把此列表当离线授权凭据。 + +本批完成的是纯领域探测入口与查询的安全能力门禁;**没有完成可信完整聚合加载器、上传或命令事务,不能称作“提交/重试/完成已可用”**。未加载 evaluation 时不能把 has_unknown 默认为 false;零 Finding 不意味着 no_issue,不能由初始 AI outcome 推导当前 U。 + +### 4.2 领域入口约定 + +`allowed_item_actions(actor, item, context, *, enabled)` 只接受服务器已授权快照;入口先检查 tenant/READ 权限。enabled 只能含下表 operationId,未知值抛配置错误;不从 HTTP query 或客户端 JSON 接收。 + +| 候选动作 | 必须加载的上下文 | 同源判断 | +|---|---|---| +| submitInspectionItem | current_findings、images、selections | 直接调用 submit_item;验证采集人、图片 owner/location/状态、顺序、overview/detail 及确认风险阻断 | +| retryInspectionItem | current_findings、images、old_run | 直接调用 retry_item;验证当前失败 run、固定制品/设备/reference_date、完整旧图片清单 | +| completeInspectionItem | current_findings、evaluation | 分别探测三个合法 ReviewOutcome 是否至少一个能通过 complete_item;不向调用者虚构人工结论 | + +ItemActionContext 为 frozen dataclass。`None` 表示未加载,`()` 只表示服务器已核验的穷尽空集合;二者不能互换。候选动作只有同时具备 capability、全部所需上下文且原守卫通过才输出。输出去重并按 operationId 稳定排序。探测是纯读,不改快照或数据库。 + +complete 探测用当前 version 和固定非空探测原因判断“是否存在合法结论”;真实命令仍须重新验证用户提交的 version、outcome、reason。U 优先于 C,未复核 Finding 阻断全部完成;保留旧守卫全部语义,不复制另一套状态表。仅把 403/404/409/422 业务守卫拒绝视为动作不可用;其他异常继续抛出,不把系统故障伪装成空按钮列表。 + +### 4.3 后续启用动作的关闭清单 + +1. 接通对应公共命令路由及唯一应用事务入口,具备当前权限、CSRF、幂等、版本、完整锁集、审计与任务规则。 +2. 在服务器可信快照中加载完整 findings、图像/选图或当前 run/evaluation;未知与空值语义不可合并,不能由请求伪造 owner、scope、U 或状态。 +3. complete 之前固定并校验当前 rule_evaluations.result 的持久化结果结构,接通从当前事实/评估计算 U 的加载器;不猜测 JSON 格式,不以缺字段或空结果默认安全。 +4. 在同一批变更中启用对应 capability,补齐查询动作与真实命令的同源正反例、并发变化后的二次守卫、失败原子回滚。只改 capability 常量不构成完成。 + +## 5. 本地验证 + +### 5.1 执行命令 + +在开发指南规定的业务 Python 3.11 环境,从仓库根目录执行: + +~~~powershell +python -m ruff check apps packages tests tools/database +python -m ruff format --check apps packages tests tools/database +python -m pytest tests/business tests/domain tests/security tests/persistence/test_unit.py -q --tb=short +$env:LABSAFE_TEST_REDIS_SERVER = '<专用测试 redis-server 可执行文件>' +python tools/database/run_mysql_tests.py --mysqld '' +python -m unittest discover -s tests/protocol -t . -v +python -B tools/design/build_specs.py --check +python -B tools/design/test_readiness_design.py +python -B tools/design/validate_specs.py +~~~ + +设计校验依赖独立工具环境;AI fixture/协议须另外在独立 AI Python 环境运行开发指南的测试。持久化启动器创建随机端口、随机凭据的临时 MySQL,Redis fixture 核对自身子进程 PID;不接管已有服务。不得将直接 pytest 跳过 MySQL 的结果记为数据库通过。 + +### 5.2 实际结果 + +| 检查 | 实际结果 | +|---|---| +| 累计单元/HTTP/领域/安全/持久化单元 | 961 passed;本批新增 270 项,其中领域 235、HTTP 边界 28、应用边界 7 | +| 完整隔离持久化套件 | 154 passed、无 skipped,最终完整执行退出码 0;本批新增 27 个真实数据库用例 | +| 数据库范围 | MySQL 8.0.33、0001_initial、43 表、115 外键、结构 errors=[];154 项中 133 项为真实 MySQL 用例,21 项为重复的持久化单元 | +| 静态检查 | Ruff check PASS;90 个 Python 文件 format --check PASS | +| 进程/协议 | 业务环境协议 4 项;独立 AI 环境 fixture 15 项、协议 4 项通过;业务进程 smoke 已计入 961 项 | +| 设计/文档 | 16 份生成物无漂移;148 项 synthetic readiness checks;完整设计 PASS、2 份 OpenAPI、214 个文档链接 | +| 路由/Git | 实际注册 28 个公共业务 operationId;巡检项写路由为 0;git diff --check 通过,暂存区为空 | + +以上为 2026-09-30、Python 3.11.4 的实际本地结果。961 与 154 重叠 21 项,不能直接相加作为唯一测试数;两环境协议测试也有重复。保留第三方 Starlette/AnyIO 已有弃用警告;没有运行本分支远程 CI、真实 GPU/模型、对象存储、浏览器 TLS 联调或生产部署,亦无独立审查批准。 + +新增测试文件:tests/domain/test_item_actions.py、tests/business/test_item_query_boundary.py、tests/business/test_item_query_application.py、tests/persistence/test_item_queries_mysql.py。领域矩阵比较原守卫,覆盖缺上下文、未启用、U/C/未复核、跨范围及异常不吞;HTTP 验 query/UUID、错误/缓存边界;真实数据库覆盖组织→模板→巡检→查询、各角色/跨 tenant/实验室、COUNT 前授权、空页、九状态、历史指针、并发快照、撤会话/改权及读限流。 + +初次错误封装测试误把人为构造的 ServiceError 消息当作底层异常;已按既有约定分别验证安全领域消息、SQLAlchemy 异常和内部 RuntimeError 脱敏,没有改弱共享安全处理。首轮持久化结果为 152 passed/1 failed,原因是历史夹具使用了非法人工结论;已改为契约枚举 no_issue,不新增 safe 枚举。 + +复核还发现可选实验室筛选复用了组织仓储的 FOR SHARE 当前读;本批改为普通一致性 SELECT,并在并发插入/更新快照测试中断言所有业务 SELECT 均无 FOR SHARE/FOR UPDATE,保留认证所需的既有锁。未改动前批组织命令锁。 + +并行启动第二个临时 MySQL 曾在初始化时退出,未运行任何用例;检查时 C 盘仅余约 315 MiB,最终完整回归改用项目内被 Git 忽略的 D 盘 `.pytest_cache` 下随机临时目录,进程级 TEMP/TMP 不修改系统设置、不清理用户文件。初始化失败不计作测试通过;全部修正后重新完整运行 154 项通过,不以首轮的部分通过抵扣。 + +## 6. 接入、剩余工作与回退 + +沿用[开发指南 3.6](../../DEVELOPMENT.md)的 APP_ENV=dev/test、API_IDENTITY_ENABLED=1、专用 MySQL/Redis、HTTPS 同源与 Secure session cookie;无新增配置、依赖或迁移。未配置返回 503。读请求不要求写用 CSRF/幂等头,所有响应 no-store,错误带 request_id。 + +调用路径:登录 → I-02D 创建或查询巡检 → 用 inspection_id 列出巡检项 → 用 item_id 查询详情。前端完整采集界面尚未完成;/ready 仍只检查已接入部分的迁移版本和 Redis,不证明存储、任务或模型就绪。 + +下一批优先实现上传 grant/complete、对象版本与 SHA 固定/校验、图像验证任务;再接提交/重试/完成的完整聚合事务和可信上下文加载。Outbox/Worker/独立 AI 闭环属 I-03;D-FINE-N/GPU 实际执行属 I-ML,当前 production 保护不变。 + +回退可关闭 API_IDENTITY_ENABLED 并重启,停用已接入身份/业务路由;不删巡检或审计,不恢复已撤会话,无 schema 回退。后续提交、Push、PR 按[协作规范](../../CONTRIBUTING.md)并经用户授权,本轮不执行。 diff --git a/docs/08-delivery/15-i02f1-upload-grant-api.md b/docs/08-delivery/15-i02f1-upload-grant-api.md new file mode 100644 index 0000000..710d741 --- /dev/null +++ b/docs/08-delivery/15-i02f1-upload-grant-api.md @@ -0,0 +1,161 @@ +# I-02F1 上传授权与 S3 适配基础验收 + +日期:2026-10-01。状态:本地实现及验证完成;仅 dev/test,未提交、Push 或创建新 PR。历史 I-02A–E 成绩保持原记录,不以本批局部通过替代整个 I-02。 + +## 1. 本批交付边界 + +| 层次 | 本批交付 | 明确不包含 | +|---|---|---| +| 公共 API | POST /api/v1/uploads,operationId=createUpload,成功 201 | completeUpload/getImage、图像下载/删除、巡检项提交/重试/完成 | +| 领域/应用/仓储 | owner 采集授权、元数据守卫、双额度、原子 grant/审计/幂等 | image 创建、owner 状态变化、validate_image 任务或 Outbox | +| S3 适配 | 真实 Boto3 SDK 本地 SigV4;内部 HEAD、固定 VersionId GET 和流式 SHA helper | 真实 MinIO/IAM/反代联调、图像解码/EXIF/分析图、ready 判定 | +| 配置/依赖 | 默认关闭的上传开关;业务环境独立 secret/SDK;AI 依赖隔离门禁 | 生产启用、完整传递依赖 lock、存储/任务就绪探针 | + +累计注册 29 个公共业务 operationId;新增只有 createUpload。默认 `app.state.uploads=None`,合法上传授权请求在未配置时返回 503。`allowed_actions` 仍为空,不能由 grant 的存在推导提交能力。 + +## 2. 接口与错误 + +### 2.1 请求和响应 + +请求遵循[公共契约](../../contracts/public-api-v1.yaml): + +| 字段 | 输入及语义 | +|---|---| +| owner_type / owner_id | inspection_item 或 remediation_task;UUID 经 HTTP 统一规范化后由领域校验,不接收客户端 tenant/lab | +| filename | 1–255 字符展示名;拒绝空白、路径分隔符、控制字符及单独 . / ..;不参与对象 key | +| mime_type | image/jpeg、image/png、image/webp | +| size_bytes | 严格整数,1–15728640(15 MiB),不是图片尺寸或实际内容验证 | +| sha256 | 拟上传原始字节的 64 位小写十六进制 SHA-256;当前仅登记声明 | +| captured_at | 带显式时区的 RFC3339,转换为 MySQL 可存储的 UTC 毫秒;无另加的新鲜度、未来窗口或小数位数上限 | + +只允许这七个字段,不接收 query。继承已实现的同源 Origin、Secure session、单值 CSRF/Idempotency-Key、JSON 和请求体大小保护;所有响应 no-store。客户端拍摄时间及 SHA 声明都不能作为已经核验的证据。 + +201 响应为 `{data:{upload_id,object_key,put_url,required_content_type,expires_at,max_bytes},request_id}`。服务端生成 upload UUID 与 `staging/{tenant_id}/{upload_id}`,PUT URL 只指向该对象、包含 Content-Type/Host 签名,有效期 600 秒。expires_at 由 SDK 的 X-Amz-Date + X-Amz-Expires 精确推导,不能由另一次取时延长。 + +### 2.2 错误边界 + +- 401:会话不存在、失效或预检后被撤销;403:同源/动作或创建者/受派人不足;404:owner 不存在、跨 tenant/lab 或不可见。 +- 409:owner 当前状态不接受新采集,或既有幂等冲突/请求执行中。重放不重新执行已完成命令的状态前置,但仍重验当前身份及 owner 权限。 +- 422:HTTP Schema/类型/枚举/大小声明越界、未知字段/query、文件名/时间/SHA 等非法。HTTP Schema 已拦截的 MIME/大小声明错误为 422;不是实际对象内容 413/415。 +- 413/415:沿用请求体大小/媒体类型保护;存储 helper 对实际超大对象或不支持的对象 MIME 分别返回 IMAGE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE,但这些 helper 尚未暴露为 complete API。 +- 429:普通写频率、上传专属频率或未完成 grant 配额;包含 Retry-After。503:未配置、Redis 故障、签名/存储依赖故障。底层异常不泄露 endpoint、凭据、SQL 或 SDK 原始消息。 + +## 3. 权限、配额与事务 + +### 3.1 可信 owner 守卫 + +owner 从当前事务的数据库加载,不能由请求构造。 + +| owner | 权限和归属 | 新授权允许状态 | +|---|---|---| +| inspection_item | 当前实验室 capture 权限;原采集创建者或 safety_admin | 父巡检 draft/in_progress;子项 draft/uploaded/needs_retake/needs_review/failed | +| remediation_task | 当前实验室 assignee 权限,且确为当前受派人 | in_progress/rejected | + +Task 不把 admin 等同受派人;合法整改证据也不因为父巡检已 completed 被阻断。数据库测试使用明确标注的合法外键合成整改历史,不代表整改命令链已实现。 + +### 3.2 两种额度不可互换 + +1. 预检结束后,在数据库事务外执行既有普通写限流和独立上传授权 10 次/分钟限流。Redis 故障拒绝请求;幂等重放也经过限流,不是绕过速率限制的通道。 +2. 提交事务内,同 tenant/user 的 `status='granted' AND expires_at>now` 最多 10 条。已过期但尚未扫成 expired 的行不占额度,validating/ready/rejected 等状态也不计作未完成 grant。 +3. 达容量上限时不写新记录,Retry-After 按最近到期时间向上取整且至少 1 秒;仅是重试提示,不预留下一次名额。 + +当前未新增配额专用复合索引,未验收大规模历史数据下的扫描/锁竞争性能;后续索引优化必须通过新迁移,不改写 `0001_initial`。 + +### 3.3 提交顺序与幂等 + +应用入口 `UploadGrantApplication.create` 先校验正文,结束短事务认证/CSRF 预检,再执行上述 Redis 限流。提交事务顺序固定: + +1. 通过 session 定位 tenant/user,仅用于锁范围;取得 tenant 共享锁。 +2. 取得当前 user 排他锁作为配额 mutex,必须早于认证或幂等外键所需的 user 共享锁。 +3. claim 幂等记录,再重新认证/CSRF;不能把预检当成提交授权。 +4. 加载并锁 owner:Item 按 inspection→item;Task 锁 remediation_task;校验范围、权限和新意图状态。 +5. 已完成的同 key/正文返回原 HTTP 状态、原字节响应、request_id、URL 和 expires_at,不再预留/审计或续签。 +6. 新意图用显式静态凭据进行本地 SDK 签名;以当前锁定读检查 grant 额度,然后 INSERT uploads、追加审计、保存幂等响应并原子提交。 + +用户 mutex 先于共享认证是本命令的必要例外,避免多会话 S→X 升级死锁;不能将其他请求的认证改为一律排他。配额必须是当前读,避免等待 mutex 后沿用 RR 旧快照。完整事务遇死锁最多尝试 3 次,每次重验,不能只重跑最后一条 SQL。 + +uploads/审计/幂等响应任一步失败均回滚。不创建 image/task/outbox,不递增 item/owner version 或改状态。原授权过期后同 key 仍返回原过期响应;新的上传意图用新 key。预签名 URL 是短期 bearer 能力,后续撤会话/关闭 API 不立即撤销已发 URL,仍依原到期时间和存储凭据策略约束。 + +## 4. 存储适配与配置 + +### 4.1 SDK 与网络边界 + +`packages/storage/s3.py` 使用业务环境 `boto3==1.43.106`。这是已安装验证的顶层版本约束,不是完整传递 lock;本批未给 AI 安装 SDK、ORM、Redis 或真实模型。 + +显式 secret 文件提供静态凭据,隔离 SDK 默认 config/credentials 文件与 profile,不使用环境凭据、metadata 或默认凭据刷新。两个 client 共用 path-style、SigV4、region us-east-1,分别用于公共端点本地签名和内部对象操作;连接/读取超时 2/5 秒、最多 2 次尝试,不继承代理配置。 + +`presign_put` 是无网络的本地密码学,因此可随响应在短事务内生成;测试禁止客户端发送网络请求并验证真实 SDK 签名结构。它不检查真实 IAM、桶存在性或版本化。HEAD/GET/PUT/Copy 等实际对象操作必须在数据库事务外,后续不得向事务内的 presign 路径加入凭据刷新或网络探测。 + +### 4.2 固定版本 helper + +- `inspect_staging(tenant, upload)`:内部 HEAD 服务端构造的 staging key;要求非空、非 `null` VersionId,检查实际大小/MIME。不信任 Metadata 中的 SHA 或 ETag 作为真实摘要。 +- `verify_staging_hash(tenant, upload, pinned, expected_sha256)`:只接受匹配的 staging key 与固定 VersionId,GET 显式传 VersionId;核对返回版本/长度/MIME,按 64 KiB 分块、最多 15 MiB 重算 SHA-256,所有分支关闭流。 +- 固定版本不存在返回 OBJECT_NOT_FOUND,绝不回退 latest;截断/超长/回包版本变动/摘要不符均拒绝。SDK 依赖错误脱敏为 503。 +- 以上是后续 complete/general 的适配基础,尚未连接公共完成接口或 Worker。即使 SHA 匹配,也没有完成解码、像素限制、方向归一、EXIF 清理、O/A 生成或 image ready。 + +### 4.3 开启与回退 + +执行[开发指南 3.9](../../DEVELOPMENT.md);新增变量见 `.env.example`:API_UPLOADS_ENABLED、S3_ENDPOINT、S3_PUBLIC_ENDPOINT、S3_BUCKET、S3_REGION、S3_ACCESS_KEY_FILE、S3_SECRET_KEY_FILE。上传开关默认 0、只接受 0/1;启用必须有身份服务,配置失败拒绝启动并关闭本次分配的资源,正常退出同样释放资源。 + +公共端点必须精确等于 PUBLIC_ORIGIN,同源 HTTPS:443、无路径/query/凭据;bucket 固定 labsafe-private。身份示例 `:8443` 不能直接用于上传。操作者还须按[部署规范](../07-quality-operations/02-deployment-operations.md)准备私有版本化桶、最小权限 API 凭据、staging 生命周期、15 MiB 反代限制和签名 query 日志保护;不得给 API 挂管理员/cleanup 凭据。 + +`/ready` 不执行 S3/IAM/真实签名请求,也不证明存储或图像处理就绪。回退只关闭 API_UPLOADS_ENABLED 后重启;不删 grant/审计/幂等历史,不降级数据库,不声称关闭开关会立即撤销已发 URL。 + +## 5. 本地验证证据 + +### 5.1 执行命令 + +业务 Python 3.11 环境,从仓库根目录执行: + +~~~powershell +python -m pip check +python -m ruff check apps packages tests tools/database +python -m ruff format --check apps packages tests tools/database +python -m pytest tests/business tests/domain tests/security tests/persistence/test_unit.py -q --tb=short +$env:LABSAFE_TEST_REDIS_SERVER = '<专用测试 redis-server 可执行文件>' +python tools/database/run_mysql_tests.py --mysqld '' +python -m unittest discover -s tests/protocol -t . -v +python -B tools/design/build_specs.py --check +python -B tools/design/test_readiness_design.py +python -B tools/design/validate_specs.py +~~~ + +设计命令使用独立校验依赖;AI fixture/协议另在独立 AI 环境运行。隔离数据库使用 Git 忽略的 D 盘 `.pytest_cache` 下随机目录和进程级 TEMP/TMP,不改系统环境、不接管已有 MySQL/Redis 服务。不得把未配置数据库而 skipped 的结果当成通过。 + +### 5.2 实际结果 + +| 检查 | 本地结果 | +|---|---| +| 累计单元/HTTP/领域/安全/持久化单元 | 1227 passed;相对 I-02E 新增 266 项;保留 1 项既有 Starlette/AnyIO 弃用警告 | +| 上传专项隔离数据库 | 24 passed、154 deselected;只是专项结果,不能替代完整持久化回归 | +| 完整隔离持久化套件 | 178 passed、无 skipped、退出码 0;157 项真实 MySQL + 21 项重叠单元,本批新增 24 项数据库用例 | +| 数据库范围 | MySQL 8.0.33、0001_initial、43 表、115 外键、结构 errors=[];未新增迁移/索引 | +| 静态检查 | Ruff check PASS;100 个 Python 文件 format --check PASS | +| 进程/协议与依赖 | 业务协议 4 项;独立 AI fixture 15 项、协议 4 项通过;业务 pip check PASS;AI 无 boto3/botocore/ORM/Redis/模型依赖 | +| 设计/文档 | 16 份生成物无漂移;148 项 synthetic readiness checks;完整设计 PASS、2 份 OpenAPI、224 个文档链接 | +| 路由/Git | 实际 29 个唯一公共业务 operationId,createUpload 存在,completeUpload 不存在,上传默认关闭;git diff --check 通过,暂存区为空 | + +以上为 Python 3.11.4 的本地结果。主套件包含 21 项持久化单元,与完整持久化套件重叠,不能将两套成绩直接相加;两环境协议测试也重复。SDK 签名是真实本地计算,对象 HEAD/GET 使用 Stubber,不是实际 MinIO;MySQL/Redis 为真实隔离测试进程。 + +新增测试入口为 tests/domain/test_uploads.py、tests/business/test_upload_boundary.py、tests/business/test_s3_storage.py、tests/persistence/test_upload_grants_mysql.py,并扩展上传限流测试。覆盖 Item 144 格、Task 24 格授权/状态矩阵、严格元数据、额度/到期、签名/默认 profile 隔离、版本消失不降级、流关闭、SHA 错误、HTTP 安全边界与启动失败资源释放。 + +数据库专项覆盖 4 会话同 key/不同 key 竞争、10 个活跃 grant 上限、真实 Redis 10/min、过期释放额度、重放不续签/不重复审计、撤权/预检后撤会话及审计/幂等失败回滚。为独立检验数据库活跃额度,部分并发测试只替换上传速率调用;真实 Redis 上传速率另有独立用例,不能称所有并发测试完全无 mock。 + +### 5.3 本批复核修正和未验证项 + +复核关闭了显式默认 profile 在无配置文件时造成 ProfileNotFound 的问题:隔离 session 后移除 profile,而不是强制名为 default 的不存在 profile。上传配额锁前置于认证/幂等外键共享锁,避免 S→X 死锁;配额查询改用当前读,避免 RR 旧快照放宽额度。拍摄时间去掉公共契约未规定的 100 字符上限,增加长小数精度回归,仍按 UTC 毫秒入库。 + +尚未执行真实 MinIO/IAM/nginx/TLS 浏览器上传、图像解码、GPU/真实模型或远程 CI,无独立评审批准。UP-03/SEC-03 仍待真实环境证据,不能以本地 SDK、端点校验或 prefix/Stubber 测试关闭。生产保护保持不变。 + +## 6. 下一批可执行关闭清单 + +I-02F2 先完成上传验证受理,不跨越[持久任务](../02-architecture/05-durable-jobs.md)和[图像持久化](../02-architecture/06-data-persistence.md)边界: + +1. 实现 completeUpload/getImage 的唯一路由/应用入口和白名单投影;重复同 upload 只生成一个 image/验证任务;仍重验 session、owner、scope、grant 期限及客户端/原 grant hash。 +2. 在事务外执行 HEAD,固定 staging key/version;进入提交事务后重验所有可变前置条件。并发覆盖对象后仍使用已选定版本,删除版本不能回退 latest。 +3. 将 upload/image 状态、validate_image task_runs、TaskDispatch/所需事件、审计和幂等响应原子提交;遵循稳定 logical_key、初始 generation/dispatch_sequence 及锁序。失败不得留下孤立 image 或先发 Redis 消息。 +4. 增加真实 MySQL 同 key/不同 key 重复 complete、预检后撤权、HEAD 后状态变化、失效 grant、任务/Outbox 注入失败的回归。未有 consumer 时明确“受理/待验证”,不返回伪 ready。 +5. 后续 general 消费按 lease/fencing 执行固定版本 GET/实算 SHA、解码/像素上限、方向归一/EXIF 清理和 O/A 对象生成,再原子提交 ready/rejected;补 Redis 丢失、崩溃重投和旧 token 回写拒绝测试。该闭环仍须 I-03 共用基础支持。 +6. 另行执行真实最小权限 IAM、同源签名 PUT/固定版本 GET、过期/篡改拒绝、15 MiB 限制、私有桶/版本化及 URL 脱敏日志测试,留下 UP-03/SEC-03 证据后才关闭部署项。 + +不自动提交/Push/PR;后续按[协作规范](../../CONTRIBUTING.md)及用户授权推进,I-02、I-03、I-ML 和生产门禁均仍未完成。 diff --git a/docs/08-delivery/16-i02f2-upload-completion-api.md b/docs/08-delivery/16-i02f2-upload-completion-api.md new file mode 100644 index 0000000..bb7186f --- /dev/null +++ b/docs/08-delivery/16-i02f2-upload-completion-api.md @@ -0,0 +1,40 @@ +# I-02F2 上传完成受理与图像查询验收 + +日期:2026-10-01。状态:本地实现与验证完成;本次按用户授权提交特性分支 PR,远程 CI 和独立评审另行记录。 + +## 1. 本批边界 + +本批新增两个公共接口,累计 **31 个**: + +| 接口 | 行为 | 本批不做 | +|---|---|---| +| `POST /api/v1/uploads/complete` / `completeUpload` | 固定 staging 对象版本,原子登记 `asset_images(validating)`、`validate_image` 任务、TaskDispatch Outbox、审计和幂等响应,返回 202 | 不计算真实 SHA、不解码、不生成 O/A、不标记 ready | +| `GET /api/v1/images/{id}` / `getImage` | 返回当前授权范围内的 Image 白名单元数据 | 不返回对象 URL/key/version,不提供下载 | + +本批复用 I-02F1 的 `API_UPLOADS_ENABLED` 开关和 S3 配置。任务只写入 MySQL 真相源;没有 publisher、worker、sweeper 或 Redis 队列消费,`task_runs=ready`、Outbox=pending、Image=validating 是预期状态。 + +## 2. 完成请求和响应 + +请求严格为 `{upload_id,sha256}`,拒绝 owner、tenant、object_key、version_id、query 和未知字段。沿用 Origin、JSON、Secure session、CSRF、Idempotency-Key、no-store 和统一错误封装。 + +首次成功返回 202,响应只含契约 Image 字段;内部 upload_id、tenant_id、对象 key/version、legal_hold、任务 payload 均不公开。日期统一为 UTC RFC3339 毫秒,`analysis_sha256`、`width`、`height` 在 validating 阶段为空。 + +## 3. 授权、固定版本和事务 + +首次完成在 API 事务外 HEAD:先锁并重验当前 owner、grant 和声明 hash;若尚无 image,事务回滚后释放全部 DB 锁,再执行内部 HEAD。HEAD 只接受服务端 staging key、非 `null` VersionId、1–15 MiB、精确 MIME/大小;Metadata SHA/ETag 不可信,固定 VersionId 不回退 latest。 + +第二次事务重新认证并比对快照;会话撤销、owner 状态变化、grant 过期、大小/MIME/对象版本不一致均拒绝。最终事务同时写 image、upload 状态、task_runs、outbox、audit 和幂等响应。事务中不执行 HEAD/GET/PUT、凭据刷新或 Redis;任意失败全回滚。 + +## 4. 幂等和并发 + +同 key/正文重放返回原 202、原 request_id 和原响应,不重 HEAD、不续签、不再建图/任务。新 key 命中已登记 image 时返回同一图像当前投影,不新增任务;当前权限仍重验。四会话竞争只产生一个 image、task 和 Outbox;跨 tenant/lab 在对象访问前返回 404。 + +## 5. 图像查询 + +`getImage` 只要求 owner.read,不要求 capture/assignee;查询只读 RR 快照,不调用对象存储,不改变状态、版本、审计或任务(既有 session idle touch 除外)。沿用既有普通读限流:Redis 不可用时使用每用户每分钟最多 30 次的进程内保守 fallback,仍重新认证;完成写请求则返回 503。`deleted` 可返回数据库墓碑元数据,但不提供下载或恢复。 + +## 6. 本地验证与限制 + +主套件 1283 项通过;完整隔离持久化回归 213 项通过(192 项真实 MySQL + 21 项重叠单元,含本批新增 35 项数据库用例);独立 AI fixture 15 项、协议测试 4+4 项和 AI 依赖隔离通过。覆盖完成请求严格字段、UUID/hash、Origin/JSON/CSRF/幂等/query、图像投影日期和字段白名单;真实 MySQL/Redis 覆盖首次登记、并发、HEAD 失败、撤权/状态变化、事务回滚、范围隔离、读权限和任务协议。Stubber 只验证 SDK 参数和错误封装,不是实时 MinIO/IAM/TLS 验收。 + +真实 SHA、解码/像素/EXIF、O/A 复制、ImageValidated、publisher/sweeper、lease/fencing、远程 CI 和人工审查仍未完成。下一批先完成 I-03A 任务基础,再接 general 图像验证消费;不得把 validating 或 ready task 当作 image ready。 diff --git a/docs/08-delivery/README.md b/docs/08-delivery/README.md index f55f47f..79c21e6 100644 --- a/docs/08-delivery/README.md +++ b/docs/08-delivery/README.md @@ -7,5 +7,14 @@ 5. [设计、实施与发布门禁](05-stage-gates.md)。 6. [实现就绪评审六项关闭清单](06-implementation-readiness.md)。 7. [I-01A工程骨架验收](07-i01a-acceptance.md)。 +8. [I-01B数据库基础验收](08-i01b-acceptance.md)。 +9. [I-01C会话、授权与幂等验收](09-i01c-acceptance.md)。 +10. [I-02A领域命令守卫验收](10-i02a-domain-guards.md)。 +11. [I-02B认证与用户API验收](11-i02b-identity-api.md)。 +12. [I-02C角色列表与授权撤销验收](12-i02c-role-api.md)。 +13. [I-02D组织、模板与巡检草稿API验收](13-i02d-foundation-api.md)。 +14. [I-02E巡检项查询与动作投影基础验收](14-i02e-item-query-api.md)。 +15. [I-02F1上传授权与S3适配基础验收](15-i02f1-upload-grant-api.md)。 +16. [I-02F2上传完成受理与图像查询验收](16-i02f2-upload-completion-api.md)。 本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。 diff --git a/docs/README.md b/docs/README.md index 5b1b9d7..8d49b13 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,11 @@ # LabSafe 文档入口 -状态:2026-09-27 设计修订候选1.1.0。先读 [最新审计](08-delivery/03-design-audit.md),不要把结构检查通过当作全系统可上线。 +状态:设计版本 1.1.0;实现进度更新于 2026-10-01。设计结论见 [最新审计](08-delivery/03-design-audit.md),不要把结构检查或局部实现通过当作全系统可上线。 ## 阅读顺序 -实现进度:I-01A 工程骨架与 I-01B 数据库基础已在特性分支实现;审批状态与本机测试不能互相替代。 -运行入口见 [开发指南](../DEVELOPMENT.md),本轮证据见 [I-01B 验收](08-delivery/08-i01b-acceptance.md)。 +实现进度:I-01A/B/C 已合并;I-02A/B/C/D/E/F1/F2 通过本地相应验证,本次提交特性分支 PR,完整 I-02 仍未完成。审批状态、远程 CI 与本机测试分别记录。 +运行入口见 [开发指南](../DEVELOPMENT.md),本轮证据见 [I-02F2 上传完成受理验收](08-delivery/16-i02f2-upload-completion-api.md),完整阶段状态见下方实施计划。 1. [文档控制](00-document-control.md):规范边界和变更要求。 2. [产品](01-requirements/01-product-requirements.md)与[范围](01-requirements/01-scope-and-acceptance.md)。 diff --git a/packages/application/__init__.py b/packages/application/__init__.py index cbf7c70..9a2dc1b 100644 --- a/packages/application/__init__.py +++ b/packages/application/__init__.py @@ -1 +1 @@ -"""Application command/query package placeholder.""" +"""Transaction-owning application services; HTTP adapters never issue SQL.""" diff --git a/packages/application/foundations.py b/packages/application/foundations.py new file mode 100644 index 0000000..7043059 --- /dev/null +++ b/packages/application/foundations.py @@ -0,0 +1,178 @@ +"""Foundation use cases sharing identity's transaction and authorization boundary.""" + +from datetime import timedelta +from uuid import uuid4 + +from sqlalchemy.exc import IntegrityError + +from packages.application.identity import retry_deadlocks +from packages.domain.security import Permission, ServiceError, authorize +from packages.persistence.inspections import InspectionRepository +from packages.persistence.organizations import OrganizationRepository +from packages.persistence.security import begin_idempotency, complete_idempotency, utc_now +from packages.persistence.templates import TemplateRepository + + +class FoundationApplication: + def __init__(self, identity): + self.identity = identity + self.organizations = OrganizationRepository() + self.templates = TemplateRepository() + self.inspections = InspectionRepository() + + def read( + self, kind, token, request_id, *, resource_id=None, laboratory_id=None, page=1, page_size=20 + ): + principal = self.identity._preflight(token) + self.identity.limits.user(principal, write=False) + return self._read_once( + kind, + token, + request_id, + resource_id=resource_id, + laboratory_id=laboratory_id, + page=page, + page_size=page_size, + ) + + @retry_deadlocks + def _read_once(self, kind, token, request_id, *, resource_id, laboratory_id, page, page_size): + with self.identity.transaction() as connection: + actor = self.identity.sessions.authenticate(connection, token).principal + if kind in {"college", "laboratory", "location"}: + if resource_id is not None: + data = self.organizations.get(connection, actor, kind, resource_id) + else: + data = self.organizations.list( + connection, actor, kind, page, page_size, laboratory_id + ) + elif kind == "template": + data = ( + self.templates.get(connection, actor, resource_id) + if resource_id + else self.templates.list(connection, actor, page, page_size) + ) + elif kind == "inspection": + data = ( + self.inspections.get(connection, actor, resource_id) + if resource_id + else self.inspections.list(connection, actor, page, page_size, laboratory_id) + ) + else: + raise ValueError("Unknown foundation resource") + return {"data": data, "request_id": request_id} + + def write( + self, + kind, + operation, + token, + csrf, + key, + body, + request_id, + *, + resource_id=None, + laboratory_id=None, + ): + principal = self.identity._preflight(token, csrf=csrf, admin=kind != "inspection") + if kind == "inspection": + authorize(principal, Permission.CAPTURE, body["laboratory_id"]) + self.identity.limits.user(principal, write=True) + try: + return self._write_once( + kind, + operation, + token, + csrf, + key, + body, + request_id, + resource_id=resource_id, + laboratory_id=laboratory_id, + ) + except IntegrityError as error: + if getattr(error.orig, "args", [None])[0] == 1062: + raise ServiceError("STATE_CONFLICT", 409, "Resource already exists") from None + raise + + def check_visibility(self, connection, actor, kind, body, laboratory_id): + if kind == "inspection": + authorize(actor, Permission.CAPTURE, body["laboratory_id"]) + self.organizations.get(connection, actor, "laboratory", body["laboratory_id"]) + else: + authorize(actor, Permission.ADMIN) + if kind == "location": + self.organizations.get(connection, actor, "laboratory", laboratory_id) + if kind == "laboratory": + self.organizations.get(connection, actor, "college", body["college_id"]) + + @retry_deadlocks + def _write_once( + self, kind, operation, token, csrf, key, body, request_id, *, resource_id, laboratory_id + ): + paths = { + "college": "/api/v1/colleges", + "laboratory": "/api/v1/laboratories", + "location": f"/api/v1/laboratories/{laboratory_id}/locations", + "template": "/api/v1/templates", + "inspection": "/api/v1/inspections", + } + path = paths[kind] + if operation != "create": + if kind != "template" or operation not in {"publish", "clone"}: + raise ValueError("Unknown foundation command") + path += f"/{resource_id}/{operation}" + with self.identity.transaction() as connection: + tenant, actor_id = self.identity.sessions.locate(connection, token) + owner = str(uuid4()) + claim = begin_idempotency( + connection, + tenant_id=tenant, + actor_id=actor_id, + method="POST", + path=path, + key=key, + request_hash=self.identity.sessions.request_digest(body), + expires_at=utc_now() + timedelta(hours=24), + lease_owner=owner, + ) + session = self.identity.sessions.authenticate(connection, token) + self.identity.sessions.require_csrf(session, csrf) + actor = session.principal + self.check_visibility(connection, actor, kind, body, laboratory_id) + if claim.state == "completed": + # Revalidate current scope before exposing a cached resource, without rerunning + # version/state preconditions or creating another resource. + result_id = claim.response["data"]["id"] + if kind == "inspection": + self.inspections.get(connection, actor, result_id, share=True) + elif kind == "template": + if resource_id is not None: + self.templates.get(connection, actor, resource_id, share=True) + self.templates.get(connection, actor, result_id, share=True) + elif kind == "location": + self.organizations.row( + connection, tenant, kind, result_id, laboratory_id=laboratory_id + ) + else: + self.organizations.get(connection, actor, kind, result_id) + return claim.response_status, claim.response + if kind in {"college", "laboratory", "location"}: + data = self.organizations.create( + connection, actor, kind, body, request_id, laboratory_id=laboratory_id + ) + elif kind == "inspection": + data = self.inspections.create(connection, actor, body, request_id) + elif operation == "create": + data = self.templates.create(connection, actor, body, request_id) + else: + data = self.templates.change( + connection, actor, resource_id, body, request_id, clone=operation == "clone" + ) + status = 200 if operation == "publish" else 201 + response = {"data": data, "request_id": request_id} + complete_idempotency( + connection, claim, status=status, response=response, lease_owner=owner + ) + return status, response diff --git a/packages/application/identity.py b/packages/application/identity.py new file mode 100644 index 0000000..75b7c2e --- /dev/null +++ b/packages/application/identity.py @@ -0,0 +1,269 @@ +"""Identity use cases: short authorization preflight, then one atomic command. + +Redis calls occur outside database transactions. Preflight grants are never used +to skip authentication inside the committing transaction. +""" + +from contextlib import contextmanager +from datetime import timedelta +from functools import wraps +from uuid import uuid4 + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError + +from packages.domain.security import ( + Permission, + ServiceError, + authorize, + permission_projection, +) +from packages.persistence.roles import RoleRepository +from packages.persistence.security import ( + SessionService, + append_audit, + begin_idempotency, + complete_idempotency, + utc_now, +) +from packages.persistence.transaction import lock_tenant +from packages.persistence.users import UserRepository, timestamp + + +def retry_deadlocks(command): + """Retry only rolled-back MySQL deadlocks, at most three complete attempts.""" + + @wraps(command) + def invoke(*args, **kwargs): + for attempt in range(3): + try: + return command(*args, **kwargs) + except OperationalError as error: + if getattr(error.orig, "args", [None])[0] != 1213: + raise + if attempt == 2: + raise ServiceError( + "REQUEST_IN_PROGRESS", 409, "Request is in progress", retry_after=2 + ) from None + + return invoke + + +class IdentityApplication: + def __init__(self, engine, sessions: SessionService, limits, environment: str): + if environment not in {"dev", "test"}: + raise ValueError("Identity API is not released for production") + self.engine, self.sessions, self.limits = engine, sessions, limits + self.environment = environment + self.users = UserRepository() + self.roles = RoleRepository() + + @contextmanager + def transaction(self): + try: + with self.engine.connect().execution_options( + isolation_level="REPEATABLE READ" + ) as connection: + old_timeout = connection.scalar(text("SELECT @@SESSION.innodb_lock_wait_timeout")) + connection.execute(text("SET SESSION innodb_lock_wait_timeout=1")) + connection.commit() + try: + with connection.begin(): + yield connection + finally: + connection.execute( + text("SET SESSION innodb_lock_wait_timeout=:timeout"), + {"timeout": old_timeout}, + ) + connection.commit() + except OperationalError as error: + errno = getattr(error.orig, "args", [None])[0] + if errno in {1205, 3572}: + raise ServiceError( + "REQUEST_IN_PROGRESS", 409, "Request is in progress", retry_after=2 + ) from None + raise + + def readiness(self): + # This is identity readiness, not the future object/AI/worker readiness gate. + with self.engine.connect() as connection: + revision = connection.scalar(text("SELECT version_num FROM alembic_version")) + if revision != "0001_initial": + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Database revision is not ready") + self.limits.ready() + + def session_projection(self, connection, session): + principal = session.principal + return { + "user": self.users.get(connection, principal.tenant_id, principal.user_id), + "tenant_id": principal.tenant_id, + "csrf_token": session.csrf_token, + "expires_at": timestamp(session.expires_at), + "permissions": permission_projection(principal), + "environment": self.environment, + } + + def login(self, body, ip, request_id): + ticket = self.limits.login(body["tenant_code"], body["username"], ip) + response, token = self._login_once(body, request_id) + # Failure leaves an unreturned, expiring session, not an unprotected login. + self.limits.login_succeeded(ticket) + return response, token + + @retry_deadlocks + def _login_once(self, body, request_id): + with self.transaction() as connection: + session = self.sessions.login(connection, **body) + append_audit( + connection, + principal=session.principal, + action="session.login", + resource_type="session", + resource_id=session.session_id, + changes={}, + reason="Login", + request_id=request_id, + ) + response = { + "data": self.session_projection(connection, session), + "request_id": request_id, + } + return response, session.token + + @retry_deadlocks + def _preflight(self, token, *, csrf=None, admin=False): + with self.transaction() as connection: + session = self.sessions.authenticate(connection, token, touch=False) + if csrf is not None: + self.sessions.require_csrf(session, csrf) + if admin: + authorize(session.principal, Permission.ADMIN) + return session.principal + + def read( + self, + operation, + token, + request_id, + *, + user_id=None, + page=1, + page_size=20, + laboratory_id=None, + ): + principal = self._preflight(token, admin=operation != "me") + self.limits.user(principal, write=False) + return self._read_once( + operation, + token, + request_id, + user_id=user_id, + page=page, + page_size=page_size, + laboratory_id=laboratory_id, + ) + + @retry_deadlocks + def _read_once( + self, + operation, + token, + request_id, + *, + user_id=None, + page=1, + page_size=20, + laboratory_id=None, + ): + with self.transaction() as connection: + session = self.sessions.authenticate(connection, token) + if operation == "me": + data = self.session_projection(connection, session) + else: + authorize(session.principal, Permission.ADMIN) + tenant = session.principal.tenant_id + if operation == "list": + data = self.users.list(connection, tenant, page, page_size) + elif operation == "roles": + data = self.roles.list( + connection, tenant, user_id, page, page_size, laboratory_id + ) + else: + data = self.users.get(connection, tenant, user_id) + return {"data": data, "request_id": request_id} + + def write(self, operation, token, csrf, key, body, request_id, *, user_id=None): + principal = self._preflight(token, csrf=csrf, admin=operation != "logout") + self.limits.user(principal, write=True) + return self._write_once(operation, token, csrf, key, body, request_id, user_id=user_id) + + @retry_deadlocks + def _write_once(self, operation, token, csrf, key, body, request_id, *, user_id=None): + paths = { + "create": "/api/v1/users", + "disable": f"/api/v1/users/{user_id}/disable", + "logout": "/api/v1/auth/logout", + "grant_role": f"/api/v1/users/{user_id}/roles", + "revoke_role": f"/api/v1/users/{user_id}/revoke-role", + } + path = paths[operation] + with self.transaction() as connection: + # Scope lookup is not authentication. Administrative tenant exclusion + # must precede the INSERT's implicit tenant/user FK shared locks to avoid + # S->X upgrade deadlocks. Idempotency still precedes target aggregate locks. + tenant, actor_id = self.sessions.locate(connection, token) + exclusive = operation in {"disable", "grant_role", "revoke_role"} + if exclusive: + lock_tenant(connection, tenant, exclusive=True) + owner = str(uuid4()) + # A keyed digest prevents offline guessing of initial_password from this table. + request_hash = self.sessions.request_digest(body) + claim = begin_idempotency( + connection, + tenant_id=tenant, + actor_id=actor_id, + method="POST", + path=path, + key=key, + request_hash=request_hash, + expires_at=utc_now() + timedelta(hours=24), + lease_owner=owner, + ) + session = self.sessions.authenticate(connection, token, exclusive=exclusive) + self.sessions.require_csrf(session, csrf) + actor = session.principal + if operation != "logout": + authorize(actor, Permission.ADMIN) + if user_id is not None: + self.users.get(connection, tenant, user_id, lock=True) + if claim.state == "completed": + return claim.response_status, claim.response + if operation == "create": + data = self.users.create(connection, actor, body, request_id) + status = 201 + elif operation == "disable": + data = self.users.disable(connection, actor, user_id, body, request_id) + status = 200 + elif operation in {"grant_role", "revoke_role"}: + data = self.roles.change( + connection, actor, user_id, body, request_id, grant=operation == "grant_role" + ) + status = 200 + else: + self.sessions.logout(connection, session) + append_audit( + connection, + principal=actor, + action="session.logout", + resource_type="session", + resource_id=session.session_id, + changes={}, + reason="Logout", + request_id=request_id, + ) + data, status = {"ok": True}, 200 + response = {"data": data, "request_id": request_id} + complete_idempotency( + connection, claim, status=status, response=response, lease_owner=owner + ) + return status, response diff --git a/packages/application/item_queries.py b/packages/application/item_queries.py new file mode 100644 index 0000000..4c7be47 --- /dev/null +++ b/packages/application/item_queries.py @@ -0,0 +1,41 @@ +"""Inspection item queries: preflight, external rate limit, authoritative read transaction.""" + +from packages.application.identity import retry_deadlocks +from packages.persistence.inspection_items import InspectionItemRepository + + +class ItemQueryApplication: + def __init__(self, identity): + self.identity = identity + self.items = InspectionItemRepository() + + def read( + self, operation, token, request_id, *, resource_id, laboratory_id=None, page=1, page_size=20 + ): + principal = self.identity._preflight(token) + self.identity.limits.user(principal, write=False) + return self._read_once( + operation, + token, + request_id, + resource_id=resource_id, + laboratory_id=laboratory_id, + page=page, + page_size=page_size, + ) + + @retry_deadlocks + def _read_once( + self, operation, token, request_id, *, resource_id, laboratory_id, page, page_size + ): + with self.identity.transaction() as connection: + actor = self.identity.sessions.authenticate(connection, token).principal + if operation == "get": + data = self.items.get(connection, actor, resource_id) + elif operation == "list": + data = self.items.list( + connection, actor, resource_id, page, page_size, laboratory_id + ) + else: + raise ValueError("Unknown inspection item query") + return {"data": data, "request_id": request_id} diff --git a/packages/application/rate_limit.py b/packages/application/rate_limit.py new file mode 100644 index 0000000..38d0633 --- /dev/null +++ b/packages/application/rate_limit.py @@ -0,0 +1,93 @@ +"""Atomic Redis fixed windows; writes/login fail closed, reads have bounded fallback.""" + +import hashlib +import logging +import math +import threading +import time +import unicodedata + +from redis.exceptions import RedisError + +from packages.domain.security import ServiceError + +COUNT = """ +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end +return {count, redis.call('TTL', KEYS[1])} +""" +RELEASE = """ +local count = tonumber(redis.call('GET', KEYS[1]) or '0') +if count > 0 then return redis.call('DECR', KEYS[1]) end +return 0 +""" + + +class RateLimits: + def __init__(self, redis_client, *, clock=time.time): + self.redis = redis_client + self.clock = clock + self._fallback = {} + self._lock = threading.Lock() + self._last_warning = None + + def ready(self): + try: + if not self.redis.ping(): + raise RedisError("Redis not ready") + except RedisError: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Rate limiter unavailable") from None + + def _charge(self, category, identity, maximum, seconds, *, read=False): + now = self.clock() + window = int(now // seconds) + remaining = max(1, math.ceil((window + 1) * seconds - now)) + digest = hashlib.sha256(identity.encode()).hexdigest() + key = f"labsafe:limit:{category}:{digest}:{window}" + try: + count, ttl = self.redis.eval(COUNT, 1, key, remaining) + except RedisError: + if not read: + raise ServiceError( + "DEPENDENCY_UNAVAILABLE", 503, "Rate limiter unavailable" + ) from None + with self._lock: + self._fallback = {k: v for k, v in self._fallback.items() if v[1] > now} + if self._last_warning != window: + logging.getLogger(__name__).warning( + "Redis unavailable; conservative local read limit" + ) + self._last_warning = window + if key not in self._fallback and len(self._fallback) >= 10000: + raise ServiceError( + "DEPENDENCY_UNAVAILABLE", 503, "Local rate limiter at capacity" + ) + count = self._fallback.get(key, (0, 0))[0] + 1 + self._fallback[key] = (count, now + remaining) + maximum, ttl = min(maximum, 30), remaining + if int(count) > maximum: + raise ServiceError( + "RATE_LIMITED", 429, "Too many requests", retry_after=max(1, int(ttl)) + ) + return key + + def login(self, tenant, username, ip): + account = unicodedata.normalize("NFKC", username).strip().casefold() + tenant = unicodedata.normalize("NFC", tenant).strip() + # In-flight attempts reserve a slot; successful login releases only its slot. + return self._charge("login", repr((tenant, account, ip)), 5, 900) + + def login_succeeded(self, ticket): + try: + self.redis.eval(RELEASE, 1, ticket) + except RedisError: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Rate limiter unavailable") from None + + def user(self, principal, *, write): + identity = f"{principal.tenant_id}:{principal.user_id}" + self._charge("user", identity, 120, 60, read=not write) + if write: + self._charge("write", identity, 60, 60) + + def upload_grant(self, principal): + self._charge("upload", f"{principal.tenant_id}:{principal.user_id}", 10, 60) diff --git a/packages/application/uploads.py b/packages/application/uploads.py new file mode 100644 index 0000000..750c6aa --- /dev/null +++ b/packages/application/uploads.py @@ -0,0 +1,236 @@ +"""Upload grants and atomic validation acceptance; never an image-ready shortcut.""" + +from datetime import timedelta +from uuid import uuid4 + +from sqlalchemy import text + +from packages.application.identity import retry_deadlocks +from packages.domain.security import ServiceError, not_found +from packages.domain.uploads import ( + MAX_BYTES, + authorize_upload, + owner_reference, + require_completion, + require_staging_match, + staging_key, + validate_complete_request, + validate_upload_request, +) +from packages.persistence.image_jobs import enqueue_image_validation +from packages.persistence.images import ImageRepository +from packages.persistence.security import begin_idempotency, complete_idempotency, utc_now +from packages.persistence.transaction import lock_tenant +from packages.persistence.uploads import UploadRepository +from packages.persistence.users import timestamp + + +class _NeedsStagingInspection(Exception): + """Roll back the entire preparation transaction before any object I/O.""" + + def __init__(self, upload): + self.upload = upload + + +class UploadGrantApplication: + def __init__(self, identity, storage): + self.identity, self.storage = identity, storage + self.uploads = UploadRepository() + self.images = ImageRepository() + + def create(self, token, csrf, key, body, request_id): + captured = validate_upload_request(body) + principal = self.identity._preflight(token, csrf=csrf) + self.identity.limits.user(principal, write=True) + self.identity.limits.upload_grant(principal) + return self._create_once(token, csrf, key, body, captured, request_id) + + @retry_deadlocks + def _create_once(self, token, csrf, key, body, captured, request_id): + with self.identity.transaction() as connection: + tenant, user = self.identity.sessions.locate(connection, token) + lock_tenant(connection, tenant) + # Acquire X before authenticate/claim would hold S on the same user. + # This is a per-user grant mutex, not a tenant-wide business write lock. + status = connection.scalar( + text("SELECT status FROM users WHERE tenant_id=:tenant AND id=:user FOR UPDATE"), + {"tenant": tenant, "user": user}, + ) + if status != "active": + raise ServiceError("UNAUTHENTICATED", 401, "Authentication required") + lease = str(uuid4()) + claim = begin_idempotency( + connection, + tenant_id=tenant, + actor_id=user, + method="POST", + path="/api/v1/uploads", + key=key, + request_hash=self.identity.sessions.request_digest(body), + expires_at=utc_now() + timedelta(hours=24), + lease_owner=lease, + ) + session = self.identity.sessions.authenticate(connection, token) + self.identity.sessions.require_csrf(session, csrf) + actor = session.principal + owner = self.uploads.owner(connection, actor, body["owner_type"], body["owner_id"]) + authorize_upload(actor, owner, check_state=claim.state != "completed") + if claim.state == "completed": + return ( + claim.response_status, + claim.response, + ) # Original URL/expiry, never extend TTL. + upload_id = str(uuid4()) + object_key = staging_key(tenant, upload_id) + # Explicit-credential SDK presigning is LOCAL cryptography, not an object call. + # HEAD/GET/PUT and credential refresh/network must never be added here. + grant = self.storage.presign_put(tenant, upload_id, body["mime_type"]) + now = utc_now() + if not now < grant.expires_at <= now + timedelta(seconds=600): + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Invalid upload grant lifetime") + self.uploads.reserve( + connection, + actor, + owner, + body, + upload_id=upload_id, + key=object_key, + expires_at=grant.expires_at, + captured_at=captured, + now=now, + request_id=request_id, + ) + response = { + "data": { + "upload_id": upload_id, + "object_key": object_key, + "put_url": grant.url, + "required_content_type": body["mime_type"], + "expires_at": timestamp(grant.expires_at), + "max_bytes": MAX_BYTES, + }, + "request_id": request_id, + } + complete_idempotency( + connection, claim, status=201, response=response, lease_owner=lease + ) + return 201, response + + def complete(self, token, csrf, key, body, request_id): + validate_complete_request(body) + principal = self.identity._preflight(token, csrf=csrf) + self.identity.limits.user(principal, write=True) + try: + return self._complete_once(token, csrf, key, body, request_id) + except _NeedsStagingInspection as preparation: + upload = preparation.upload + # No open transaction, persisted pending claim, mutex, or owner lock at this point. + pinned = self.storage.inspect_staging(upload["tenant_id"], upload["id"]) + return self._complete_once(token, csrf, key, body, request_id, observation=(upload, pinned)) + + @retry_deadlocks + def _complete_once(self, token, csrf, key, body, request_id, *, observation=None): + with self.identity.transaction() as connection: + tenant, user = self.identity.sessions.locate(connection, token) + lock_tenant(connection, tenant) + # Same actor mutex order as grant creation, before FK/auth shared reads. + status = connection.scalar( + text("SELECT status FROM users WHERE tenant_id=:tenant AND id=:user FOR UPDATE"), + {"tenant": tenant, "user": user}, + ) + if status != "active": + raise ServiceError("UNAUTHENTICATED", 401, "Authentication required") + lease = str(uuid4()) + claim = begin_idempotency( + connection, + tenant_id=tenant, + actor_id=user, + method="POST", + path="/api/v1/uploads/complete", + key=key, + request_hash=self.identity.sessions.request_digest(body), + expires_at=utc_now() + timedelta(hours=24), + lease_owner=lease, + ) + session = self.identity.sessions.authenticate(connection, token) + self.identity.sessions.require_csrf(session, csrf) + actor = session.principal + located = self.uploads.get(connection, actor, body["upload_id"]) + owner = self.uploads.owner(connection, actor, *owner_reference(located)) + upload = self.uploads.get(connection, actor, body["upload_id"], lock=True) + if ( + owner_reference(upload) != owner_reference(located) + or owner.laboratory_id != upload["laboratory_id"] + ): + raise ServiceError("STATE_CONFLICT", 409, "Upload owner changed") + authorize_upload(actor, owner, check_state=False) + image = self.images.for_upload(connection, actor, upload["id"]) + require_completion( + upload, body["sha256"], utc_now(), already_completed=image is not None + ) + if image is not None: + if image["status"] == "deleted": + raise not_found() + if ( + owner_reference(image) != owner_reference(upload) + or image["laboratory_id"] != upload["laboratory_id"] + or image["original_sha256"] != upload["expected_sha256"] + ): + raise RuntimeError("Persisted image does not match its upload") + task = connection.scalar( + text( + "SELECT id FROM task_runs WHERE tenant_id=:tenant " + "AND task_type='validate_image' AND resource_id=:image " + "AND logical_key=:logical FOR SHARE" + ), + { + "tenant": tenant, + "image": image["id"], + "logical": f"validate_image:{image['id']}", + }, + ) + if task is None: + raise RuntimeError("Persisted image has no validation task") + if claim.state == "completed": + return claim.response_status, claim.response + else: + if claim.state == "completed": + raise RuntimeError("Completed response has no image") + authorize_upload(actor, owner) + if observation is None: + # Raising exits/rolls back the transaction, including its idempotency claim. + raise _NeedsStagingInspection(upload) + observed, pinned = observation + if upload != observed: + raise ServiceError("STATE_CONFLICT", 409, "Upload changed during inspection") + require_staging_match(upload, pinned) + image_id = str(uuid4()) + self.images.create_validating(connection, upload, pinned, image_id) + enqueue_image_validation( + connection, + tenant_id=tenant, + upload_id=upload["id"], + image_id=image_id, + request_id=request_id, + ) + self.uploads.mark_validating(connection, actor, upload["id"], request_id) + image = self.images.for_upload(connection, actor, upload["id"]) + response = {"data": self.images.project(image), "request_id": request_id} + complete_idempotency( + connection, claim, status=202, response=response, lease_owner=lease + ) + return 202, response + + def get_image(self, token, image_id, request_id): + principal = self.identity._preflight(token) + self.identity.limits.user(principal, write=False) + return self._get_image_once(token, image_id, request_id) + + @retry_deadlocks + def _get_image_once(self, token, image_id, request_id): + with self.identity.transaction() as connection: + actor = self.identity.sessions.authenticate(connection, token).principal + return { + "data": self.images.get(connection, actor, image_id), + "request_id": request_id, + } diff --git a/packages/domain/__init__.py b/packages/domain/__init__.py index d96aeeb..71d74d5 100644 --- a/packages/domain/__init__.py +++ b/packages/domain/__init__.py @@ -1 +1 @@ -"""Domain package placeholder for immutable facts, guards and state transitions.""" +"""Pure authorization and workflow guards; no HTTP, SQL or model dependencies.""" diff --git a/packages/domain/foundations.py b/packages/domain/foundations.py new file mode 100644 index 0000000..a756c7b --- /dev/null +++ b/packages/domain/foundations.py @@ -0,0 +1,40 @@ +"""Pure guards for location structure, template bodies and inspection creation.""" + +from packages.domain.security import Permission, ServiceError, authorize + + +def require_active(status): + if status != "active": + raise ServiceError("STATE_CONFLICT", 409, "Resource is not active") + + +def location_parent(kind, parent_kind): + allowed = { + "room": {None}, + "area": {"room"}, + "shelf": {"room", "area"}, + "cabinet": {"room", "area"}, + } + if kind not in allowed or parent_kind not in allowed[kind]: + raise ServiceError("VALIDATION_ERROR", 422, "Invalid location parent type") + + +def template_items(items): + if not 1 <= len(items) <= 100: + raise ServiceError("VALIDATION_ERROR", 422, "Template needs 1 to 100 items") + for key in ("id", "code", "sort_order"): + if len({item[key] for item in items}) != len(items): + raise ServiceError("VALIDATION_ERROR", 422, "Duplicate template item field") + + +def create_inspection(actor, laboratory_id, template_status, item_count, location_ids): + authorize(actor, Permission.CAPTURE, laboratory_id) + if template_status != "published": + raise ServiceError("STATE_CONFLICT", 409, "Template is not published") + if ( + not 1 <= len(location_ids) <= 100 + or len(set(location_ids)) != len(location_ids) + or not 1 <= item_count <= 100 + or item_count * len(location_ids) > 100 + ): + raise ServiceError("VALIDATION_ERROR", 422, "Invalid inspection item capacity") diff --git a/packages/domain/item_actions.py b/packages/domain/item_actions.py new file mode 100644 index 0000000..30953bf --- /dev/null +++ b/packages/domain/item_actions.py @@ -0,0 +1,88 @@ +"""Read-only action probes using the exact command guards, never a second state table. + +Contexts are server-loaded snapshots, not request bodies. None means unknown, +whereas () means the loader verified an exhaustive empty collection. Enabled +operations must also have real application/HTTP command handlers. +""" + +from dataclasses import dataclass + +from packages.domain.security import Permission, Principal, ServiceError, authorize, not_found +from packages.domain.workflow import ( + Evaluation, + FailedRun, + Finding, + Image, + ImageSelection, + Item, + ReviewOutcome, + complete_item, + retry_item, + submit_item, +) + +SUBMIT = "submitInspectionItem" +RETRY = "retryInspectionItem" +COMPLETE = "completeInspectionItem" +ITEM_OPERATIONS = frozenset({SUBMIT, RETRY, COMPLETE}) + + +@dataclass(frozen=True, kw_only=True) +class ItemActionContext: + current_findings: tuple[Finding, ...] | None = None + images: tuple[Image, ...] | None = None + selections: tuple[ImageSelection, ...] | None = None + old_run: FailedRun | None = None + evaluation: Evaluation | None = None + + +def allowed_item_actions( + actor: Principal, item: Item, context: ItemActionContext, *, enabled: frozenset[str] +) -> list[str]: + if actor.tenant_id != item.tenant_id: + raise not_found() + authorize(actor, Permission.READ, item.laboratory_id) + if not enabled <= ITEM_OPERATIONS: + raise ValueError("Unrecognized item command capability") + if context.current_findings is None: + return [] # An unloaded collection is not proof of no confirmed/unreviewed findings. + shared = {"expected_version": item.version, "current_findings": context.current_findings} + probes = {} + if SUBMIT in enabled and context.images is not None and context.selections is not None: + probes[SUBMIT] = [ + lambda: submit_item( + actor, item, **shared, images=context.images, selections=context.selections + ) + ] + if RETRY in enabled and context.images is not None and context.old_run is not None: + probes[RETRY] = [ + lambda: retry_item( + actor, item, **shared, images=context.images, old_run=context.old_run + ) + ] + if COMPLETE in enabled and context.evaluation is not None: + # Test existence of a legal outcome, not an invented outcome in the response. + # Actual commands must validate the submitted outcome, reason and version again. + probes[COMPLETE] = [ + lambda outcome=outcome: complete_item( + actor, + item, + **shared, + evaluation=context.evaluation, + outcome=outcome, + reason="Read-only action availability probe", + ) + for outcome in ReviewOutcome + ] + allowed = [] + for operation in sorted(probes): + for probe in probes[operation]: + try: + probe() + except ServiceError as error: + if error.status not in {403, 404, 409, 422}: + raise + else: + allowed.append(operation) + break + return allowed diff --git a/packages/domain/security.py b/packages/domain/security.py index 7e0c3b8..485a122 100644 --- a/packages/domain/security.py +++ b/packages/domain/security.py @@ -75,6 +75,20 @@ def valid_grant(role: str, scope: str, lab: str | None) -> bool: ) +def permission_projection(principal: Principal) -> list[dict[str, str | None]]: + permissions = sorted( + { + (str(permission), lab or "") + for role, scope, lab in principal.roles + if valid_grant(role, scope, lab) + for permission in ROLE_PERMISSIONS[role] + } + ) + if len(permissions) > 200: + raise ServiceError("STATE_CONFLICT", 409, "Permission projection exceeds capacity") + return [{"action": action, "laboratory_id": lab or None} for action, lab in permissions] + + def visible_laboratories(principal: Principal) -> frozenset[str] | None: """None means all labs in this tenant, never all tenants.""" labs = set() diff --git a/packages/domain/uploads.py b/packages/domain/uploads.py new file mode 100644 index 0000000..0983b33 --- /dev/null +++ b/packages/domain/uploads.py @@ -0,0 +1,170 @@ +"""Pure upload guards. Server-loaded ownership, never client-supplied scope.""" + +import math +import re +from datetime import datetime, timezone +from uuid import UUID + +from packages.domain.security import Permission, ServiceError, authorize, not_found +from packages.domain.workflow import Item, Task + +MAX_BYTES = 15 * 1024 * 1024 +GRANT_SECONDS = 600 +MAX_OPEN_GRANTS = 10 +MIME_TYPES = frozenset({"image/jpeg", "image/png", "image/webp"}) +CAPTURE_STATES = frozenset({"draft", "uploaded", "needs_retake", "needs_review", "failed"}) +EVIDENCE_STATES = frozenset({"in_progress", "rejected"}) + + +def invalid(message="Invalid upload request"): + return ServiceError("VALIDATION_ERROR", 422, message) + + +def identifier(value): + try: + if not isinstance(value, str) or len(value) != 36 or str(UUID(value)) != value: + raise ValueError + except (ValueError, AttributeError, TypeError): + raise invalid("Invalid canonical identifier") from None + return value + + +def digest(value): + if not isinstance(value, str) or re.fullmatch(r"[a-f0-9]{64}", value) is None: + raise invalid("Invalid SHA256") + return value + + +def capture_time(value): + if ( + not isinstance(value, str) + or re.fullmatch( + r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?" + r"(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)", + value, + ) + is None + ): + raise invalid("Capture time must be RFC3339 with an explicit offset") + try: + parsed = datetime.fromisoformat(value.upper().replace("Z", "+00:00")) + utc = parsed.astimezone(timezone.utc).replace(tzinfo=None) + if utc.year < 1000: + raise ValueError + except (ValueError, OverflowError): + raise invalid("Capture time is outside the database range") from None + # The contract does not define a recency/skew window. Do not invent one. + return utc.replace(microsecond=utc.microsecond // 1000 * 1000) + + +def validate_upload_request(body): + fields = { + "owner_type", + "owner_id", + "filename", + "mime_type", + "size_bytes", + "sha256", + "captured_at", + } + if not isinstance(body, dict) or set(body) != fields: + raise invalid() + if body["owner_type"] not in ("inspection_item", "remediation_task"): + raise invalid("Invalid upload owner type") + identifier(body["owner_id"]) + name = body["filename"] + if ( + not isinstance(name, str) + or not name.strip() + or len(name) > 255 + or name in {".", ".."} + or any(ord(c) < 32 or ord(c) == 127 or c in "/\\" for c in name) + ): + raise invalid("Filename must be a display name, not a path") + if not isinstance(body["mime_type"], str) or body["mime_type"] not in MIME_TYPES: + raise ServiceError("UNSUPPORTED_MEDIA_TYPE", 415, "Unsupported image media type") + if type(body["size_bytes"]) is not int or body["size_bytes"] < 1: + raise invalid("Invalid image size") + if body["size_bytes"] > MAX_BYTES: + raise ServiceError("IMAGE_TOO_LARGE", 413, "Image exceeds 15 MiB") + digest(body["sha256"]) + return capture_time(body["captured_at"]) + + +def authorize_upload(actor, owner: Item | Task, *, check_state=True): + if actor.tenant_id != owner.tenant_id: + raise not_found() + if isinstance(owner, Item): + authorize(actor, Permission.CAPTURE, owner.laboratory_id, creator_id=owner.creator_id) + valid = ( + owner.inspection_status in {"draft", "in_progress"} and owner.status in CAPTURE_STATES + ) + elif isinstance(owner, Task): + authorize(actor, Permission.ASSIGNEE, owner.laboratory_id, assignee_id=owner.assignee_id) + valid = owner.status in EVIDENCE_STATES + else: + raise ValueError("Unsupported server upload owner") + if check_state and not valid: + raise ServiceError("STATE_CONFLICT", 409, "Owner cannot accept capture") + + +def require_grant_capacity(expirations, now): + # Caller locks the user quota mutex BEFORE authentication/foreign-key shared reads. + active = [expires for expires in expirations if expires > now] + if len(active) >= MAX_OPEN_GRANTS: + raise ServiceError( + "RATE_LIMITED", + 429, + "Too many unfinished upload grants", + retry_after=max(1, math.ceil((min(active) - now).total_seconds())), + ) + + +def staging_key(tenant_id, upload_id): + return f"staging/{identifier(tenant_id)}/{identifier(upload_id)}" + + +def validate_complete_request(body): + if not isinstance(body, dict) or set(body) != {"upload_id", "sha256"}: + raise invalid("Invalid upload completion request") + identifier(body["upload_id"]) + digest(body["sha256"]) + + +def owner_reference(row): + item, task = row["inspection_item_id"], row["remediation_task_id"] + if (item is None) == (task is None): + raise RuntimeError("Invalid persisted upload owner") + return ("inspection_item", item) if item is not None else ("remediation_task", task) + + +def require_completion(upload, sha256, now, *, already_completed=False): + if upload["expected_sha256"] != sha256: + raise ServiceError("HASH_MISMATCH", 422, "Completion hash differs from upload grant") + if upload["object_key"] != staging_key(upload["tenant_id"], upload["id"]): + raise RuntimeError("Invalid persisted staging reference") + if already_completed: + if upload["status"] not in {"validating", "ready", "rejected"}: + raise RuntimeError("Completed image has inconsistent upload status") + return + if upload["status"] != "granted" or upload["expires_at"] <= now: + raise ServiceError("STATE_CONFLICT", 409, "Upload grant is no longer completable") + + +def require_staging_match(upload, pinned): + # This validates HEAD metadata only; the worker must still hash/decode the bytes. + version = pinned.version_id + if ( + not isinstance(version, str) + or not version + or version == "null" + or len(version) > 200 + or any(not 33 <= ord(c) <= 126 for c in version) + or pinned.key != upload["object_key"] + or type(pinned.size_bytes) is not int + or pinned.size_bytes != upload["size_bytes"] + or not 1 <= pinned.size_bytes <= MAX_BYTES + or pinned.mime_type != upload["mime_type"] + or pinned.mime_type not in MIME_TYPES + ): + raise ServiceError("IMAGE_INVALID", 422, "Staging object does not match upload grant") diff --git a/packages/domain/workflow.py b/packages/domain/workflow.py new file mode 100644 index 0000000..72224df --- /dev/null +++ b/packages/domain/workflow.py @@ -0,0 +1,622 @@ +"""Pure I-02 command guards, not HTTP handlers or a persistence unit of work. + +Snapshots MUST be loaded by the server in the same locked transaction. In +particular, current_findings must be exhaustive, not a client-provided subset. +Successful guards return decisions only; no snapshot or database is mutated. +""" + +from dataclasses import dataclass +from datetime import date, datetime +from enum import StrEnum +from typing import TypeVar + +from packages.domain.security import ( + Permission, + Principal, + ServiceError, + authorize, + not_found, + require_version, +) + + +class ItemStatus(StrEnum): + DRAFT = "draft" + UPLOADED = "uploaded" + QUEUED = "queued" + QUALITY_CHECKING = "quality_checking" + NEEDS_RETAKE = "needs_retake" + PROCESSING = "processing" + NEEDS_REVIEW = "needs_review" + COMPLETED = "completed" + FAILED = "failed" + + +class RunStatus(StrEnum): + QUEUED = "queued" + PROCESSING = "processing" + RETRYING = "retrying" + COMPLETED = "completed" + NEEDS_RETAKE = "needs_retake" + NEEDS_REVIEW = "needs_review" + FAILED = "failed" + SUPERSEDED = "superseded" + + +class FindingStatus(StrEnum): + NEEDS_REVIEW = "needs_review" + CONFIRMED = "confirmed" + REJECTED = "rejected" + CANNOT_DETERMINE = "cannot_determine" + DISPATCHED = "dispatched" + CLOSED = "closed" + + +class TaskStatus(StrEnum): + PENDING_DISPATCH = "pending_dispatch" + IN_PROGRESS = "in_progress" + PENDING_RECHECK = "pending_recheck" + REJECTED = "rejected" + CANNOT_REMEDIATE = "cannot_remediate" + CLOSED = "closed" + + +class ReviewOutcome(StrEnum): + NO_ISSUE = "no_issue" + ISSUES_CONFIRMED = "issues_confirmed" + CANNOT_DETERMINE = "cannot_determine" + + +@dataclass(frozen=True, kw_only=True) +class Resource: + id: str + tenant_id: str + laboratory_id: str + version: int + + +@dataclass(frozen=True, kw_only=True) +class Item(Resource): + status: ItemStatus | str + inspection_status: str + creator_id: str + location_id: str + current_run_id: str | None + current_evaluation_id: str | None + + +@dataclass(frozen=True, kw_only=True) +class Finding(Resource): + status: FindingStatus | str + item_id: str + run_id: str + evaluation_id: str + superseded: bool + evidence_ready: bool + + +@dataclass(frozen=True, kw_only=True) +class Task(Resource): + status: TaskStatus | str + finding_id: str + assignee_id: str + latest_evidence_id: str | None + + +@dataclass(frozen=True, kw_only=True) +class Image: + id: str + tenant_id: str + laboratory_id: str + owner_type: str + owner_id: str + location_id: str | None + status: str + available: bool + + +@dataclass(frozen=True, kw_only=True) +class Evidence: + id: str + tenant_id: str + task_id: str + submitted_by: str + + +@dataclass(frozen=True, kw_only=True) +class Evaluation: + id: str + tenant_id: str + item_id: str + run_id: str + status: str + has_unknown: bool + insufficient_facts: bool + + +@dataclass(frozen=True, kw_only=True) +class ImageSelection: + image_id: str + role: str + parent_image_id: str | None + + +@dataclass(frozen=True, kw_only=True) +class PinnedInputs: + """Immutable retry inputs; configuration availability is server-verified.""" + + model_bundle_id: str + dictionary_version_id: str + rule_bundle_id: str + pipeline_version: str + device_profile: str + reference_date: date + images: tuple[ImageSelection, ...] + + +@dataclass(frozen=True, kw_only=True) +class FailedRun: + id: str + tenant_id: str + item_id: str + status: RunStatus | str + pinned: PinnedInputs + configuration_available: bool + + +@dataclass(frozen=True) +class RetryDecision: + item_status: ItemStatus + replay_of: str + pinned: PinnedInputs + + +@dataclass(frozen=True) +class RemediationDecision: + """Application MUST persist both statuses atomically, with audit/outbox.""" + + task_status: TaskStatus + finding_status: FindingStatus + + +EnumT = TypeVar("EnumT", bound=StrEnum) +CONFIRMED = frozenset({FindingStatus.CONFIRMED, FindingStatus.DISPATCHED, FindingStatus.CLOSED}) +UNCERTAIN_REASONS = frozenset( + {"blur", "occluded", "label_unreadable", "rule_missing", "insufficient_evidence", "other"} +) + + +def _conflict(message: str) -> ServiceError: + return ServiceError("STATE_CONFLICT", 409, message) + + +def _invalid(message: str) -> ServiceError: + return ServiceError("VALIDATION_ERROR", 422, message) + + +def _enum(value: str, kind: type[EnumT]) -> EnumT: + try: + return kind(value) + except (ValueError, TypeError) as exc: + raise _conflict("Unknown state or outcome") from exc + + +def _text(value: str, maximum: int = 2000) -> None: + if not isinstance(value, str) or not value.strip() or len(value) > maximum: + raise _invalid("Nonblank text within the contract length limit is required") + + +def _guard( + actor: Principal, + resource: Resource, + permission: Permission, + expected_version: int, + **identities: str, +) -> None: + # Visibility precedes version/state checks, including for tenant administrators. + if actor.tenant_id != resource.tenant_id: + raise not_found() + if not resource.id or not resource.laboratory_id: + raise _conflict("Resource scope is incomplete") + authorize(actor, permission, resource.laboratory_id, **identities) + require_version(resource.version, expected_version) + + +def _current(finding: Finding) -> FindingStatus: + if finding.superseded is not False: + raise _conflict("Superseded findings are immutable") + return _enum(finding.status, FindingStatus) + + +def _findings(item: Item, findings: tuple[Finding, ...]) -> tuple[FindingStatus, ...]: + states = [] + seen = set() + for finding in findings: + if ( + finding.tenant_id != item.tenant_id + or finding.laboratory_id != item.laboratory_id + or finding.item_id != item.id + ): + raise not_found() + if finding.id in seen: + raise _conflict("Duplicate finding in aggregate snapshot") + seen.add(finding.id) + if finding.superseded is True: + continue + if ( + finding.run_id != item.current_run_id + or finding.evaluation_id != item.current_evaluation_id + ): + raise _conflict("Finding does not belong to the current evaluation") + states.append(_current(finding)) + return tuple(states) + + +def _open_inspection(item: Item) -> None: + if item.inspection_status not in {"draft", "in_progress"}: + raise _conflict("Inspection is not open") + if not item.creator_id or not item.location_id: + raise _conflict("Inspection ownership or location is incomplete") + + +def _images( + resource: Resource, + images: tuple[Image, ...], + owner_type: str, + maximum: int, + location_id: str | None = None, +) -> None: + if not 1 <= len(images) <= maximum or len({image.id for image in images}) != len(images): + raise _invalid("Image list is empty, duplicated or over capacity") + for image in images: + if image.tenant_id != resource.tenant_id or image.laboratory_id != resource.laboratory_id: + raise not_found() + if image.owner_type != owner_type or image.owner_id != resource.id: + raise _invalid("Image does not belong to this owner") + if location_id is not None and image.location_id != location_id: + raise _invalid("Image location differs from the item") + if image.status != "ready" or image.available is not True: + raise _conflict("All images must be ready and available") + + +def _selections(images: tuple[Image, ...], selections: tuple[ImageSelection, ...]) -> None: + if tuple(image.id for image in images) != tuple(value.image_id for value in selections): + raise _invalid("Loaded images must match the complete ordered selection") + overview = selections[0] + if overview.role != "overview" or overview.parent_image_id is not None: + raise _invalid("The first image must be the sole parentless overview") + if any( + value.role != "detail" or value.parent_image_id != overview.image_id + for value in selections[1:] + ): + raise _invalid("Detail images must reference the first overview") + + +def submit_item( + actor: Principal, + item: Item, + *, + expected_version: int, + images: tuple[Image, ...], + selections: tuple[ImageSelection, ...], + current_findings: tuple[Finding, ...], +) -> ItemStatus: + _guard(actor, item, Permission.CAPTURE, expected_version, creator_id=item.creator_id) + _open_inspection(item) + if _enum(item.status, ItemStatus) not in { + ItemStatus.UPLOADED, + ItemStatus.NEEDS_RETAKE, + ItemStatus.NEEDS_REVIEW, + }: + raise _conflict("Item cannot be submitted") + if CONFIRMED.intersection(_findings(item, current_findings)): + raise _conflict("Confirmed risks block resubmission") + _images(item, images, "inspection_item", 3, item.location_id) + _selections(images, selections) + return ItemStatus.QUEUED + + +def retry_item( + actor: Principal, + item: Item, + *, + expected_version: int, + old_run: FailedRun, + images: tuple[Image, ...], + current_findings: tuple[Finding, ...], +) -> RetryDecision: + _guard(actor, item, Permission.REVIEW, expected_version) + _open_inspection(item) + if old_run.tenant_id != item.tenant_id: + raise not_found() + if ( + _enum(item.status, ItemStatus) is not ItemStatus.FAILED + or old_run.id != item.current_run_id + or old_run.item_id != item.id + or _enum(old_run.status, RunStatus) is not RunStatus.FAILED + ): + raise _conflict("Retry requires the current technically failed run") + if CONFIRMED.intersection(_findings(item, current_findings)): + raise _conflict("Confirmed risks block retry") + pinned = old_run.pinned + if old_run.configuration_available is not True or not all( + isinstance(value, str) and value.strip() + for value in ( + pinned.model_bundle_id, + pinned.dictionary_version_id, + pinned.rule_bundle_id, + pinned.pipeline_version, + pinned.device_profile, + ) + ): + raise _conflict("Pinned configuration is unavailable") + if ( + pinned.pipeline_version != "vision-v1" + or pinned.device_profile not in {"cpu", "cuda"} + or type(pinned.reference_date) is not date + ): + raise _conflict("Pinned execution profile is invalid") + _images(item, images, "inspection_item", 3, item.location_id) + if tuple(image.id for image in images) != tuple(value.image_id for value in pinned.images): + raise _conflict("Retry must preserve the original ordered image list") + _selections(images, pinned.images) + return RetryDecision(ItemStatus.QUEUED, old_run.id, pinned) + + +def complete_item( + actor: Principal, + item: Item, + *, + expected_version: int, + evaluation: Evaluation, + current_findings: tuple[Finding, ...], + outcome: ReviewOutcome | str, + reason: str, +) -> ItemStatus: + _guard(actor, item, Permission.REVIEW, expected_version) + _open_inspection(item) + if _enum(item.status, ItemStatus) is not ItemStatus.NEEDS_REVIEW: + raise _conflict("Only an item needing review can be completed") + if evaluation.tenant_id != item.tenant_id: + raise not_found() + if ( + evaluation.id != item.current_evaluation_id + or evaluation.item_id != item.id + or evaluation.run_id != item.current_run_id + or evaluation.status != "completed" + ): + raise _conflict("Current evaluation is not complete") + if type(evaluation.has_unknown) is not bool or type(evaluation.insufficient_facts) is not bool: + raise _invalid("Evaluation flags must be booleans") + states = _findings(item, current_findings) + if FindingStatus.NEEDS_REVIEW in states: + raise _conflict("All current findings must be reviewed first") + unknown = ( + evaluation.has_unknown + or evaluation.insufficient_facts + or FindingStatus.CANNOT_DETERMINE in states + ) + confirmed = bool(CONFIRMED.intersection(states)) + required = ( + ReviewOutcome.CANNOT_DETERMINE + if unknown + else ReviewOutcome.ISSUES_CONFIRMED + if confirmed + else ReviewOutcome.NO_ISSUE + ) + if _enum(outcome, ReviewOutcome) is not required: + raise _conflict("Outcome does not match the current evaluation and findings") + _text(reason) + return ItemStatus.COMPLETED + + +def _review(actor: Principal, finding: Finding, expected_version: int, reason: str) -> None: + _guard(actor, finding, Permission.REVIEW, expected_version) + if _current(finding) is not FindingStatus.NEEDS_REVIEW: + raise _conflict("Finding is not awaiting review") + _text(reason) + + +def confirm_finding( + actor: Principal, finding: Finding, *, expected_version: int, reason: str +) -> FindingStatus: + _review(actor, finding, expected_version, reason) + if finding.evidence_ready is not True: + raise _conflict("Confirmation requires usable evidence") + return FindingStatus.CONFIRMED + + +def reject_finding( + actor: Principal, finding: Finding, *, expected_version: int, reason: str +) -> FindingStatus: + _review(actor, finding, expected_version, reason) + return FindingStatus.REJECTED + + +def cannot_determine_finding( + actor: Principal, finding: Finding, *, expected_version: int, reason: str, reason_code: str +) -> FindingStatus: + _review(actor, finding, expected_version, reason) + if not isinstance(reason_code, str) or reason_code not in UNCERTAIN_REASONS: + raise _invalid("Unknown uncertainty reason_code") + return FindingStatus.CANNOT_DETERMINE + + +def _assignment( + resource: Resource, assignee: Principal, assignee_active: bool, due_at: datetime, now: datetime +) -> None: + if assignee.tenant_id != resource.tenant_id: + raise not_found() + if ( + assignee_active is not True + or not assignee.user_id + or ("remediator", "laboratory", resource.laboratory_id) not in assignee.roles + ): + raise _invalid("Assignee must be active with a remediator role in this laboratory") + if any( + not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None + for value in (due_at, now) + ): + raise _invalid("Deadline and clock must be timezone-aware") + if due_at <= now: + raise _invalid("due_at must be in the future") + + +def dispatch_finding( + actor: Principal, + finding: Finding, + *, + expected_version: int, + assignee: Principal, + assignee_active: bool, + due_at: datetime, + now: datetime, +) -> RemediationDecision: + _guard(actor, finding, Permission.DISPATCH, expected_version) + if _current(finding) is not FindingStatus.CONFIRMED: + raise _conflict("Only confirmed findings can be dispatched") + _assignment(finding, assignee, assignee_active, due_at, now) + return RemediationDecision(TaskStatus.PENDING_DISPATCH, FindingStatus.DISPATCHED) + + +def _task( + actor: Principal, + task: Task, + finding: Finding, + permission: Permission, + expected_version: int, + **identities: str, +) -> TaskStatus: + _guard(actor, task, permission, expected_version, **identities) + if finding.tenant_id != task.tenant_id or finding.laboratory_id != task.laboratory_id: + raise not_found() + if finding.id != task.finding_id or _current(finding) is not FindingStatus.DISPATCHED: + raise _conflict("Task must reference its current dispatched finding") + return _enum(task.status, TaskStatus) + + +def accept_task( + actor: Principal, task: Task, finding: Finding, *, expected_version: int +) -> RemediationDecision: + state = _task( + actor, task, finding, Permission.ASSIGNEE, expected_version, assignee_id=task.assignee_id + ) + if state is not TaskStatus.PENDING_DISPATCH: + raise _conflict("Task is not awaiting acceptance") + return RemediationDecision(TaskStatus.IN_PROGRESS, FindingStatus.DISPATCHED) + + +def submit_evidence( + actor: Principal, + task: Task, + finding: Finding, + *, + expected_version: int, + images: tuple[Image, ...], + description: str, +) -> RemediationDecision: + state = _task( + actor, task, finding, Permission.ASSIGNEE, expected_version, assignee_id=task.assignee_id + ) + if state not in {TaskStatus.IN_PROGRESS, TaskStatus.REJECTED}: + raise _conflict("Task is not accepting evidence") + _images(task, images, "remediation_task", 10) + _text(description, 4000) + return RemediationDecision(TaskStatus.PENDING_RECHECK, FindingStatus.DISPATCHED) + + +def _recheck( + actor: Principal, + task: Task, + finding: Finding, + evidence: Evidence, + evidence_id: str, + expected_version: int, + reason: str, +) -> None: + _guard(actor, task, Permission.RECHECK, expected_version) + if evidence.tenant_id != task.tenant_id: + raise not_found() + state = _task( + actor, + task, + finding, + Permission.RECHECK, + expected_version, + assignee_id=task.assignee_id, + submitted_by=evidence.submitted_by, + ) + if ( + state is not TaskStatus.PENDING_RECHECK + or not task.latest_evidence_id + or evidence_id != task.latest_evidence_id + or evidence.id != evidence_id + or evidence.task_id != task.id + or not evidence.submitted_by + or not task.assignee_id + ): + raise _conflict("Recheck requires the latest evidence for this task") + _text(reason) + + +def recheck_task( + actor: Principal, + task: Task, + finding: Finding, + *, + expected_version: int, + evidence: Evidence, + evidence_id: str, + reason: str, +) -> RemediationDecision: + _recheck(actor, task, finding, evidence, evidence_id, expected_version, reason) + return RemediationDecision(TaskStatus.CLOSED, FindingStatus.CLOSED) + + +def reject_task( + actor: Principal, + task: Task, + finding: Finding, + *, + expected_version: int, + evidence: Evidence, + evidence_id: str, + reason: str, +) -> RemediationDecision: + _recheck(actor, task, finding, evidence, evidence_id, expected_version, reason) + return RemediationDecision(TaskStatus.REJECTED, FindingStatus.DISPATCHED) + + +def cannot_remediate( + actor: Principal, task: Task, finding: Finding, *, expected_version: int, reason: str +) -> RemediationDecision: + state = _task(actor, task, finding, Permission.ADMIN, expected_version) + if state in {TaskStatus.CLOSED, TaskStatus.CANNOT_REMEDIATE}: + raise _conflict("Task cannot be marked unremediable from this state") + _text(reason) + return RemediationDecision(TaskStatus.CANNOT_REMEDIATE, FindingStatus.DISPATCHED) + + +def reassign_task( + actor: Principal, + task: Task, + finding: Finding, + *, + expected_version: int, + assignee: Principal, + assignee_active: bool, + due_at: datetime, + now: datetime, + reason: str, +) -> RemediationDecision: + state = _task(actor, task, finding, Permission.DISPATCH, expected_version) + if state not in { + TaskStatus.PENDING_DISPATCH, + TaskStatus.IN_PROGRESS, + TaskStatus.REJECTED, + TaskStatus.CANNOT_REMEDIATE, + }: + raise _conflict("Task cannot be reassigned from this state") + _assignment(task, assignee, assignee_active, due_at, now) + _text(reason) + return RemediationDecision(TaskStatus.PENDING_DISPATCH, FindingStatus.DISPATCHED) diff --git a/packages/persistence/foundations.py b/packages/persistence/foundations.py new file mode 100644 index 0000000..b0ed422 --- /dev/null +++ b/packages/persistence/foundations.py @@ -0,0 +1,72 @@ +"""Shared SQL helpers for fixed, server-owned foundation queries.""" + +from sqlalchemy import text + +from packages.domain.security import Permission, ServiceError, authorize, visible_laboratories +from packages.persistence.bootstrap import normalized +from packages.persistence.users import timestamp + +BASE = "id,created_at,updated_at,version" + + +def project(row): + result = dict(row) + for key in ("created_at", "updated_at"): + if key in result: + result[key] = timestamp(result[key]) + return result + + +def clean(value, maximum): + try: + return normalized(value, maximum) + except (ValueError, TypeError): + raise ServiceError("VALIDATION_ERROR", 422, "Invalid resource text") from None + + +def is_admin(actor): + try: + authorize(actor, Permission.ADMIN) + return True + except ServiceError: + return False + + +def lab_filter(actor, column, params, laboratory_id=None): + if laboratory_id is not None: + authorize(actor, Permission.READ, laboratory_id) + params["filter_lab"] = laboratory_id + return f" AND {column}=:filter_lab" + labs = visible_laboratories(actor) + if labs is None: + return "" + if not labs: + raise ServiceError("FORBIDDEN", 403, "No laboratory access") + names = [] + for index, lab in enumerate(sorted(labs)): + key = f"scope_{index}" + params[key] = lab + names.append(":" + key) + return f" AND {column} IN ({','.join(names)})" + + +def page_query(connection, table, columns, where, params, page, page_size, *, project_row=project): + # SQL fragments are fixed by repositories, never supplied by the request. + total = connection.scalar(text(f"SELECT COUNT(*) FROM {table} WHERE {where}"), params) + rows = ( + connection.execute( + text( + f"SELECT {columns} FROM {table} WHERE {where} " + "ORDER BY created_at DESC,id DESC LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + .mappings() + .all() + ) + return { + "items": [project_row(row) for row in rows], + "total": total, + "page": page, + "page_size": page_size, + } diff --git a/packages/persistence/image_jobs.py b/packages/persistence/image_jobs.py new file mode 100644 index 0000000..2854a3f --- /dev/null +++ b/packages/persistence/image_jobs.py @@ -0,0 +1,60 @@ +"""Atomic validate_image intent + initial dispatch, not a publisher or worker.""" + +from uuid import UUID, uuid4 + +from sqlalchemy import text + +from packages.persistence.transaction import require_transaction +from packages.persistence.users import timestamp +from packages.shared.json_hash import canonical_json + + +def enqueue_image_validation(connection, *, tenant_id, upload_id, image_id, request_id): + require_transaction(connection) + now = connection.scalar(text("SELECT UTC_TIMESTAMP(3)")) + task_id, event_id = str(uuid4()), str(uuid4()) + payload = {"upload_id": upload_id, "image_id": image_id} + connection.execute( + text( + "INSERT INTO task_runs (id,tenant_id,task_type,resource_id,logical_key,payload," + "state,attempt,replay_generation,dispatch_sequence,fencing_token,available_at," + "created_at,updated_at) VALUES " + "(:id,:tenant,'validate_image',:image,:logical,:payload," + "'ready',0,0,1,0,:now,:now,:now)" + ), + { + "id": task_id, + "tenant": tenant_id, + "image": image_id, + "logical": f"validate_image:{image_id}", + "payload": canonical_json(payload), + "now": now, + }, + ) + message = { + "schema_version": "1.1", + "tenant_id": tenant_id, + "trace_id": UUID(request_id).hex, + "task_id": task_id, + "task_type": "validate_image", + "resource_id": image_id, + "replay_generation": 0, + "dispatch_sequence": 1, + "created_at": timestamp(now), + "payload": payload, + } + connection.execute( + text( + "INSERT INTO outbox_events (id,tenant_id,event_type,aggregate_type,aggregate_id," + "aggregate_version,payload,state,available_at,created_at,updated_at) VALUES " + "(:id,:tenant,'TaskDispatch','task_run',:task,1,:payload,'pending',:now,:now,:now)" + ), + { + "id": event_id, + "tenant": tenant_id, + "task": task_id, + "payload": canonical_json(message), + "now": now, + }, + ) + return task_id diff --git a/packages/persistence/images.py b/packages/persistence/images.py new file mode 100644 index 0000000..8644d2b --- /dev/null +++ b/packages/persistence/images.py @@ -0,0 +1,84 @@ +"""Image metadata projection; no object URLs, keys, versions or decoding side effects.""" + +from sqlalchemy import text + +from packages.domain.security import Permission, authorize, not_found +from packages.domain.uploads import owner_reference +from packages.persistence.foundations import project +from packages.persistence.users import timestamp + +PUBLIC_FIELDS = ( + "id", + "created_at", + "updated_at", + "version", + "laboratory_id", + "status", + "original_sha256", + "analysis_sha256", + "width", + "height", + "mime_type", + "captured_at", +) + + +class ImageRepository: + @staticmethod + def project(row): + data = project({name: row[name] for name in PUBLIC_FIELDS}) + data["captured_at"] = timestamp(row["captured_at"]) + data["owner_type"], data["owner_id"] = owner_reference(row) + return data + + def get(self, connection, actor, image_id): + row = ( + connection.execute( + text("SELECT * FROM asset_images WHERE tenant_id=:tenant AND id=:id"), + {"tenant": actor.tenant_id, "id": image_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + # Both owner FKs also bind laboratory_id. Read does not require capture permission. + authorize(actor, Permission.READ, row["laboratory_id"]) + return self.project(row) + + def for_upload(self, connection, actor, upload_id): + return ( + connection.execute( + text( + "SELECT * FROM asset_images WHERE tenant_id=:tenant " + "AND upload_id=:upload FOR UPDATE" + ), + {"tenant": actor.tenant_id, "upload": upload_id}, + ) + .mappings() + .first() + ) + + def create_validating(self, connection, upload, pinned, image_id): + connection.execute( + text( + "INSERT INTO asset_images (id,tenant_id,laboratory_id,inspection_item_id," + "remediation_task_id,upload_id,original_key,original_object_version," + "original_sha256,mime_type,captured_at) VALUES " + "(:id,:tenant,:lab,:item,:task,:upload,:key,:version,:sha,:mime,:captured)" + ), + { + "id": image_id, + "tenant": upload["tenant_id"], + "lab": upload["laboratory_id"], + "item": upload["inspection_item_id"], + "task": upload["remediation_task_id"], + "upload": upload["id"], + "key": pinned.key, + "version": pinned.version_id, + # A declared digest until worker verification. Never mark ready here. + "sha": upload["expected_sha256"], + "mime": upload["mime_type"], + "captured": upload["captured_at"], + }, + ) diff --git a/packages/persistence/inspection_items.py b/packages/persistence/inspection_items.py new file mode 100644 index 0000000..e2d5665 --- /dev/null +++ b/packages/persistence/inspection_items.py @@ -0,0 +1,111 @@ +"""Tenant-scoped, snapshot-consistent inspection item read models.""" + +from sqlalchemy import text + +from packages.domain.item_actions import ItemActionContext, allowed_item_actions +from packages.domain.security import Permission, authorize, not_found +from packages.domain.workflow import Item +from packages.persistence.foundations import project + +PUBLIC_FIELDS = ( + "id", + "created_at", + "updated_at", + "version", + "inspection_id", + "laboratory_id", + "location_id", + "status", + "submission_revision", + "current_run_id", + "current_fact_revision_id", + "review_outcome", +) +FIELDS = ",".join("item." + name for name in PUBLIC_FIELDS) +INTERNAL_FIELDS = ( + ",item.tenant_id,item.current_evaluation_id,inspection.status AS inspection_status," + "inspection.inspector_id AS creator_id" +) +FROM = ( + "inspection_items item JOIN inspections inspection " + "ON inspection.tenant_id=item.tenant_id AND inspection.id=item.inspection_id " + "AND inspection.laboratory_id=item.laboratory_id" +) + + +class InspectionItemRepository: + @staticmethod + def project(actor, row): + snapshot = Item(**{name: row[name] for name in Item.__dataclass_fields__}) + result = project({name: row[name] for name in PUBLIC_FIELDS}) + # No item write command is mounted in I-02E. Enabling one requires its + # real handler and a complete trusted context loader in the same change. + # Do not infer images/evaluation/U from status or manufacture empty facts. + result["allowed_actions"] = allowed_item_actions( + actor, snapshot, ItemActionContext(), enabled=frozenset() + ) + return result + + def get(self, connection, actor, item_id): + row = ( + connection.execute( + text( + f"SELECT {FIELDS}{INTERNAL_FIELDS} FROM {FROM} " + "WHERE item.tenant_id=:tenant AND item.id=:id" + ), + {"tenant": actor.tenant_id, "id": item_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + authorize(actor, Permission.READ, row["laboratory_id"]) + return self.project(actor, row) + + def list(self, connection, actor, inspection_id, page, page_size, laboratory_id=None): + parent = ( + connection.execute( + text("SELECT laboratory_id FROM inspections WHERE tenant_id=:tenant AND id=:id"), + {"tenant": actor.tenant_id, "id": inspection_id}, + ) + .mappings() + .first() + ) + if parent is None: + raise not_found() + authorize(actor, Permission.READ, parent["laboratory_id"]) + params = {"tenant": actor.tenant_id, "inspection": inspection_id} + where = "item.tenant_id=:tenant AND item.inspection_id=:inspection" + if laboratory_id is not None: + # OrganizationRepository.get uses a locking current read for commands. + # Keep this read-only filter in the same snapshot as the parent and page. + exists = connection.scalar( + text("SELECT id FROM laboratories WHERE tenant_id=:tenant AND id=:id"), + {"tenant": actor.tenant_id, "id": laboratory_id}, + ) + if exists is None: + raise not_found() + authorize(actor, Permission.READ, laboratory_id) + where += " AND item.laboratory_id=:lab" + params["lab"] = laboratory_id + # Authorization, count, page and parent/item state all use one RR snapshot. + # An authorized but nonmatching lab filter returns a genuinely empty page. + total = connection.scalar(text(f"SELECT COUNT(*) FROM {FROM} WHERE {where}"), params) + rows = ( + connection.execute( + text( + f"SELECT {FIELDS}{INTERNAL_FIELDS} FROM {FROM} WHERE {where} " + "ORDER BY item.created_at DESC,item.id DESC LIMIT :limit OFFSET :offset" + ), + {**params, "limit": page_size, "offset": (page - 1) * page_size}, + ) + .mappings() + .all() + ) + return { + "items": [self.project(actor, row) for row in rows], + "total": total, + "page": page, + "page_size": page_size, + } diff --git a/packages/persistence/inspections.py b/packages/persistence/inspections.py new file mode 100644 index 0000000..12990c0 --- /dev/null +++ b/packages/persistence/inspections.py @@ -0,0 +1,124 @@ +"""Inspection drafts pin published templates and create all items atomically.""" + +from uuid import uuid4 + +from sqlalchemy import text + +from packages.domain.foundations import create_inspection, require_active +from packages.domain.security import Permission, authorize, not_found +from packages.persistence.foundations import BASE, lab_filter, page_query, project +from packages.persistence.organizations import OrganizationRepository +from packages.persistence.security import append_audit +from packages.persistence.templates import TemplateRepository + +INSPECTION = BASE + ",laboratory_id,template_id,inspector_id,status" + + +class InspectionRepository: + @staticmethod + def project(connection, tenant, row, *, share=False): + result = project(row) + result["item_ids"] = list( + connection.execute( + text( + "SELECT id FROM inspection_items WHERE tenant_id=:tenant " + "AND inspection_id=:id ORDER BY created_at,id" + (" FOR SHARE" if share else "") + ), + {"tenant": tenant, "id": row["id"]}, + ).scalars() + ) + return result + + def get(self, connection, actor, inspection_id, *, share=False): + row = ( + connection.execute( + text( + f"SELECT {INSPECTION} FROM inspections WHERE tenant_id=:tenant AND id=:id" + + (" FOR SHARE" if share else "") + ), + {"tenant": actor.tenant_id, "id": inspection_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + authorize(actor, Permission.READ, row["laboratory_id"]) + return self.project(connection, actor.tenant_id, row, share=share) + + def list(self, connection, actor, page, page_size, laboratory_id=None): + params = {"tenant": actor.tenant_id} + if laboratory_id is not None: + OrganizationRepository().get(connection, actor, "laboratory", laboratory_id) + where = "tenant_id=:tenant" + lab_filter(actor, "laboratory_id", params, laboratory_id) + return page_query( + connection, + "inspections", + INSPECTION, + where, + params, + page, + page_size, + project_row=lambda row: self.project(connection, actor.tenant_id, row), + ) + + def create(self, connection, actor, body, request_id): + lab_id, template_id = body["laboratory_id"], body["template_id"] + authorize(actor, Permission.CAPTURE, lab_id) + organizations, templates = OrganizationRepository(), TemplateRepository() + lab = organizations.row(connection, actor.tenant_id, "laboratory", lab_id) + require_active(lab["status"]) + # Shared lock is enough: published bodies are immutable and retained. + template = templates.get(connection, actor, template_id, share=True) + create_inspection( + actor, lab_id, template["status"], len(template["items"]), body["location_ids"] + ) + for location_id in sorted(body["location_ids"]): + location = organizations.row( + connection, actor.tenant_id, "location", location_id, laboratory_id=lab_id + ) + require_active(location["status"]) + inspection_id = str(uuid4()) + connection.execute( + text( + "INSERT INTO inspections (id,tenant_id,laboratory_id,template_id,inspector_id) " + "VALUES (:id,:tenant,:lab,:template,:actor)" + ), + { + "id": inspection_id, + "tenant": actor.tenant_id, + "lab": lab_id, + "template": template_id, + "actor": actor.user_id, + }, + ) + connection.execute( + text( + "INSERT INTO inspection_items " + "(id,tenant_id,inspection_id,laboratory_id,template_item_id,location_id) " + "VALUES (:id,:tenant,:inspection,:lab,:item,:location)" + ), + [ + { + "id": str(uuid4()), + "tenant": actor.tenant_id, + "inspection": inspection_id, + "lab": lab_id, + "item": item["id"], + "location": location, + } + for item in template["items"] + for location in body["location_ids"] + ], + ) + append_audit( + connection, + principal=actor, + action="inspection.create", + resource_type="inspection", + resource_id=inspection_id, + changes={"status": "draft", "template_id": template_id}, + reason="Create inspection", + request_id=request_id, + ) + return self.get(connection, actor, inspection_id) diff --git a/packages/persistence/organizations.py b/packages/persistence/organizations.py new file mode 100644 index 0000000..8cb07eb --- /dev/null +++ b/packages/persistence/organizations.py @@ -0,0 +1,119 @@ +"""Scoped organization read models and append-only creation commands.""" + +from uuid import uuid4 + +from sqlalchemy import text + +from packages.domain.foundations import location_parent, require_active +from packages.domain.security import Permission, authorize, not_found +from packages.persistence.foundations import BASE, clean, is_admin, lab_filter, page_query, project +from packages.persistence.security import append_audit + +TABLES = { + "college": ("colleges", BASE + ",name,code,status"), + "laboratory": ("laboratories", BASE + ",college_id,name,code,status"), + "location": ("locations", BASE + ",laboratory_id,parent_id,type,label,status"), +} + + +class OrganizationRepository: + def row(self, connection, tenant, kind, resource_id, *, laboratory_id=None): + table, columns = TABLES[kind] + where = "tenant_id=:tenant AND id=:id" + if laboratory_id is not None: + where += " AND laboratory_id=:lab" + row = ( + connection.execute( + text(f"SELECT {columns} FROM {table} WHERE {where} FOR SHARE"), + {"tenant": tenant, "id": resource_id, "lab": laboratory_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + return row + + def get(self, connection, actor, kind, resource_id): + row = self.row(connection, actor.tenant_id, kind, resource_id) + if kind == "college": + authorize(actor, Permission.READ, published=True) + if not is_admin(actor) and row["status"] != "active": + raise not_found() + else: + authorize(actor, Permission.READ, resource_id) + return project(row) + + def list(self, connection, actor, kind, page, page_size, laboratory_id=None): + table, columns = TABLES[kind] + params = {"tenant": actor.tenant_id} + where = "tenant_id=:tenant" + if kind == "college": + authorize(actor, Permission.READ, published=True) + if not is_admin(actor): + where += " AND status='active'" + else: + if laboratory_id is not None: + self.get(connection, actor, "laboratory", laboratory_id) + where += lab_filter( + actor, "id" if kind == "laboratory" else "laboratory_id", params, laboratory_id + ) + return page_query(connection, table, columns, where, params, page, page_size) + + def create(self, connection, actor, kind, body, request_id, *, laboratory_id=None): + authorize(actor, Permission.ADMIN) + tenant, resource_id = actor.tenant_id, str(uuid4()) + if kind in {"college", "laboratory"}: + params = { + "id": resource_id, + "tenant": tenant, + "code": clean(body["code"], 64), + "name": clean(body["name"], 200), + } + if kind == "college": + statement = ( + "INSERT INTO colleges (id,tenant_id,code,name) VALUES (:id,:tenant,:code,:name)" + ) + else: + college = self.row(connection, tenant, "college", body["college_id"]) + require_active(college["status"]) + params["college"] = college["id"] + statement = ( + "INSERT INTO laboratories (id,tenant_id,college_id,code,name) " + "VALUES (:id,:tenant,:college,:code,:name)" + ) + else: + lab = self.row(connection, tenant, "laboratory", laboratory_id) + require_active(lab["status"]) + parent = None + if body["parent_id"] is not None: + parent = self.row( + connection, tenant, "location", body["parent_id"], laboratory_id=laboratory_id + ) + require_active(parent["status"]) + location_parent(body["type"], parent["type"] if parent else None) + # New server ID + existing legal parent type makes a cycle impossible. + params = { + "id": resource_id, + "tenant": tenant, + "lab": laboratory_id, + "parent": body["parent_id"], + "kind": body["type"], + "label": clean(body["label"], 200), + } + statement = ( + "INSERT INTO locations (id,tenant_id,laboratory_id,parent_id,type,label) " + "VALUES (:id,:tenant,:lab,:parent,:kind,:label)" + ) + connection.execute(text(statement), params) + append_audit( + connection, + principal=actor, + action=f"{kind}.create", + resource_type=kind, + resource_id=resource_id, + changes={"status": "active"}, + reason=f"Create {kind}", + request_id=request_id, + ) + return project(self.row(connection, tenant, kind, resource_id)) diff --git a/packages/persistence/roles.py b/packages/persistence/roles.py new file mode 100644 index 0000000..381818c --- /dev/null +++ b/packages/persistence/roles.py @@ -0,0 +1,143 @@ +"""Tenant-scoped role assignments with user versioning and atomic revocation.""" + +from uuid import uuid4 + +from sqlalchemy import text + +from packages.domain.security import ( + Permission, + ServiceError, + authorize, + not_found, + permission_projection, + require_version, + valid_grant, +) +from packages.persistence.security import ( + UserSecurityService, + append_audit, + load_principal, + revoke_user_sessions, +) +from packages.persistence.transaction import lock_tenant, require_transaction, utc_now +from packages.persistence.users import UserRepository, timestamp + + +class RoleRepository: + @staticmethod + def laboratory(connection, tenant, laboratory_id): + row = connection.execute( + text("SELECT status FROM laboratories WHERE tenant_id=:tenant AND id=:id FOR SHARE"), + {"tenant": tenant, "id": laboratory_id}, + ).first() + if row is None: + raise not_found() + return row.status + + def list(self, connection, tenant, user_id, page, page_size, laboratory_id=None): + require_transaction(connection) + UserRepository().get(connection, tenant, user_id) + if laboratory_id is not None: + self.laboratory(connection, tenant, laboratory_id) + where = "ur.tenant_id=:tenant AND ur.user_id=:user" + if laboratory_id is not None: + where += " AND ur.laboratory_id=:lab" + params = { + "tenant": tenant, + "user": user_id, + "lab": laboratory_id, + "limit": page_size, + "offset": (page - 1) * page_size, + } + total = connection.scalar(text(f"SELECT COUNT(*) FROM user_roles ur WHERE {where}"), params) + rows = connection.execute( + text( + "SELECT ur.id,ur.created_at,ur.updated_at,ur.version,ur.user_id,r.code AS role," + "ur.scope_kind,ur.laboratory_id FROM user_roles ur JOIN roles r ON r.id=ur.role_id " + f"WHERE {where} ORDER BY ur.created_at DESC,ur.id DESC LIMIT :limit OFFSET :offset" + ), + params, + ).mappings() + items = [] + for row in rows: + item = dict(row) + for field in ("created_at", "updated_at"): + item[field] = timestamp(item[field]) + items.append(item) + return {"items": items, "total": total, "page": page, "page_size": page_size} + + def change(self, connection, actor, user_id, body, request_id, *, grant): + require_transaction(connection) + authorize(actor, Permission.ADMIN) + tenant = actor.tenant_id + # Application must take this lock before the idempotency INSERT's FK locks. + lock_tenant(connection, tenant, exclusive=True) + actor = load_principal(connection, tenant, actor.user_id) + authorize(actor, Permission.ADMIN) + users = UserRepository() + user = users.get(connection, tenant, user_id, lock=True) + require_version(user["version"], body["expected_version"]) + role, scope, lab = body["role"], body["scope_kind"], body["laboratory_id"] + if not valid_grant(role, scope, lab): + raise ServiceError("VALIDATION_ERROR", 422, "Invalid role scope") + if grant and user["status"] != "active": + raise ServiceError("STATE_CONFLICT", 409, "Cannot grant roles to a disabled user") + if lab is not None: + status = self.laboratory(connection, tenant, lab) + if grant and status != "active": + raise ServiceError("STATE_CONFLICT", 409, "Cannot grant roles in an archived lab") + role_id = connection.scalar( + text("SELECT id FROM roles WHERE code=:role FOR SHARE"), {"role": role} + ) + if role_id is None: + raise ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Role catalog is not ready") + params = {"tenant": tenant, "user": user_id, "role": role_id, "scope": scope, "lab": lab} + assignment = connection.scalar( + text( + "SELECT id FROM user_roles WHERE tenant_id=:tenant AND user_id=:user " + "AND role_id=:role AND scope_kind=:scope AND laboratory_id <=> :lab FOR UPDATE" + ), + params, + ) + if grant: + if assignment is not None: + raise ServiceError("STATE_CONFLICT", 409, "Role assignment already exists") + assignment = str(uuid4()) + connection.execute( + text( + "INSERT INTO user_roles " + "(id,tenant_id,user_id,role_id,scope_kind,laboratory_id) " + "VALUES (:id,:tenant,:user,:role,:scope,:lab)" + ), + {**params, "id": assignment}, + ) + # A grant must not make the next login exceed Session.permissions capacity. + permission_projection(load_principal(connection, tenant, user_id)) + else: + if assignment is None: + raise not_found() + if role == "safety_admin" and user["status"] == "active": + UserSecurityService()._last_admin_guard(connection, tenant, user_id) + connection.execute( + text("DELETE FROM user_roles WHERE tenant_id=:tenant AND user_id=:user AND id=:id"), + {"tenant": tenant, "user": user_id, "id": assignment}, + ) + connection.execute( + text( + "UPDATE users SET version=version+1,session_epoch=session_epoch+1,updated_at=:now " + "WHERE tenant_id=:tenant AND id=:user" + ), + {"tenant": tenant, "user": user_id, "now": utc_now()}, + ) + revoke_user_sessions(connection, tenant, user_id) + append_audit( + connection, + principal=actor, + action="role.grant" if grant else "role.revoke", + resource_type="user_role", + resource_id=assignment, + changes={"user_id": user_id, "role": role, "scope_kind": scope, "laboratory_id": lab}, + reason="Grant role" if grant else "Revoke role", + request_id=request_id, + ) + return users.get(connection, tenant, user_id, lock=True) diff --git a/packages/persistence/security.py b/packages/persistence/security.py index 00e77ad..74c8c7b 100644 --- a/packages/persistence/security.py +++ b/packages/persistence/security.py @@ -1,4 +1,4 @@ -"""Transaction-bound sessions and append-only audit; no public auth routes yet. +"""Transaction-bound sessions and append-only audit for the identity application. 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. @@ -107,6 +107,10 @@ def __init__(self, csrf_key: bytes): self._csrf_key = csrf_key self._dummy_hash = PASSWORD_HASHER.hash(secrets.token_urlsafe(32)) + def request_digest(self, body: Any) -> str: + """Keyed canonical hash: password-bearing requests cannot be guessed offline.""" + return hmac.new(self._csrf_key, canonical_json(body).encode(), hashlib.sha256).hexdigest() + 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") @@ -201,13 +205,20 @@ def authenticate( tenant, user = self.locate(connection, token) lock_tenant(connection, tenant, exclusive=exclusive) principal = load_principal(connection, tenant, user) + # The principal read already holds the user/role shared locks. A locking + # join below would also upgrade users to X for each independent session, + # deadlocking concurrent sessions of the same actor (S -> X). Read the + # epoch under S and lock only the session being touched. + current_epoch = connection.scalar( + text("SELECT session_epoch FROM users WHERE tenant_id=:tenant AND id=:user FOR SHARE"), + {"tenant": tenant, "user": 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 " + "s.session_epoch FROM sessions s " "WHERE s.tenant_id=:tenant AND s.user_id=:user " "AND s.token_hash=:hash FOR UPDATE" ), @@ -220,7 +231,7 @@ def authenticate( if ( not row or row["revoked_at"] is not None - or row["session_epoch"] != row["current_epoch"] + or row["session_epoch"] != current_epoch or row["expires_at"] <= now or row["idle_expires_at"] <= now ): @@ -351,8 +362,11 @@ def begin_idempotency( 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): + if ( + not isinstance(key, str) + or not re.fullmatch(r"[!-~]{8,128}", key) + 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() @@ -385,13 +399,14 @@ def begin_idempotency( errno = getattr(getattr(error, "orig", None), "args", [None])[0] if errno not in (1062, "1062"): raise + now = utc_now() # A competing insert may have waited before yielding the current row. 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" + "AND path_hash=:path AND key_hash=:key AND expires_at>:now FOR UPDATE NOWAIT" ), { "tenant": tenant_id, @@ -432,7 +447,9 @@ def begin_idempotency( ).rowcount if updated == 1: return IdempotencyClaim(row["id"], "pending", lease_owner=lease_owner) - return IdempotencyClaim(row["id"], "pending", lease_owner=row["lease_owner"]) + raise ServiceError( + "REQUEST_IN_PROGRESS", 409, "Request is still in progress", retry_after=2 + ) return IdempotencyClaim(row["id"], row["state"], row["response_status"], response) @@ -452,13 +469,15 @@ def complete_idempotency( 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" + "WHERE id=:id AND state='pending' AND lease_owner=:owner " + "AND lease_until>:now AND expires_at>:now" ), { "status": status, "response": json.dumps(response, ensure_ascii=False, separators=(",", ":")), "id": claim.record_id, "owner": owner, + "now": utc_now(), }, ) if result.rowcount != 1: @@ -472,7 +491,10 @@ class UserSecurityService: 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"), + text( + "SELECT id,status,version FROM users " + "WHERE tenant_id=:tenant AND id=:user FOR UPDATE" + ), {"tenant": tenant_id, "user": user_id}, ) .mappings() @@ -484,23 +506,28 @@ def _lock_user(connection: Connection, tenant_id: str, user_id: str) -> dict[str @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}, + active = ( + connection.execute( + text( + "SELECT u.id 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' " + "ORDER BY u.id FOR UPDATE" + ), + {"tenant": tenant_id}, + ) + .scalars() + .all() ) mine = connection.scalar( text( - "SELECT COUNT(*) FROM user_roles ur JOIN roles r ON r.id=ur.role_id " + "SELECT ur.id 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'" + "AND ur.scope_kind='tenant' FOR UPDATE" ), {"tenant": tenant_id, "user": user_id}, ) - if mine and active <= 1: + if mine and len(active) <= 1: raise ServiceError("LAST_ADMIN", 409, "Cannot remove the last safety_admin") def disable_user( @@ -518,6 +545,8 @@ def disable_user( raise ServiceError("NOT_FOUND", 404, "Not found") authorize(actor, Permission.ADMIN) lock_tenant(connection, tenant_id, exclusive=True) + actor = load_principal(connection, tenant_id, actor.user_id) + authorize(actor, Permission.ADMIN) user = self._lock_user(connection, tenant_id, user_id) if user["status"] == "active": self._last_admin_guard(connection, tenant_id, user_id) @@ -557,6 +586,8 @@ def revoke_safety_admin( raise ServiceError("NOT_FOUND", 404, "Not found") authorize(actor, Permission.ADMIN) lock_tenant(connection, tenant_id, exclusive=True) + actor = load_principal(connection, tenant_id, actor.user_id) + authorize(actor, Permission.ADMIN) self._lock_user(connection, tenant_id, user_id) self._last_admin_guard(connection, tenant_id, user_id) deleted = connection.execute( diff --git a/packages/persistence/templates.py b/packages/persistence/templates.py new file mode 100644 index 0000000..22ceb58 --- /dev/null +++ b/packages/persistence/templates.py @@ -0,0 +1,193 @@ +"""Immutable published template bodies with serialized family revision allocation.""" + +from uuid import uuid4 + +from sqlalchemy import text + +from packages.domain.foundations import template_items +from packages.domain.security import Permission, ServiceError, authorize, not_found, require_version +from packages.persistence.foundations import BASE, clean, is_admin, page_query, project +from packages.persistence.security import append_audit +from packages.persistence.transaction import utc_now + +COLUMNS = BASE + ",name,revision,status" + + +class TemplateRepository: + @staticmethod + def row(connection, tenant, template_id, *, lock=False, share=False): + row = ( + connection.execute( + text( + f"SELECT {COLUMNS},family_id FROM inspection_templates " + "WHERE tenant_id=:tenant AND id=:id" + + (" FOR UPDATE" if lock else " FOR SHARE" if share else "") + ), + {"tenant": tenant, "id": template_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + return row + + @staticmethod + def items(connection, tenant, template_id, *, share=False): + rows = ( + connection.execute( + text( + "SELECT id,code,title,capture_hint,sort_order,required FROM template_items " + "WHERE tenant_id=:tenant AND template_id=:id ORDER BY sort_order,id" + + (" FOR SHARE" if share else "") + ), + {"tenant": tenant, "id": template_id}, + ) + .mappings() + .all() + ) + return [{**row, "required": bool(row["required"])} for row in rows] + + def project(self, connection, tenant, row, *, share=False): + result = project({key: row[key] for key in COLUMNS.split(",")}) + result["items"] = self.items(connection, tenant, row["id"], share=share) + template_items(result["items"]) + return result + + def get(self, connection, actor, template_id, *, share=False): + authorize(actor, Permission.READ, published=True) + row = self.row(connection, actor.tenant_id, template_id, share=share) + if row["status"] != "published" and not is_admin(actor): + raise not_found() + return self.project(connection, actor.tenant_id, row, share=share) + + def list(self, connection, actor, page, page_size): + authorize(actor, Permission.READ, published=True) + where = "tenant_id=:tenant" + ("" if is_admin(actor) else " AND status='published'") + return page_query( + connection, + "inspection_templates", + COLUMNS, + where, + {"tenant": actor.tenant_id}, + page, + page_size, + project_row=lambda row: self.project(connection, actor.tenant_id, row), + ) + + def insert( + self, + connection, + actor, + name, + items, + request_id, + *, + family=None, + revision=1, + reason="Create template", + ): + items = [ + { + **item, + "code": clean(item["code"], 64), + "title": clean(item["title"], 200), + "capture_hint": clean(item["capture_hint"], 1000), + } + for item in items + ] + template_items(items) + template_id = str(uuid4()) + connection.execute( + text( + "INSERT INTO inspection_templates (id,tenant_id,family_id,revision,name) " + "VALUES (:id,:tenant,:family,:revision,:name)" + ), + { + "id": template_id, + "tenant": actor.tenant_id, + "family": family or template_id, + "revision": revision, + "name": clean(name, 200), + }, + ) + connection.execute( + text( + "INSERT INTO template_items " + "(id,tenant_id,template_id,code,title,capture_hint,sort_order,required) " + "VALUES (:id,:tenant,:template,:code,:title,:capture_hint,:sort_order,:required)" + ), + [{**item, "tenant": actor.tenant_id, "template": template_id} for item in items], + ) + append_audit( + connection, + principal=actor, + action="template.clone" if family else "template.create", + resource_type="template", + resource_id=template_id, + changes={"revision": revision}, + reason=reason, + request_id=request_id, + ) + return self.get(connection, actor, template_id) + + def create(self, connection, actor, body, request_id): + authorize(actor, Permission.ADMIN) + return self.insert(connection, actor, body["name"], body["items"], request_id) + + def change(self, connection, actor, template_id, body, request_id, *, clone): + authorize(actor, Permission.ADMIN) + if clone: + source = self.row(connection, actor.tenant_id, template_id) + # Every family root is a durable mutex, also when cloning a later revision. + self.row(connection, actor.tenant_id, source["family_id"], lock=True) + source = self.row(connection, actor.tenant_id, template_id, lock=True) + require_version(source["version"], body["expected_version"]) + items = self.items(connection, actor.tenant_id, template_id) + template_items(items) + if clone: + revisions = ( + connection.execute( + text( + "SELECT revision FROM inspection_templates " + "WHERE tenant_id=:tenant AND family_id=:family ORDER BY revision FOR UPDATE" + ), + {"tenant": actor.tenant_id, "family": source["family_id"]}, + ) + .scalars() + .all() + ) + if max(revisions) >= 2147483647: + raise ServiceError("STATE_CONFLICT", 409, "Template revision exceeds capacity") + # The selected source stays immutable; allocate after latest committed family revision. + return self.insert( + connection, + actor, + source["name"], + [{**i, "id": str(uuid4())} for i in items], + request_id, + family=source["family_id"], + revision=max(revisions) + 1, + reason=body["reason"], + ) + if source["status"] != "draft": + raise ServiceError("STATE_CONFLICT", 409, "Only a draft template can be published") + connection.execute( + text( + "UPDATE inspection_templates " + "SET status='published',version=version+1,updated_at=:now " + "WHERE tenant_id=:tenant AND id=:id" + ), + {"tenant": actor.tenant_id, "id": template_id, "now": utc_now()}, + ) + append_audit( + connection, + principal=actor, + action="template.publish", + resource_type="template", + resource_id=template_id, + changes={"status": "published"}, + reason=body["reason"], + request_id=request_id, + ) + return self.get(connection, actor, template_id) diff --git a/packages/persistence/uploads.py b/packages/persistence/uploads.py new file mode 100644 index 0000000..f56ba54 --- /dev/null +++ b/packages/persistence/uploads.py @@ -0,0 +1,178 @@ +"""Upload ownership and grant reservations; caller owns the transaction and quota mutex.""" + +from sqlalchemy import text + +from packages.domain.security import not_found +from packages.domain.uploads import require_grant_capacity +from packages.domain.workflow import Item, Task +from packages.persistence.security import append_audit + + +class UploadRepository: + def get(self, connection, actor, upload_id, *, lock=False): + row = ( + connection.execute( + text( + "SELECT * FROM uploads WHERE tenant_id=:tenant AND id=:id" + + (" FOR UPDATE" if lock else "") + ), + {"tenant": actor.tenant_id, "id": upload_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + return dict(row) + + def mark_validating(self, connection, actor, upload_id, request_id): + changed = connection.execute( + text( + "UPDATE uploads SET status='validating',version=version+1," + "updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=:tenant AND id=:id " + "AND status='granted'" + ), + {"tenant": actor.tenant_id, "id": upload_id}, + ).rowcount + if changed != 1: + raise RuntimeError("Upload transition lost its locked precondition") + append_audit( + connection, + principal=actor, + action="upload.complete", + resource_type="upload", + resource_id=upload_id, + changes={"status": "validating"}, + reason="Accept upload for asynchronous image validation", + request_id=request_id, + ) + + def owner(self, connection, actor, owner_type, owner_id): + params = {"tenant": actor.tenant_id, "id": owner_id} + if owner_type == "inspection_item": + parent_id = connection.scalar( + text( + "SELECT inspection_id FROM inspection_items WHERE tenant_id=:tenant AND id=:id" + ), + params, + ) + if parent_id is None: + raise not_found() + parent = ( + connection.execute( + text( + "SELECT id,laboratory_id,status,inspector_id FROM inspections " + "WHERE tenant_id=:tenant AND id=:parent FOR UPDATE" + ), + {**params, "parent": parent_id}, + ) + .mappings() + .first() + ) + row = ( + connection.execute( + text( + "SELECT id,tenant_id,laboratory_id,version,status,location_id," + "current_run_id,current_evaluation_id,inspection_id FROM inspection_items " + "WHERE tenant_id=:tenant AND id=:id FOR UPDATE" + ), + params, + ) + .mappings() + .first() + ) + if ( + parent is None + or row is None + or row["inspection_id"] != parent["id"] + or row["laboratory_id"] != parent["laboratory_id"] + ): + raise not_found() + return Item( + **{ + k: row[k] + for k in Item.__dataclass_fields__ + if k not in {"inspection_status", "creator_id"} + }, + inspection_status=parent["status"], + creator_id=parent["inspector_id"], + ) + if owner_type == "remediation_task": + row = ( + connection.execute( + text( + "SELECT id,tenant_id,laboratory_id,version,status," + "finding_id,assignee_id,latest_evidence_id FROM remediation_tasks " + "WHERE tenant_id=:tenant AND id=:id FOR UPDATE" + ), + params, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + return Task(**dict(row)) + raise ValueError("Unknown upload owner type") + + def reserve( + self, + connection, + actor, + owner, + body, + *, + upload_id, + key, + expires_at, + captured_at, + now, + request_id, + ): + # Current read after waiting for the user mutex, not the locator's old RR snapshot. + expirations = ( + connection.execute( + text( + "SELECT expires_at FROM uploads " + "WHERE tenant_id=:tenant AND requested_by=:user AND status='granted' " + "AND expires_at>:now FOR SHARE" + ), + {"tenant": actor.tenant_id, "user": actor.user_id, "now": now}, + ) + .scalars() + .all() + ) + require_grant_capacity(expirations, now) + connection.execute( + text( + "INSERT INTO uploads (id,tenant_id,laboratory_id,inspection_item_id," + "remediation_task_id,requested_by,object_key,expected_sha256,mime_type,size_bytes," + "captured_at,expires_at) VALUES " + "(:id,:tenant,:lab,:item,:task,:user,:key,:sha,:mime," + ":size,:captured,:expires)" + ), + { + "id": upload_id, + "tenant": actor.tenant_id, + "lab": owner.laboratory_id, + "item": owner.id if body["owner_type"] == "inspection_item" else None, + "task": owner.id if body["owner_type"] == "remediation_task" else None, + "user": actor.user_id, + "key": key, + "sha": body["sha256"], + "mime": body["mime_type"], + "size": body["size_bytes"], + "captured": captured_at, + "expires": expires_at, + }, + ) + append_audit( + connection, + principal=actor, + action="upload.create", + resource_type="upload", + resource_id=upload_id, + changes={"status": "granted"}, + reason="Create upload grant", + request_id=request_id, + ) diff --git a/packages/persistence/users.py b/packages/persistence/users.py new file mode 100644 index 0000000..71c3d06 --- /dev/null +++ b/packages/persistence/users.py @@ -0,0 +1,120 @@ +"""Tenant-scoped user repository; all projections exclude authentication secrets.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError + +from packages.domain.security import ServiceError, not_found, require_version +from packages.persistence.bootstrap import PASSWORD_HASHER, normalized +from packages.persistence.security import UserSecurityService, append_audit + +USER_COLUMNS = "id,created_at,updated_at,version,username,display_name,status" + + +def timestamp(value: datetime) -> str: + return ( + value.replace(tzinfo=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + ) + + +def project_user(row): + result = dict(row) + for field in ("created_at", "updated_at"): + result[field] = timestamp(result[field]) + return result + + +class UserRepository: + def get(self, connection, tenant, user_id, *, lock=False): + suffix = " FOR UPDATE" if lock else "" + row = ( + connection.execute( + text( + f"SELECT {USER_COLUMNS} FROM users WHERE tenant_id=:tenant AND id=:id{suffix}" + ), + {"tenant": tenant, "id": user_id}, + ) + .mappings() + .first() + ) + if row is None: + raise not_found() + return project_user(row) + + def list(self, connection, tenant, page, page_size): + # Both nonlocking reads share the transaction's REPEATABLE READ snapshot. + total = connection.scalar( + text("SELECT COUNT(*) FROM users WHERE tenant_id=:tenant"), {"tenant": tenant} + ) + rows = connection.execute( + text( + f"SELECT {USER_COLUMNS} FROM users WHERE tenant_id=:tenant " + "ORDER BY created_at DESC,id DESC LIMIT :limit OFFSET :offset" + ), + {"tenant": tenant, "limit": page_size, "offset": (page - 1) * page_size}, + ).mappings() + return { + "items": [project_user(row) for row in rows], + "total": total, + "page": page, + "page_size": page_size, + } + + def create(self, connection, actor, body, request_id): + try: + username = normalized(body["username"], 128, account=True) + display = normalized(body["display_name"], 100) + except (ValueError, TypeError): + raise ServiceError("VALIDATION_ERROR", 422, "Invalid user details") from None + password = body["initial_password"] + if not 12 <= len(password) <= 128: + raise ServiceError( + "VALIDATION_ERROR", 422, "Password must contain 12 to 128 characters" + ) + user_id = str(uuid4()) + try: + connection.execute( + text( + "INSERT INTO users (id,tenant_id,username,display_name,password_hash) " + "VALUES (:id,:tenant,:username,:display,:encoded)" + ), + { + "id": user_id, + "tenant": actor.tenant_id, + "username": username, + "display": display, + "encoded": PASSWORD_HASHER.hash(password), + }, + ) + except IntegrityError as error: + if getattr(error.orig, "args", [None])[0] == 1062: + raise ServiceError("STATE_CONFLICT", 409, "Username already exists") from None + raise + append_audit( + connection, + principal=actor, + action="user.create", + resource_type="user", + resource_id=user_id, + changes={"status": "active"}, + reason="Create user", + request_id=request_id, + ) + return self.get(connection, actor.tenant_id, user_id, lock=True) + + def disable(self, connection, actor, user_id, body, request_id): + user = self.get(connection, actor.tenant_id, user_id, lock=True) + require_version(user["version"], body["expected_version"]) + if user["status"] != "active": + raise ServiceError("STATE_CONFLICT", 409, "User is already disabled") + UserSecurityService().disable_user( + connection, + tenant_id=actor.tenant_id, + user_id=user_id, + actor=actor, + request_id=request_id, + reason=body["reason"], + ) + return self.get(connection, actor.tenant_id, user_id, lock=True) diff --git a/packages/storage/__init__.py b/packages/storage/__init__.py new file mode 100644 index 0000000..5c28077 --- /dev/null +++ b/packages/storage/__init__.py @@ -0,0 +1 @@ +"""Explicit-credential object adapters; no default AWS profile discovery.""" diff --git a/packages/storage/s3.py b/packages/storage/s3.py new file mode 100644 index 0000000..1746dd7 --- /dev/null +++ b/packages/storage/s3.py @@ -0,0 +1,269 @@ +"""Bounded S3 adapter. Presigning is local; HEAD/GET must run outside DB transactions.""" + +import hashlib +import hmac +import os +import re +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import boto3 +import botocore.session +from botocore.config import Config +from botocore.exceptions import BotoCoreError, ClientError + +from packages.domain.security import ServiceError +from packages.domain.uploads import GRANT_SECONDS, MAX_BYTES, MIME_TYPES, digest, staging_key + +BUCKET = "labsafe-private" + + +def dependency(): + return ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Object storage unavailable") + + +def endpoint(value, *, public=False): + try: + parsed = urlsplit(value) + if ( + not isinstance(value, str) + or any(c.isspace() for c in value) + or parsed.scheme not in ({"https"} if public else {"http", "https"}) + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or "?" in value + or "#" in value + or "\\" in value + or (public and parsed.port not in (None, 443)) + ): + raise ValueError + parsed.port # Reject invalid port strings on internal endpoints too. + except (ValueError, TypeError, AttributeError): + raise ValueError("Invalid S3 endpoint configuration") from None + return value + + +@dataclass(frozen=True, kw_only=True) +class S3Settings: + internal_endpoint: str + public_endpoint: str + public_origin: str + access_key: str = field(repr=False) + secret_key: str = field(repr=False) + bucket: str = BUCKET + region: str = "us-east-1" + + def __post_init__(self): + endpoint(self.internal_endpoint) + endpoint(self.public_endpoint, public=True) + if ( + self.public_endpoint != self.public_origin + or self.bucket != BUCKET + or self.region != "us-east-1" + ): + raise ValueError("S3 origin, bucket or region differs from the deployment contract") + for secret in (self.access_key, self.secret_key): + if ( + not isinstance(secret, str) + or not 1 <= len(secret) <= 256 + or any(not 33 <= ord(c) <= 126 for c in secret) + ): + raise ValueError("Invalid S3 credentials") + + @classmethod + def from_environment(cls, public_origin): + try: + + def secret(name): + # No provider chain, ~/.aws, environment credential fallback or metadata service. + return Path(os.environ[name]).read_text(encoding="ascii").rstrip("\r\n") + + return cls( + internal_endpoint=os.environ["S3_ENDPOINT"], + public_endpoint=os.environ["S3_PUBLIC_ENDPOINT"], + public_origin=public_origin, + access_key=secret("S3_ACCESS_KEY_FILE"), + secret_key=secret("S3_SECRET_KEY_FILE"), + bucket=os.environ.get("S3_BUCKET", BUCKET), + region=os.environ.get("S3_REGION", "us-east-1"), + ) + except (OSError, UnicodeError, KeyError, ValueError): + raise ValueError("S3 configuration or credential files are invalid") from None + + +@dataclass(frozen=True) +class PutGrant: + url: str = field(repr=False) + expires_at: datetime + + +@dataclass(frozen=True) +class ObjectVersion: + key: str + version_id: str + size_bytes: int + mime_type: str + + +class S3Storage: + def __init__(self, settings: S3Settings): + self.settings = settings + config = Config( + signature_version="s3v4", + s3={"addressing_style": "path"}, + connect_timeout=2, + read_timeout=5, + retries={"mode": "standard", "total_max_attempts": 2}, + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + proxies={}, + ) + isolated = botocore.session.Session() + isolated.set_config_variable("config_file", os.devnull) + isolated.set_config_variable("credentials_file", os.devnull) + isolated.get_component("config_store").set_config_variable("profile", None) + session = boto3.session.Session( + aws_access_key_id=settings.access_key, + aws_secret_access_key=settings.secret_key, + region_name=settings.region, + botocore_session=isolated, + ) + self.internal = session.client("s3", endpoint_url=settings.internal_endpoint, config=config) + try: + self.signing = session.client( + "s3", endpoint_url=settings.public_endpoint, config=config + ) + except Exception: + self.internal.close() + raise + + def close(self): + try: + self.internal.close() + finally: + self.signing.close() + + def presign_put(self, tenant_id, upload_id, mime_type): + """Local SigV4 only: fixed credentials, no network or credential refresh.""" + if mime_type not in MIME_TYPES: + raise ValueError("Unsupported grant content type") + key = staging_key(tenant_id, upload_id) + try: + url = self.signing.generate_presigned_url( + "put_object", + Params={"Bucket": BUCKET, "Key": key, "ContentType": mime_type}, + ExpiresIn=GRANT_SECONDS, + HttpMethod="PUT", + ) + parts, origin = urlsplit(url), urlsplit(self.settings.public_origin) + query = parse_qs(parts.query, strict_parsing=True) + if ( + parts.scheme != origin.scheme + or parts.netloc != origin.netloc + or parts.path != f"/{BUCKET}/{key}" + or parts.fragment + or len(url) > 4096 + or any(len(v) != 1 for v in query.values()) + or query.get("X-Amz-Algorithm") != ["AWS4-HMAC-SHA256"] + or query.get("X-Amz-Expires") != [str(GRANT_SECONDS)] + or query.get("X-Amz-SignedHeaders") != ["content-type;host"] + ): + raise ValueError + started = datetime.strptime(query["X-Amz-Date"][0], "%Y%m%dT%H%M%SZ") + expected_credential = ( + f"{self.settings.access_key}/{started:%Y%m%d}/us-east-1/s3/aws4_request" + ) + if ( + query.get("X-Amz-Credential") != [expected_credential] + or re.fullmatch(r"[a-f0-9]{64}", query.get("X-Amz-Signature", [""])[0]) is None + ): + raise ValueError + return PutGrant(url, started + timedelta(seconds=GRANT_SECONDS)) + except (BotoCoreError, ClientError, ValueError, KeyError, TypeError): + raise dependency() from None + + @staticmethod + def _failure(error): + if isinstance(error, ClientError) and error.response.get("Error", {}).get("Code") in { + "NoSuchKey", + "NoSuchVersion", + "NotFound", + "404", + }: + return ServiceError("OBJECT_NOT_FOUND", 404, "Object version not found") + return dependency() # No IAM details, endpoints or provider messages in public errors. + + def inspect_staging(self, tenant_id, upload_id): + key = staging_key(tenant_id, upload_id) + try: + result = self.internal.head_object(Bucket=BUCKET, Key=key) + except (BotoCoreError, ClientError) as error: + raise self._failure(error) from None + version = result.get("VersionId") + size, mime = result.get("ContentLength"), result.get("ContentType") + if ( + not isinstance(version, str) + or not version + or version == "null" + or len(version) > 200 + or any(ord(c) < 33 or ord(c) > 126 for c in version) + or result.get("DeleteMarker") is True + ): + raise dependency() # Versioning must be enabled; never silently pin the latest object. + if type(size) is not int or size < 1: + raise ServiceError("IMAGE_INVALID", 422, "Empty or invalid object") + if size > MAX_BYTES: + raise ServiceError("IMAGE_TOO_LARGE", 413, "Image exceeds 15 MiB") + if mime not in MIME_TYPES: + raise ServiceError("UNSUPPORTED_MEDIA_TYPE", 415, "Unsupported object media type") + return ObjectVersion(key, version, size, mime) + + def verify_staging_hash(self, tenant_id, upload_id, pinned: ObjectVersion, expected_sha256): + """Future general-worker primitive, NOT image decoding or a ready-state decision.""" + digest(expected_sha256) + if ( + pinned.key != staging_key(tenant_id, upload_id) + or not pinned.version_id + or pinned.version_id == "null" + or len(pinned.version_id) > 200 + or type(pinned.size_bytes) is not int + or not 1 <= pinned.size_bytes <= MAX_BYTES + or pinned.mime_type not in MIME_TYPES + ): + raise ValueError("Invalid pinned staging reference") + stream = None + try: + result = self.internal.get_object( + Bucket=BUCKET, Key=pinned.key, VersionId=pinned.version_id + ) + stream = result["Body"] + if ( + result.get("VersionId") != pinned.version_id + or result.get("ContentLength") != pinned.size_bytes + or result.get("ContentType") != pinned.mime_type + or result.get("DeleteMarker") is True + ): + raise ServiceError("IMAGE_INVALID", 422, "Pinned object metadata changed") + checksum, total = hashlib.sha256(), 0 + while chunk := stream.read(64 * 1024): + total += len(chunk) + if total > MAX_BYTES or total > pinned.size_bytes: + raise ServiceError("IMAGE_INVALID", 422, "Object size does not match grant") + checksum.update(chunk) + if total != pinned.size_bytes: + raise ServiceError("IMAGE_INVALID", 422, "Object stream was truncated") + actual = checksum.hexdigest() + if not hmac.compare_digest(actual, expected_sha256): + raise ServiceError("HASH_MISMATCH", 422, "Object content hash does not match grant") + return actual + except (BotoCoreError, ClientError, OSError) as error: + raise self._failure(error) from None + finally: + if stream is not None: + stream.close() diff --git a/pyproject.toml b/pyproject.toml index 73dc80e..b23e6cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ readme = "DEVELOPMENT.md" dependencies = [] [project.optional-dependencies] +storage = ["boto3==1.43.106"] ai = [ "fastapi==0.115.6", "pydantic==2.10.5", diff --git a/requirements/py311-business.txt b/requirements/py311-business.txt index 3b1ac09..a51633f 100644 --- a/requirements/py311-business.txt +++ b/requirements/py311-business.txt @@ -8,4 +8,5 @@ sqlalchemy==2.0.37 pymysql[rsa]==1.1.1 argon2-cffi==23.1.0 tzdata==2026.4 +boto3==1.43.106 diff --git a/tests/business/test_foundation_boundary.py b/tests/business/test_foundation_boundary.py new file mode 100644 index 0000000..abd7fa0 --- /dev/null +++ b/tests/business/test_foundation_boundary.py @@ -0,0 +1,214 @@ +"""Strict request contracts without database dependencies.""" + +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from apps.api.app.main import create_app +from tests.business.test_identity_boundary import ORIGIN +from tests.helpers import configured + +ID = str(uuid4()) +HEADERS = {"Origin": ORIGIN, "Idempotency-Key": "foundation-test-key", "X-CSRF-Token": "s" * 43} +ITEM = { + "id": ID, + "code": "item", + "title": "Title", + "capture_hint": "Hint", + "sort_order": 0, + "required": True, +} +COMMANDS = [ + ("/colleges", "college", "create", {"name": "College", "code": "col"}, None, None), + ( + "/laboratories", + "laboratory", + "create", + {"college_id": ID, "name": "Lab", "code": "lab"}, + None, + None, + ), + ( + f"/laboratories/{ID}/locations", + "location", + "create", + {"parent_id": None, "type": "room", "label": "Room"}, + None, + ID, + ), + ("/templates", "template", "create", {"name": "Template", "items": [ITEM]}, None, None), + ( + f"/templates/{ID}/publish", + "template", + "publish", + {"expected_version": 1, "reason": "Publish"}, + ID, + None, + ), + ( + f"/templates/{ID}/clone", + "template", + "clone", + {"expected_version": 2, "reason": "Clone"}, + ID, + None, + ), + ( + "/inspections", + "inspection", + "create", + {"laboratory_id": ID, "template_id": ID, "location_ids": [ID]}, + None, + None, + ), +] + + +def client(service): + with configured(): + app = create_app(identity=Mock(), public_origin=ORIGIN) + app.state.foundations = service + return TestClient(app, base_url=ORIGIN) + + +@pytest.mark.parametrize("path,kind,operation,body,resource_id,lab", COMMANDS) +def test_write_contract(path, kind, operation, body, resource_id, lab): + service = Mock() + status = 200 if operation == "publish" else 201 + service.write.return_value = (status, {"data": {}, "request_id": ID}) + with client(service) as http: + result = http.post("/api/v1" + path, json=body, headers=HEADERS) + assert result.status_code == status, result.text + assert result.headers["cache-control"] == "no-store" + args = service.write.call_args + assert args.args[:2] == (kind, operation) and args.args[5] == body + assert args.kwargs == {"resource_id": resource_id, "laboratory_id": lab} + + +@pytest.mark.parametrize("path,kind,operation,body,resource_id,lab", COMMANDS) +def test_writes_keep_shared_security_boundary(path, kind, operation, body, resource_id, lab): + service = Mock() + with client(service) as http: + for invalid in ({**body, "tenant_id": ID}, {}): + assert http.post("/api/v1" + path, json=invalid, headers=HEADERS).status_code == 422 + assert http.post("/api/v1" + path, json=body).status_code == 403 + assert http.post("/api/v1" + path, content="x", headers=HEADERS).status_code == 415 + assert http.post("/api/v1" + path, json=body, headers={"Origin": ORIGIN}).status_code == 422 + duplicate = [*HEADERS.items(), ("X-CSRF-Token", "duplicate")] + assert http.post("/api/v1" + path, json=body, headers=duplicate).status_code == 422 + assert ( + http.post("/api/v1" + path + "?unexpected=1", json=body, headers=HEADERS).status_code + == 422 + ) + service.write.assert_not_called() + + +@pytest.mark.parametrize( + "patch", + [ + {"required": 1}, + {"required": "true"}, + {"sort_order": True}, + {"sort_order": "1"}, + {"sort_order": -1}, + {"sort_order": 2147483648}, + {"id": "bad"}, + {"id": 7}, + {"capture_hint": ""}, + {"extra": 1}, + ], +) +def test_strict_template_item(patch): + service = Mock() + with client(service) as http: + result = http.post( + "/api/v1/templates", + headers=HEADERS, + json={"name": "Template", "items": [{**ITEM, **patch}]}, + ) + assert result.status_code == 422, result.text + service.write.assert_not_called() + + +def test_nullable_parent_required_and_uuid_normalization(): + service = Mock() + service.write.return_value = (201, {"data": {}, "request_id": ID}) + with client(service) as http: + path = f"/api/v1/laboratories/{ID}/locations" + assert ( + http.post(path, json={"type": "room", "label": "R"}, headers=HEADERS).status_code == 422 + ) + assert ( + http.post( + path, json={"parent_id": ID.upper(), "type": "area", "label": "A"}, headers=HEADERS + ).status_code + == 201 + ) + assert service.write.call_args.args[5]["parent_id"] == ID + + +@pytest.mark.parametrize( + "path", + ["/colleges", "/laboratories", "/templates", "/inspections", f"/laboratories/{ID}/locations"], +) +def test_pagination_query_boundaries(path): + service = Mock() + service.read.return_value = {"data": {}, "request_id": ID} + with client(service) as http: + for query in ["page=0", "page_size=101", "page=1&page=2", "unknown=1"]: + assert http.get("/api/v1" + path + "?" + query).status_code == 422 + assert http.get("/api/v1" + path + "?page=2&page_size=3").status_code == 200 + assert service.read.call_args.kwargs["page"] == 2 + assert service.read.call_args.kwargs["page_size"] == 3 + + +def test_query_scopes_and_unconfigured_foundations(): + service = Mock() + service.read.return_value = {"data": {}, "request_id": ID} + with client(service) as http: + for path in ("/colleges", "/templates", f"/laboratories/{ID}/locations"): + assert http.get("/api/v1" + path + f"?laboratory_id={ID}").status_code == 422 + for path in ("/laboratories", "/inspections"): + assert http.get("/api/v1" + path + f"?laboratory_id={ID}").status_code == 200 + assert service.read.call_args.kwargs["laboratory_id"] == ID + assert http.get("/api/v1" + path + "?laboratory_id=bad").status_code == 422 + with client(None) as http: + assert http.get("/api/v1/colleges").status_code == 503 + assert ( + http.post( + "/api/v1/colleges", json={"name": "N", "code": "C"}, headers=HEADERS + ).status_code + == 503 + ) + + +def test_maximal_unicode_template_passes_bounded_body_limit(): + service = Mock() + service.write.return_value = (201, {"data": {}, "request_id": ID}) + body = { + "name": "T", + "items": [ + { + **ITEM, + "id": str(uuid4()), + "code": str(i) + "😀" * 60, + "title": "😀" * 200, + "capture_hint": "😀" * 1000, + "sort_order": i, + } + for i in range(100) + ], + } + with client(service) as http: + assert http.post("/api/v1/templates", json=body, headers=HEADERS).status_code == 201 + assert ( + http.post( + "/api/v1/templates", + content=b" " * (2097152 + 1), + headers={**HEADERS, "Content-Type": "application/json"}, + ).status_code + == 422 + ) + assert service.write.call_count == 1 diff --git a/tests/business/test_identity_application.py b/tests/business/test_identity_application.py new file mode 100644 index 0000000..c538688 --- /dev/null +++ b/tests/business/test_identity_application.py @@ -0,0 +1,69 @@ +"""Application retry boundaries and readiness do not require a live database.""" + +from contextlib import contextmanager +from unittest.mock import Mock + +import pytest +from sqlalchemy.exc import OperationalError + +from packages.application.identity import IdentityApplication, retry_deadlocks +from packages.domain.security import ServiceError + + +def deadlock(errno=1213): + return OperationalError("test statement", {}, Exception(errno, "test error")) + + +def test_deadlock_retries_whole_command_without_changing_version(): + command = Mock(side_effect=[deadlock(), deadlock(), "committed"]) + assert retry_deadlocks(command)(expected_version=7) == "committed" + assert command.call_count == 3 + assert all(call.kwargs == {"expected_version": 7} for call in command.call_args_list) + + +def test_deadlock_retries_stop_after_three_attempts(): + command = Mock(side_effect=deadlock()) + with pytest.raises(ServiceError) as error: + retry_deadlocks(command)() + assert command.call_count == 3 + assert error.value.code == "REQUEST_IN_PROGRESS" and error.value.retry_after == 2 + + +@pytest.mark.parametrize("errno", [1205, 2003, 3572]) +def test_non_deadlock_database_errors_are_not_retried(errno): + command = Mock(side_effect=deadlock(errno)) + with pytest.raises(OperationalError): + retry_deadlocks(command)() + assert command.call_count == 1 + + +@pytest.mark.parametrize("revision", ["0001_initial", "unknown", None]) +def test_readiness_checks_revision_and_releases_connection_before_redis(revision): + connected = False + connection = Mock() + connection.scalar.return_value = revision + + @contextmanager + def connect(): + nonlocal connected + connected = True + try: + yield connection + finally: + connected = False + + def ready(): + assert not connected + + engine, limits = Mock(), Mock() + engine.connect.side_effect = connect + limits.ready.side_effect = ready + app = IdentityApplication(engine, Mock(), limits, "test") + if revision == "0001_initial": + app.readiness() + limits.ready.assert_called_once_with() + else: + with pytest.raises(ServiceError) as error: + app.readiness() + assert error.value.status == 503 + limits.ready.assert_not_called() diff --git a/tests/business/test_identity_boundary.py b/tests/business/test_identity_boundary.py new file mode 100644 index 0000000..91c3cba --- /dev/null +++ b/tests/business/test_identity_boundary.py @@ -0,0 +1,139 @@ +"""HTTP adapter checks; database behavior is tested against disposable MySQL.""" + +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import OperationalError + +from apps.api.app.main import create_app +from packages.domain.security import ServiceError +from tests.helpers import configured + +ORIGIN = "https://labsafe.test" +LOGIN = {"tenant_code": "org", "username": "admin", "password": "long-test-password"} + + +def client(service=None): + with configured(): + return TestClient( + create_app(identity=service or Mock(), public_origin=ORIGIN), base_url=ORIGIN + ) + + +def test_identity_rejects_origin_content_type_extra_fields_and_redacts_inputs(): + service = Mock() + with client(service) as http: + assert http.post("/api/v1/auth/login", json=LOGIN).status_code == 403 + assert ( + http.post( + "/api/v1/auth/login", json=LOGIN, headers={"Origin": "https://evil.test"} + ).status_code + == 403 + ) + assert ( + http.post( + "/api/v1/auth/login", content="username=x", headers={"Origin": ORIGIN} + ).status_code + == 415 + ) + result = http.post( + "/api/v1/auth/login", + json={**LOGIN, "tenant_id": str(uuid4())}, + headers={"Origin": ORIGIN}, + ) + assert result.status_code == 422 + assert LOGIN["password"] not in result.text + assert not service.login.called + + +def test_cookie_flags_and_forwarded_ip_not_trusted(): + service = Mock() + service.login.return_value = ({"data": {}, "request_id": str(uuid4())}, "s" * 43) + with client(service) as http: + result = http.post( + "/api/v1/auth/login", + json=LOGIN, + headers={"Origin": ORIGIN, "X-Forwarded-For": "9.9.9.9"}, + ) + assert result.status_code == 200 + cookie = result.headers["set-cookie"].lower() + for flag in ("secure", "httponly", "samesite=lax", "path=/", "max-age=28800"): + assert flag in cookie + assert service.login.call_args.args[1] != "9.9.9.9" + assert result.headers["cache-control"] == "no-store" + + +def test_logout_and_write_header_validation(): + service = Mock() + service.write.return_value = (200, {"data": {"ok": True}, "request_id": str(uuid4())}) + with client(service) as http: + assert ( + http.post("/api/v1/auth/logout", json={}, headers={"Origin": ORIGIN}).status_code == 422 + ) + headers = {"Origin": ORIGIN, "X-CSRF-Token": "s" * 43, "Idempotency-Key": "request-123"} + result = http.post("/api/v1/auth/logout", json={}, headers=headers) + assert result.status_code == 200 + assert "Max-Age=0" in result.headers["set-cookie"] + assert ( + http.post("/api/v1/auth/logout", json={"extra": True}, headers=headers).status_code + == 422 + ) + bad = {"expected_version": True, "reason": "test"} + assert ( + http.post(f"/api/v1/users/{uuid4()}/disable", json=bad, headers=headers).status_code + == 422 + ) + + +def test_queries_and_exceptions_are_redacted(): + service = Mock() + service.read.side_effect = RuntimeError("private password and database URL") + with client(service) as http: + for query in ("laboratory_id=bad", "page=0", "page_size=101", "page=1&page=2"): + assert http.get("/api/v1/users?" + query).status_code == 422 + response = http.get("/api/v1/me") + assert response.status_code == 500 + assert "password" not in response.text + + +def test_unconfigured_identity_does_not_fake_success(): + with configured(): + app = create_app(public_origin=ORIGIN) + with TestClient(app, base_url=ORIGIN) as http: + assert http.get("/api/v1/me").status_code == 503 + + +def test_readiness_checks_identity_dependencies_without_claiming_system_readiness(): + service = Mock() + with client(service) as http: + response = http.get("/ready") + assert response.status_code == 200 + assert response.json()["is_simulated"] is True + assert "identity, I-02D foundations, I-02E item queries" in response.json()["scope"] + assert ( + "optional I-02F1/F2 upload signing and validation acceptance only" + in response.json()["scope"] + ) + assert "readiness not implemented" in response.json()["scope"] + service.readiness.assert_called_once_with() + + +@pytest.mark.parametrize( + "failure", + [ + ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Rate limiter unavailable"), + ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Database revision is not ready"), + OperationalError("private SQL", {}, Exception(2003, "private URL")), + ], +) +def test_readiness_dependency_failures_are_503_and_health_remains_alive(failure): + service = Mock() + service.readiness.side_effect = failure + with client(service) as http: + response = http.get("/ready") + assert response.status_code == 503 + assert response.json()["error"]["code"] == "DEPENDENCY_UNAVAILABLE" + assert "private" not in response.text + assert http.get("/health").status_code == 200 diff --git a/tests/business/test_image_projection.py b/tests/business/test_image_projection.py new file mode 100644 index 0000000..84166dd --- /dev/null +++ b/tests/business/test_image_projection.py @@ -0,0 +1,58 @@ +"""Image projection must be JSON-safe before storing the idempotent response.""" + +from datetime import datetime +from uuid import uuid4 + +import pytest + +from packages.persistence.images import ImageRepository +from packages.shared.json_hash import canonical_json + + +@pytest.mark.parametrize("owner", ["inspection_item", "remediation_task"]) +@pytest.mark.parametrize("status", ["validating", "ready", "rejected", "deleted"]) +def test_projection_has_only_contract_fields_and_all_dates_are_utc_strings(owner, status): + now, owner_id = datetime(2026, 10, 1, 2, 3, 4, 123000), str(uuid4()) + row = { + "id": str(uuid4()), + "created_at": now, + "updated_at": now, + "version": 1, + "laboratory_id": str(uuid4()), + "status": status, + "original_sha256": "a" * 64, + "analysis_sha256": None, + "width": None, + "height": None, + "mime_type": "image/png", + "captured_at": now, + "inspection_item_id": owner_id if owner == "inspection_item" else None, + "remediation_task_id": owner_id if owner == "remediation_task" else None, + "tenant_id": str(uuid4()), + "upload_id": str(uuid4()), + "original_key": "internal-key", + "original_object_version": "internal-version", + "legal_hold": True, + } + projected = ImageRepository.project(row) + for field in ("created_at", "updated_at", "captured_at"): + assert projected[field] == "2026-10-01T02:03:04.123Z" + assert projected["owner_type"] == owner and projected["owner_id"] == owner_id + assert set(projected) == { + "id", + "created_at", + "updated_at", + "version", + "owner_type", + "owner_id", + "laboratory_id", + "status", + "original_sha256", + "analysis_sha256", + "width", + "height", + "mime_type", + "captured_at", + } + encoded = canonical_json({"data": projected, "request_id": str(uuid4())}) + assert "internal-key" not in encoded and "internal-version" not in encoded diff --git a/tests/business/test_item_query_application.py b/tests/business/test_item_query_application.py new file mode 100644 index 0000000..c55fee4 --- /dev/null +++ b/tests/business/test_item_query_application.py @@ -0,0 +1,109 @@ +"""Read transaction boundaries, authoritative actor reload and bounded retries.""" + +from contextlib import contextmanager +from unittest.mock import Mock + +import pytest + +from packages.application.item_queries import ItemQueryApplication +from packages.domain.security import ServiceError +from tests.business.test_identity_application import deadlock + + +def application(): + identity = Mock() + connection = object() + active = False + + @contextmanager + def transaction(): + nonlocal active + assert not active + active = True + try: + yield connection + finally: + active = False + + def limit(actor, *, write): + assert actor is identity._preflight.return_value + assert not active and write is False + + def authenticate(conn, token): + assert active and conn is connection and token == "token" + return Mock(principal="reloaded-actor") + + identity.transaction.side_effect = transaction + identity.limits.user.side_effect = limit + identity.sessions.authenticate.side_effect = authenticate + app = ItemQueryApplication(identity) + app.items = Mock() + return app, identity, connection + + +@pytest.mark.parametrize("operation", ["list", "get"]) +def test_read_uses_reauthenticated_actor_and_transaction_local_projection(operation): + app, identity, connection = application() + getattr(app.items, operation).return_value = {"item": "projection"} + result = app.read( + operation, + "token", + "request", + resource_id="resource", + laboratory_id="lab", + page=3, + page_size=5, + ) + assert result == {"data": {"item": "projection"}, "request_id": "request"} + identity._preflight.assert_called_once_with("token") + identity.sessions.authenticate.assert_called_once_with(connection, "token") + arguments = (connection, "reloaded-actor", "resource") + if operation == "list": + arguments += (3, 5, "lab") + getattr(app.items, operation).assert_called_once_with(*arguments) + + +def test_deadlock_reauthenticates_and_retries_entire_read_without_recharging_limit(): + app, identity, _ = application() + app.items.get.side_effect = [deadlock(), {"id": "item"}] + assert app.read("get", "token", "request", resource_id="item")["data"] == {"id": "item"} + assert identity.transaction.call_count == identity.sessions.authenticate.call_count == 2 + assert app.items.get.call_args_list[0] == app.items.get.call_args_list[1] + identity.limits.user.assert_called_once() + + +def test_deadlock_exhaustion_is_bounded_and_keeps_retry_hint(): + app, identity, _ = application() + app.items.get.side_effect = deadlock() + with pytest.raises(ServiceError) as caught: + app.read("get", "token", "request", resource_id="item") + assert caught.value.status == 409 and caught.value.retry_after == 2 + assert identity.transaction.call_count == 3 + identity.limits.user.assert_called_once() + + +def test_rate_limit_rejection_never_starts_read_transaction(): + app, identity, _ = application() + identity.limits.user.side_effect = ServiceError("RATE_LIMITED", 429, "Too many requests") + with pytest.raises(ServiceError) as caught: + app.read("get", "token", "request", resource_id="item") + assert caught.value.status == 429 + identity.transaction.assert_not_called() + identity.sessions.authenticate.assert_not_called() + assert not app.items.mock_calls + + +def test_revoked_session_does_not_reuse_preflight_principal(): + app, identity, _ = application() + identity.sessions.authenticate.side_effect = ServiceError("UNAUTHENTICATED", 401, "Revoked") + with pytest.raises(ServiceError) as caught: + app.read("list", "token", "request", resource_id="item") + assert caught.value.status == 401 + assert not app.items.mock_calls + + +def test_unknown_internal_operation_is_not_silently_mapped_to_get(): + app, _, _ = application() + with pytest.raises(ValueError, match="Unknown inspection item query"): + app.read("typo", "token", "request", resource_id="item") + assert not app.items.mock_calls diff --git a/tests/business/test_item_query_boundary.py b/tests/business/test_item_query_boundary.py new file mode 100644 index 0000000..a2ec2df --- /dev/null +++ b/tests/business/test_item_query_boundary.py @@ -0,0 +1,120 @@ +"""Inspection item HTTP contracts without database dependencies.""" + +from unittest.mock import Mock +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError + +from apps.api.app.main import create_app +from packages.domain.security import ServiceError +from packages.persistence.security import SESSION_COOKIE +from tests.business.test_identity_boundary import ORIGIN +from tests.helpers import configured + +ID = str(uuid4()) +LIST = f"/api/v1/inspections/{ID}/items" +DETAIL = f"/api/v1/inspection-items/{ID}" + + +def client(service): + with configured(): + app = create_app(identity=Mock(), public_origin=ORIGIN) + app.state.item_queries = service + return TestClient(app, base_url=ORIGIN) + + +@pytest.mark.parametrize( + "query", + [ + "page=0", + "page=-1", + "page=1.5", + "page=true", + "page=", + "page_size=0", + "page_size=101", + "page_size=-1", + "page=1&page=2", + "page_size=1&page_size=2", + "laboratory_id=bad", + "laboratory_id=", + f"laboratory_id={ID}&laboratory_id={ID}", + "tenant_id=x", + "status=draft", + ], +) +def test_list_rejects_invalid_ambiguous_or_unknown_queries(query): + service = Mock() + with client(service) as http: + result = http.get(LIST + "?" + query) + assert result.status_code == 422 + assert result.headers["cache-control"] == "no-store" + service.read.assert_not_called() + + +@pytest.mark.parametrize("query", ["page=1", "page_size=20", f"laboratory_id={ID}", "x=1"]) +def test_detail_rejects_all_query_parameters(query): + service = Mock() + with client(service) as http: + assert http.get(DETAIL + "?" + query).status_code == 422 + service.read.assert_not_called() + + +def test_query_dispatch_defaults_boundaries_and_uuid_normalization(): + service = Mock() + service.read.return_value = {"data": {}, "request_id": ID} + with client(service) as http: + http.cookies.set(SESSION_COOKIE, "token") + assert http.get(LIST).status_code == 200 + assert service.read.call_args.kwargs == dict( + resource_id=ID, page=1, page_size=20, laboratory_id=None + ) + result = http.get( + LIST.replace(ID, ID.upper()) + f"?page=2&page_size=100&laboratory_id={ID.upper()}" + ) + assert result.status_code == 200 + assert service.read.call_args.args[0] == "list" + assert service.read.call_args.args[1] == "token" + assert service.read.call_args.kwargs == dict( + resource_id=ID, page=2, page_size=100, laboratory_id=ID + ) + assert http.get(DETAIL.replace(ID, ID.upper())).status_code == 200 + assert service.read.call_args.args[0] == "get" + assert service.read.call_args.kwargs == {"resource_id": ID} + assert result.headers["cache-control"] == "no-store" + operations = { + route.operation_id for route in http.app.routes if hasattr(route, "operation_id") + } + assert {"listInspectionItems", "getInspectionItem"} <= operations + + +@pytest.mark.parametrize("path", [LIST, DETAIL]) +def test_missing_configuration_and_invalid_resource_ids(path): + with client(None) as http: + result = http.get(path) + assert result.status_code == 503 + assert result.json()["error"]["code"] == "DEPENDENCY_UNAVAILABLE" + assert result.headers["cache-control"] == "no-store" + assert http.get(path.replace(ID, "bad")).status_code == 422 + + +@pytest.mark.parametrize("path", [LIST, DETAIL]) +@pytest.mark.parametrize( + "error,status", + [ + (ServiceError("NOT_FOUND", 404, "Not found"), 404), + (RuntimeError("internal database detail"), 500), + (SQLAlchemyError("internal database detail"), 503), + ], +) +def test_service_errors_use_shared_sanitized_envelope(path, error, status): + service = Mock() + service.read.side_effect = error + with client(service) as http: + response = http.get(path) + assert response.status_code == status + assert "internal database detail" not in response.text + assert response.json()["request_id"] + assert response.headers["cache-control"] == "no-store" diff --git a/tests/business/test_role_boundary.py b/tests/business/test_role_boundary.py new file mode 100644 index 0000000..6b68519 --- /dev/null +++ b/tests/business/test_role_boundary.py @@ -0,0 +1,106 @@ +"""Role HTTP request shape, strict validation and route contract checks.""" + +from unittest.mock import Mock +from uuid import uuid4 + +import pytest + +from tests.business.test_identity_boundary import ORIGIN, client + +BODY = { + "expected_version": 1, + "role": "inspector", + "scope_kind": "laboratory", + "laboratory_id": str(uuid4()), +} +HEADERS = {"Origin": ORIGIN, "Idempotency-Key": "test-role-123", "X-CSRF-Token": "s" * 43} + + +@pytest.mark.parametrize( + "suffix,operation", [("roles", "grant_role"), ("revoke-role", "revoke_role")] +) +def test_role_commands_pass_exact_contract_to_application(suffix, operation): + service = Mock() + service.write.return_value = (200, {"data": {}, "request_id": str(uuid4())}) + user = str(uuid4()) + with client(service) as http: + result = http.post(f"/api/v1/users/{user}/{suffix}", json=BODY, headers=HEADERS) + assert result.status_code == 200 + assert service.write.call_args.args[0] == operation + assert service.write.call_args.args[4] == BODY + assert service.write.call_args.kwargs == {"user_id": user} + + +@pytest.mark.parametrize( + "patch", + [ + {"role": "superadmin"}, + {"scope_kind": "global"}, + {"expected_version": True}, + {"expected_version": "1"}, + {"expected_version": 0}, + {"expected_version": 2147483648}, + {"laboratory_id": "bad"}, + {"laboratory_id": 4}, + {"tenant_id": str(uuid4())}, + {"reason": "not in role contract"}, + ], +) +def test_role_invalid_input_is_rejected_without_application_call(patch): + service = Mock() + with client(service) as http: + for suffix in ("roles", "revoke-role"): + result = http.post( + f"/api/v1/users/{uuid4()}/{suffix}", json={**BODY, **patch}, headers=HEADERS + ) + assert result.status_code == 422, result.text + service.write.assert_not_called() + + +def test_role_nullable_lab_is_required_and_query_is_validated(): + service = Mock() + service.read.return_value = {"data": {}, "request_id": str(uuid4())} + user, lab = str(uuid4()), str(uuid4()) + with client(service) as http: + missing = {k: v for k, v in BODY.items() if k != "laboratory_id"} + assert ( + http.post(f"/api/v1/users/{user}/roles", json=missing, headers=HEADERS).status_code + == 422 + ) + for query in ( + "laboratory_id=bad", + "page=0", + "page_size=101", + "page=1&page=2", + f"laboratory_id={lab}&laboratory_id={lab}", + "role=viewer", + ): + assert http.get(f"/api/v1/users/{user}/roles?{query}").status_code == 422 + assert ( + http.get( + f"/api/v1/users/{user}/roles?page=2&page_size=3&laboratory_id={lab}" + ).status_code + == 200 + ) + assert service.read.call_args.kwargs == { + "user_id": user, + "page": 2, + "page_size": 3, + "laboratory_id": lab, + } + + +def test_roles_keep_origin_json_and_unique_write_header_requirements(): + service = Mock() + with client(service) as http: + for suffix in ("roles", "revoke-role"): + path = f"/api/v1/users/{uuid4()}/{suffix}" + assert ( + http.post( + path, json=BODY, headers={**HEADERS, "Origin": "https://evil.test"} + ).status_code + == 403 + ) + assert http.post(path, content="text", headers=HEADERS).status_code == 415 + assert http.post(path, json=BODY, headers={"Origin": ORIGIN}).status_code == 422 + service.write.assert_not_called() diff --git a/tests/business/test_s3_storage.py b/tests/business/test_s3_storage.py new file mode 100644 index 0000000..671306f --- /dev/null +++ b/tests/business/test_s3_storage.py @@ -0,0 +1,300 @@ +"""Real SDK signing and stubbed S3 responses; NOT live MinIO/IAM/TLS acceptance.""" + +import hashlib +from dataclasses import replace +from datetime import datetime, timedelta +from io import BytesIO +from unittest.mock import patch +from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit +from uuid import uuid4 + +import pytest +from botocore.exceptions import EndpointConnectionError +from botocore.response import StreamingBody +from botocore.stub import Stubber + +from packages.domain.security import ServiceError +from packages.domain.uploads import MAX_BYTES, staging_key +from packages.storage.s3 import BUCKET, ObjectVersion, S3Settings, S3Storage + +TENANT, UPLOAD = str(uuid4()), str(uuid4()) +KEY = staging_key(TENANT, UPLOAD) + + +def settings(**values): + return S3Settings( + **{ + "internal_endpoint": "http://127.0.0.1:9000", + "public_endpoint": "https://labsafe.test", + "public_origin": "https://labsafe.test", + "access_key": "synthetic-access", + "secret_key": "synthetic-secret-not-deployment", + **values, + } + ) + + +@pytest.fixture +def storage(): + result = S3Storage(settings()) + yield result + result.close() + + +@pytest.mark.parametrize( + "origin", + [ + "http://labsafe.test", + "https://labsafe.test/", + "https://labsafe.test:8443", + "https://user:secret@labsafe.test", + "https://labsafe.test?x=1", + "https://labsafe.test#x", + "https://labsafe.test?", + "https://labsafe.test#", + "https://labsafe.test/path", + "https://lab safe.test", + ], +) +def test_invalid_public_origins_never_fall_back_to_internal(origin): + with pytest.raises(ValueError): + settings(public_endpoint=origin, public_origin=origin) + + +@pytest.mark.parametrize( + "values", + [ + {"public_origin": "https://other.test"}, + {"bucket": "public"}, + {"region": "us-west-2"}, + {"access_key": ""}, + {"secret_key": "has space"}, + {"internal_endpoint": "http://user:secret@minio:9000"}, + {"internal_endpoint": "file:///x"}, + ], +) +def test_configuration_rejects_wrong_scope_or_credentials(values): + with pytest.raises(ValueError): + settings(**values) + assert "synthetic-secret" not in repr(settings()) + + +def test_explicit_credentials_ignore_default_profiles_and_metadata(monkeypatch, tmp_path): + config = tmp_path / "aws-config" + config.write_text("not valid configuration", encoding="utf-8") + monkeypatch.setenv("AWS_CONFIG_FILE", str(config)) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(config)) + monkeypatch.setenv("AWS_PROFILE", "untrusted-profile") + monkeypatch.setenv("AWS_EC2_METADATA_SERVICE_ENDPOINT", "http://127.0.0.1:1") + with patch("botocore.httpsession.URLLib3Session.send", side_effect=AssertionError("network")): + instance = S3Storage(settings()) + try: + assert instance.presign_put(TENANT, UPLOAD, "image/png").url.startswith( + "https://labsafe.test/" + ) + finally: + instance.close() + + +def test_sigv4_content_type_path_host_ttl_and_no_network(storage): + instant = datetime(2026, 10, 1, 2, 3, 4) + with ( + patch("botocore.auth.get_current_datetime", return_value=instant), + patch("botocore.httpsession.URLLib3Session.send", side_effect=AssertionError("network")), + ): + grant = storage.presign_put(TENANT, UPLOAD, "image/png") + other_mime = storage.presign_put(TENANT, UPLOAD, "image/jpeg") + other_key = storage.presign_put(TENANT, str(uuid4()), "image/png") + url, query = urlsplit(grant.url), parse_qs(urlsplit(grant.url).query) + assert url.netloc == "labsafe.test" and url.path == f"/{BUCKET}/{KEY}" + assert query["X-Amz-SignedHeaders"] == ["content-type;host"] + assert query["X-Amz-Expires"] == ["600"] + assert query["X-Amz-Credential"][0].endswith("/20261001/us-east-1/s3/aws4_request") + assert grant.expires_at == instant + timedelta(seconds=600) + assert "Signature" not in repr(grant) + for alternative in (other_mime, other_key): + assert ( + parse_qs(urlsplit(alternative.url).query)["X-Amz-Signature"] != query["X-Amz-Signature"] + ) + + +@pytest.mark.parametrize( + "change,code", + [ + ({"VersionId": "null"}, "DEPENDENCY_UNAVAILABLE"), + ({"VersionId": ""}, "DEPENDENCY_UNAVAILABLE"), + ({"VersionId": "x" * 201}, "DEPENDENCY_UNAVAILABLE"), + ({"ContentLength": 0}, "IMAGE_INVALID"), + ({"ContentLength": MAX_BYTES + 1}, "IMAGE_TOO_LARGE"), + ({"ContentType": "image/svg+xml"}, "UNSUPPORTED_MEDIA_TYPE"), + ({"DeleteMarker": True}, "DEPENDENCY_UNAVAILABLE"), + ], +) +def test_head_requires_versioned_bounded_images(storage, change, code): + with Stubber(storage.internal) as stub: + stub.add_response( + "head_object", + {"VersionId": "v1", "ContentLength": 4, "ContentType": "image/png", **change}, + {"Bucket": BUCKET, "Key": KEY}, + ) + with pytest.raises(ServiceError) as caught: + storage.inspect_staging(TENANT, UPLOAD) + assert caught.value.code == code + + +def test_head_pins_version_but_does_not_trust_declared_sha(storage): + with Stubber(storage.internal) as stub: + stub.add_response( + "head_object", + { + "VersionId": "v1", + "ContentLength": 4, + "ContentType": "image/png", + "Metadata": {"sha256": "a" * 64}, + "ETag": '"not-a-sha256"', + }, + {"Bucket": BUCKET, "Key": KEY}, + ) + assert storage.inspect_staging(TENANT, UPLOAD) == ObjectVersion(KEY, "v1", 4, "image/png") + + +@pytest.mark.parametrize( + "provider,expected", + [ + ("NoSuchKey", "OBJECT_NOT_FOUND"), + ("NoSuchVersion", "OBJECT_NOT_FOUND"), + ("404", "OBJECT_NOT_FOUND"), + ("AccessDenied", "DEPENDENCY_UNAVAILABLE"), + ("SlowDown", "DEPENDENCY_UNAVAILABLE"), + ], +) +def test_provider_errors_are_sanitized(storage, provider, expected): + with Stubber(storage.internal) as stub: + stub.add_client_error( + "head_object", service_error_code=provider, service_message="secret internal endpoint" + ) + with pytest.raises(ServiceError) as caught: + storage.inspect_staging(TENANT, UPLOAD) + assert caught.value.code == expected and "secret" not in str(caught.value) + + +@pytest.mark.parametrize( + "kind", ["valid", "hash", "version", "mime", "size", "truncated", "overflow"] +) +def test_stream_uses_exact_version_recomputes_hash_and_closes(storage, kind): + payload = b"synthetic-image-bytes" # Hash fixture, deliberately NOT a decodable image. + expected = hashlib.sha256(payload).hexdigest() + raw = BytesIO( + payload[:-1] if kind == "truncated" else payload + b"x" if kind == "overflow" else payload + ) + pinned = ObjectVersion(KEY, "fixed-version", len(payload), "image/png") + result = { + "Body": raw, + "VersionId": "changed" if kind == "version" else "fixed-version", + "ContentLength": len(payload) + (1 if kind == "size" else 0), + "ContentType": "image/jpeg" if kind == "mime" else "image/png", + "Metadata": {"sha256": expected}, + } + with Stubber(storage.internal) as stub: + stub.add_response( + "get_object", result, {"Bucket": BUCKET, "Key": KEY, "VersionId": "fixed-version"} + ) + if kind == "valid": + assert storage.verify_staging_hash(TENANT, UPLOAD, pinned, expected) == expected + else: + with pytest.raises(ServiceError) as caught: + storage.verify_staging_hash( + TENANT, UPLOAD, pinned, "0" * 64 if kind == "hash" else expected + ) + assert caught.value.code == ("HASH_MISMATCH" if kind == "hash" else "IMAGE_INVALID") + assert raw.closed + + +def test_missing_fixed_version_does_not_fall_back_to_latest(storage): + with Stubber(storage.internal) as stub: + stub.add_client_error( + "get_object", + "NoSuchVersion", + expected_params={"Bucket": BUCKET, "Key": KEY, "VersionId": "v1"}, + ) + with pytest.raises(ServiceError) as caught: + storage.verify_staging_hash( + TENANT, UPLOAD, ObjectVersion(KEY, "v1", 4, "image/png"), "a" * 64 + ) + assert caught.value.code == "OBJECT_NOT_FOUND" + stub.assert_no_pending_responses() + + +def test_truncated_sdk_stream_and_network_failure_are_not_success(storage): + raw = BytesIO(b"x") + with Stubber(storage.internal) as stub: + stub.add_response( + "get_object", + { + "Body": StreamingBody(raw, 4), + "VersionId": "v1", + "ContentLength": 4, + "ContentType": "image/png", + }, + ) + with pytest.raises(ServiceError) as caught: + storage.verify_staging_hash( + TENANT, UPLOAD, ObjectVersion(KEY, "v1", 4, "image/png"), "a" * 64 + ) + assert caught.value.status == 503 + assert raw.closed + with patch.object( + storage.internal, "head_object", side_effect=EndpointConnectionError(endpoint_url="secret") + ): + with pytest.raises(ServiceError) as caught: + storage.inspect_staging(TENANT, UPLOAD) + assert caught.value.status == 503 and "secret" not in str(caught.value) + + +def test_foreign_pinned_key_rejected_without_s3_call(storage): + pinned = ObjectVersion(KEY, "v1", 4, "image/png") + with patch.object(storage.internal, "get_object") as get: + with pytest.raises(ValueError): + storage.verify_staging_hash(TENANT, UPLOAD, replace(pinned, key="other/key"), "a" * 64) + get.assert_not_called() + + +@pytest.mark.parametrize( + "field,value", + [ + ("X-Amz-Signature", ""), + ("X-Amz-Credential", "wrong"), + ("X-Amz-Expires", "3600"), + ("X-Amz-SignedHeaders", "host"), + ], +) +def test_presigner_drift_is_rejected_instead_of_returning_an_unusable_grant(storage, field, value): + original = storage.presign_put(TENANT, UPLOAD, "image/png") + parts = urlsplit(original.url) + query = parse_qs(parts.query) + query[field] = [value] + modified = urlunsplit(parts._replace(query=urlencode(query, doseq=True))) + with patch.object(storage.signing, "generate_presigned_url", return_value=modified): + with pytest.raises(ServiceError) as caught: + storage.presign_put(TENANT, UPLOAD, "image/png") + assert caught.value.status == 503 + + +def test_credential_files_are_explicit_and_errors_do_not_echo_contents(monkeypatch, tmp_path): + access, secret = tmp_path / "access", tmp_path / "secret" + access.write_text("synthetic-access\n", encoding="ascii") + secret.write_text("synthetic-secret\n", encoding="ascii") + for key, value in { + "S3_ENDPOINT": "http://127.0.0.1:9000", + "S3_PUBLIC_ENDPOINT": "https://labsafe.test", + "S3_ACCESS_KEY_FILE": str(access), + "S3_SECRET_KEY_FILE": str(secret), + "S3_BUCKET": BUCKET, + "S3_REGION": "us-east-1", + }.items(): + monkeypatch.setenv(key, value) + assert S3Settings.from_environment("https://labsafe.test").secret_key == "synthetic-secret" + secret.write_text("private invalid value", encoding="ascii") + with pytest.raises(ValueError) as caught: + S3Settings.from_environment("https://labsafe.test") + assert "private" not in str(caught.value) and str(secret) not in str(caught.value) diff --git a/tests/business/test_upload_boundary.py b/tests/business/test_upload_boundary.py new file mode 100644 index 0000000..5c3f0cb --- /dev/null +++ b/tests/business/test_upload_boundary.py @@ -0,0 +1,134 @@ +"""Grant route strictness and capability configuration; no real object server.""" + +from unittest.mock import Mock, patch + +import pytest +from fastapi.testclient import TestClient + +from apps.api.app.main import create_app +from packages.domain.uploads import MAX_BYTES, validate_upload_request +from tests.business.test_foundation_boundary import HEADERS, ID, ORIGIN +from tests.domain.test_uploads import upload_body +from tests.helpers import configured + + +def client(service): + with configured(): + app = create_app(identity=Mock(), public_origin=ORIGIN) + app.state.uploads = service + return TestClient(app, base_url=ORIGIN) + + +def test_create_upload_dispatches_only_the_contract_and_uses_original_response(): + service = Mock() + service.create.return_value = (201, {"data": {}, "request_id": ID}) + with client(service) as http: + response = http.post("/api/v1/uploads", json=upload_body(ID.upper()), headers=HEADERS) + assert response.status_code == 201 and response.headers["cache-control"] == "no-store" + assert service.create.call_args.args[3] == upload_body(ID) + assert http.post("/api/v1/uploads/complete", json={}, headers=HEADERS).status_code == 422 + assert service.create.call_count == 1 + + +def test_capture_time_has_no_undocumented_fractional_precision_limit(): + body = {**upload_body(), "captured_at": "2026-10-01T01:02:03." + "1234567890" * 10 + "Z"} + assert validate_upload_request(body).microsecond == 123000 + service = Mock() + service.create.return_value = (201, {"data": {}, "request_id": ID}) + with client(service) as http: + response = http.post("/api/v1/uploads", json=body, headers=HEADERS) + assert response.status_code == 201 + assert service.create.call_args.args[3] == body + + +@pytest.mark.parametrize( + "patch", + [ + {"size_bytes": True}, + {"size_bytes": "1"}, + {"size_bytes": 0}, + {"size_bytes": MAX_BYTES + 1}, + {"owner_type": "anything"}, + {"owner_id": "bad"}, + {"filename": ""}, + {"mime_type": "image/gif"}, + {"sha256": "A" * 64}, + {"tenant_id": ID}, + {"object_key": "bad"}, + {"put_url": "https://evil.test"}, + {"captured_at": 123}, + ], +) +def test_upload_rejects_unknown_and_non_strict_fields(patch): + service = Mock() + with client(service) as http: + response = http.post("/api/v1/uploads", json={**upload_body(), **patch}, headers=HEADERS) + assert response.status_code == 422, response.text + service.create.assert_not_called() + + +def test_upload_keeps_same_origin_csrf_idempotency_and_query_guards(): + service = Mock() + with client(service) as http: + assert http.post("/api/v1/uploads", json=upload_body()).status_code == 403 + assert http.post("/api/v1/uploads", content="x", headers=HEADERS).status_code == 415 + assert ( + http.post("/api/v1/uploads", json=upload_body(), headers={"Origin": ORIGIN}).status_code + == 422 + ) + assert ( + http.post("/api/v1/uploads?x=1", json=upload_body(), headers=HEADERS).status_code == 422 + ) + duplicate = [*HEADERS.items(), ("X-CSRF-Token", "duplicate")] + assert ( + http.post("/api/v1/uploads", json=upload_body(), headers=duplicate).status_code == 422 + ) + service.create.assert_not_called() + with client(None) as http: + assert http.post("/api/v1/uploads", json=upload_body(), headers=HEADERS).status_code == 503 + + +def test_upload_feature_configuration_is_fail_closed(monkeypatch): + with configured(): + monkeypatch.setenv("API_UPLOADS_ENABLED", "yes") + with pytest.raises(ValueError, match="API_UPLOADS_ENABLED"): + create_app() + monkeypatch.setenv("API_UPLOADS_ENABLED", "1") + monkeypatch.setenv("API_IDENTITY_ENABLED", "0") + with pytest.raises(ValueError, match="require configured identity"): + create_app() + + +def test_invalid_upload_flag_is_checked_before_allocating_identity(monkeypatch): + with configured(), patch("apps.api.app.main.configured_identity") as configure: + monkeypatch.setenv("API_IDENTITY_ENABLED", "1") + monkeypatch.setenv("API_UPLOADS_ENABLED", "bad") + with pytest.raises(ValueError, match="API_UPLOADS_ENABLED"): + create_app() + configure.assert_not_called() + + +@pytest.mark.parametrize("failure", [False, True]) +def test_owned_storage_and_identity_are_closed_on_shutdown_or_startup_failure(monkeypatch, failure): + identity, storage = Mock(), Mock() + with ( + configured(), + patch("apps.api.app.main.configured_identity", return_value=(identity, ORIGIN)), + patch( + "packages.storage.s3.S3Settings.from_environment", + side_effect=ValueError("bad config") if failure else None, + ), + patch("packages.storage.s3.S3Storage", return_value=storage), + ): + monkeypatch.setenv("API_IDENTITY_ENABLED", "1") + monkeypatch.setenv("API_UPLOADS_ENABLED", "1") + if failure: + with pytest.raises(ValueError, match="bad config"): + create_app() + storage.close.assert_not_called() + else: + with TestClient(create_app()): + pass + storage.close.assert_called_once() + identity.engine.dispose.assert_called_once() + identity.limits.redis.close.assert_called_once() diff --git a/tests/business/test_upload_completion_boundary.py b/tests/business/test_upload_completion_boundary.py new file mode 100644 index 0000000..d133be1 --- /dev/null +++ b/tests/business/test_upload_completion_boundary.py @@ -0,0 +1,92 @@ +"""Public completion/image routing and security; service calls are isolated here.""" + +from unittest.mock import Mock + +import pytest + +from tests.business.test_upload_boundary import HEADERS, ID, client + + +def complete_body(): + return {"upload_id": ID, "sha256": "a" * 64} + + +def test_completion_returns_canonical_original_202_and_normalized_id(): + service = Mock() + service.complete.return_value = (202, {"data": {"status": "validating"}, "request_id": ID}) + with client(service) as http: + response = http.post( + "/api/v1/uploads/complete", + json={**complete_body(), "upload_id": ID.upper()}, + headers=HEADERS, + ) + assert response.status_code == 202 and response.headers["cache-control"] == "no-store" + assert service.complete.call_args.args[3] == complete_body() + assert response.json()["request_id"] == ID + + +@pytest.mark.parametrize( + "values", + [ + {"upload_id": "bad"}, + {"sha256": "A" * 64}, + {"sha256": 1}, + {"owner_id": ID}, + {"tenant_id": ID}, + {"object_key": "untrusted"}, + {"version_id": "untrusted"}, + {"expected_version": 1}, + {"ready": True}, + ], +) +def test_completion_rejects_non_contract_input(values): + service = Mock() + with client(service) as http: + response = http.post( + "/api/v1/uploads/complete", json={**complete_body(), **values}, headers=HEADERS + ) + assert response.status_code == 422 + service.complete.assert_not_called() + + +def test_completion_keeps_origin_json_csrf_key_and_query_guards(): + service = Mock() + with client(service) as http: + path = "/api/v1/uploads/complete" + assert http.post(path, json=complete_body()).status_code == 403 + assert http.post(path, content="text", headers=HEADERS).status_code == 415 + assert ( + http.post(path + "?version=latest", json=complete_body(), headers=HEADERS).status_code + == 422 + ) + for missing in ("X-CSRF-Token", "Idempotency-Key"): + headers = {k: v for k, v in HEADERS.items() if k.lower() != missing.lower()} + assert http.post(path, json=complete_body(), headers=headers).status_code == 422 + service.complete.assert_not_called() + + +def test_get_image_is_read_only_and_needs_no_write_headers(): + service = Mock() + service.get_image.return_value = {"data": {"status": "validating"}, "request_id": ID} + with client(service) as http: + response = http.get(f"/api/v1/images/{ID.upper()}") + assert response.status_code == 200 and response.headers["cache-control"] == "no-store" + assert service.get_image.call_args.args[1] == ID + service.complete.assert_not_called() + + +@pytest.mark.parametrize("suffix", ["bad", ID + "?x=1", ID + "?x=1&x=2"]) +def test_image_rejects_bad_id_or_query(suffix): + service = Mock() + with client(service) as http: + assert http.get("/api/v1/images/" + suffix).status_code == 422 + service.get_image.assert_not_called() + + +def test_completion_and_image_are_disabled_without_configured_upload_service(): + with client(None) as http: + assert ( + http.post("/api/v1/uploads/complete", json=complete_body(), headers=HEADERS).status_code + == 503 + ) + assert http.get(f"/api/v1/images/{ID}").status_code == 503 diff --git a/tests/domain/__init__.py b/tests/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/test_foundations.py b/tests/domain/test_foundations.py new file mode 100644 index 0000000..024f9b8 --- /dev/null +++ b/tests/domain/test_foundations.py @@ -0,0 +1,98 @@ +"""Pure foundation structure and capacity guards.""" + +import pytest + +from packages.domain.foundations import ( + create_inspection, + location_parent, + require_active, + template_items, +) +from packages.domain.security import Principal, ServiceError + +ACTOR = Principal("user", "tenant", "inspector", (("inspector", "laboratory", "lab"),)) + + +@pytest.mark.parametrize("kind", ["room", "area", "shelf", "cabinet", "invalid"]) +@pytest.mark.parametrize("parent", [None, "room", "area", "shelf", "cabinet"]) +def test_location_parent_matrix(kind, parent): + allowed = { + "room": {None}, + "area": {"room"}, + "shelf": {"room", "area"}, + "cabinet": {"room", "area"}, + } + if parent in allowed.get(kind, set()): + location_parent(kind, parent) + else: + with pytest.raises(ServiceError, match="parent") as error: + location_parent(kind, parent) + assert error.value.status == 422 + + +@pytest.mark.parametrize("status", ["active", "archived", "unknown"]) +def test_active_resource_guard(status): + if status == "active": + require_active(status) + else: + with pytest.raises(ServiceError) as error: + require_active(status) + assert error.value.status == 409 + + +@pytest.mark.parametrize("count", [0, 1, 100, 101]) +def test_template_capacity(count): + items = [{"id": i, "code": str(i), "sort_order": i} for i in range(count)] + if 1 <= count <= 100: + template_items(items) + else: + with pytest.raises(ServiceError): + template_items(items) + + +@pytest.mark.parametrize("key", ["id", "code", "sort_order"]) +def test_template_unique_fields(key): + items = [{"id": i, "code": str(i), "sort_order": i} for i in range(2)] + items[1][key] = items[0][key] + with pytest.raises(ServiceError, match="Duplicate"): + template_items(items) + + +@pytest.mark.parametrize( + "count,locations,accepted", + [ + (1, ["a"], True), + (10, list(range(10)), True), + (100, ["a"], True), + (1, list(range(100)), True), + (101, ["a"], False), + (2, list(range(51)), False), + (0, ["a"], False), + (1, [], False), + (1, ["a", "a"], False), + ], +) +def test_inspection_cartesian_capacity(count, locations, accepted): + if accepted: + create_inspection(ACTOR, "lab", "published", count, locations) + else: + with pytest.raises(ServiceError) as error: + create_inspection(ACTOR, "lab", "published", count, locations) + assert error.value.status == 422 + + +@pytest.mark.parametrize("status", ["draft", "retired"]) +def test_inspection_requires_published(status): + with pytest.raises(ServiceError) as error: + create_inspection(ACTOR, "lab", status, 1, ["a"]) + assert error.value.status == 409 + + +def test_inspection_capture_scope(): + with pytest.raises(ServiceError) as error: + create_inspection(ACTOR, "other", "published", 1, ["a"]) + assert error.value.status == 404 + viewer = Principal("u", "t", "v", (("viewer", "laboratory", "lab"),)) + with pytest.raises(ServiceError) as error: + create_inspection(viewer, "lab", "published", 1, ["a"]) + assert error.value.status == 403 diff --git a/tests/domain/test_item_actions.py b/tests/domain/test_item_actions.py new file mode 100644 index 0000000..0b0214a --- /dev/null +++ b/tests/domain/test_item_actions.py @@ -0,0 +1,260 @@ +"""Action availability is a read-only probe of the original command guards.""" + +from dataclasses import replace +from unittest.mock import patch + +import pytest + +from packages.domain import item_actions as actions +from packages.domain.item_actions import ( + COMPLETE, + ITEM_OPERATIONS, + RETRY, + SUBMIT, + ItemActionContext, + allowed_item_actions, +) +from packages.domain.security import ServiceError +from packages.domain.workflow import ( + ItemStatus, + ReviewOutcome, + complete_item, + retry_item, + submit_item, +) +from tests.domain.test_workflow import ( + ADMIN, + DISPATCHER, + INSPECTOR, + OTHER_INSPECTOR, + REMEDIATOR, + evaluation, + failed_run, + finding, + image, + item, +) + + +def context(**overrides): + values = dict( + current_findings=(), + images=(image("inspection_item", "item-1"),), + selections=failed_run().pinned.images, + old_run=failed_run(), + evaluation=evaluation(), + ) + values.update(overrides) + return ItemActionContext(**values) + + +def direct_actions(actor, snapshot, loaded): + """Independent reference: call the commands with their actual required arguments.""" + probes = [ + ( + SUBMIT, + lambda: submit_item( + actor, + snapshot, + expected_version=snapshot.version, + current_findings=loaded.current_findings, + images=loaded.images, + selections=loaded.selections, + ), + ), + ( + RETRY, + lambda: retry_item( + actor, + snapshot, + expected_version=snapshot.version, + current_findings=loaded.current_findings, + images=loaded.images, + old_run=loaded.old_run, + ), + ), + ] + for outcome in ReviewOutcome: + probes.append( + ( + COMPLETE, + lambda outcome=outcome: complete_item( + actor, + snapshot, + expected_version=snapshot.version, + current_findings=loaded.current_findings, + evaluation=loaded.evaluation, + outcome=outcome, + reason="Actual review reason", + ), + ) + ) + result = set() + for name, probe in probes: + try: + probe() + except ServiceError: + pass + else: + result.add(name) + return sorted(result) + + +@pytest.mark.parametrize("actor", [ADMIN, INSPECTOR, OTHER_INSPECTOR, DISPATCHER, REMEDIATOR]) +@pytest.mark.parametrize("status", list(ItemStatus)) +@pytest.mark.parametrize("parent", ["in_progress", "completed", "cancelled"]) +def test_projection_matches_command_guards_for_states_roles_and_parent(actor, status, parent): + snapshot, loaded = item(status=status, inspection_status=parent), context() + before = (replace(snapshot), replace(loaded)) + assert allowed_item_actions(actor, snapshot, loaded, enabled=ITEM_OPERATIONS) == direct_actions( + actor, snapshot, loaded + ) + assert (snapshot, loaded) == before + + +@pytest.mark.parametrize( + "status,expected", + [ + ("uploaded", [SUBMIT]), + ("failed", [RETRY]), + ("needs_review", sorted([SUBMIT, COMPLETE])), + ("draft", []), + ], +) +def test_positive_probes_have_real_expected_actions(status, expected): + assert ( + allowed_item_actions(INSPECTOR, item(status=status), context(), enabled=ITEM_OPERATIONS) + == expected + ) + + +@pytest.mark.parametrize( + "missing,operation,status", + [ + ("current_findings", SUBMIT, "uploaded"), + ("images", SUBMIT, "uploaded"), + ("selections", SUBMIT, "uploaded"), + ("current_findings", RETRY, "failed"), + ("images", RETRY, "failed"), + ("old_run", RETRY, "failed"), + ("current_findings", COMPLETE, "needs_review"), + ("evaluation", COMPLETE, "needs_review"), + ], +) +def test_unloaded_context_is_not_an_empty_or_safe_result(missing, operation, status): + assert ( + allowed_item_actions( + INSPECTOR, + item(status=status), + context(**{missing: None}), + enabled=frozenset({operation}), + ) + == [] + ) + + +@pytest.mark.parametrize( + "flags", + [ + {}, + {"has_unknown": True}, + {"insufficient_facts": True}, + {"has_unknown": True, "insufficient_facts": True}, + {"has_unknown": "false"}, + {"insufficient_facts": None}, + {"id": "old-evaluation"}, + {"run_id": "old-run"}, + {"status": "failed"}, + ], +) +@pytest.mark.parametrize( + "findings", + [ + (), + (finding(),), + (finding(status="confirmed"),), + (finding(status="rejected"),), + (finding(status="cannot_determine"),), + (finding(superseded=True),), + (finding(run_id="old-run"),), + (finding(item_id="other-item"),), + ], +) +def test_complete_probes_preserve_unknown_and_finding_aggregation(flags, findings): + snapshot, loaded = item(), context(evaluation=evaluation(**flags), current_findings=findings) + expected = [name for name in direct_actions(INSPECTOR, snapshot, loaded) if name == COMPLETE] + assert ( + allowed_item_actions(INSPECTOR, snapshot, loaded, enabled=frozenset({COMPLETE})) == expected + ) + + +@pytest.mark.parametrize( + "operation,status,field,value", + [ + (SUBMIT, "uploaded", "images", ()), + (SUBMIT, "uploaded", "images", (image("inspection_item", "other-item"),)), + (SUBMIT, "uploaded", "selections", ()), + (SUBMIT, "uploaded", "current_findings", (finding(status="confirmed"),)), + (RETRY, "failed", "images", ()), + (RETRY, "failed", "old_run", failed_run(configuration_available=False)), + (RETRY, "failed", "old_run", failed_run(id="old-run")), + (RETRY, "failed", "current_findings", (finding(status="confirmed"),)), + ], +) +def test_invalid_command_context_does_not_advertise_action(operation, status, field, value): + assert ( + allowed_item_actions( + INSPECTOR, + item(status=status), + context(**{field: value}), + enabled=frozenset({operation}), + ) + == [] + ) + + +def test_unimplemented_capabilities_never_call_command_guards(): + with ( + patch.object(actions, "submit_item") as submit, + patch.object(actions, "retry_item") as retry, + patch.object(actions, "complete_item") as complete, + ): + assert allowed_item_actions(INSPECTOR, item(), context(), enabled=frozenset()) == [] + for guard in (submit, retry, complete): + guard.assert_not_called() + + +def test_unknown_capability_is_configuration_error_even_with_unloaded_context(): + with pytest.raises(ValueError, match="Unrecognized"): + allowed_item_actions(INSPECTOR, item(), ItemActionContext(), enabled=frozenset({"typo"})) + + +@pytest.mark.parametrize( + "error", + [ + RuntimeError("broken loader"), + ServiceError("DEPENDENCY_UNAVAILABLE", 503, "dependency"), + ServiceError("UNAUTHENTICATED", 401, "auth"), + ], +) +def test_faults_are_not_disguised_as_unavailable_actions(error): + with patch.object(actions, "submit_item", side_effect=error): + with pytest.raises(type(error)) as caught: + allowed_item_actions( + INSPECTOR, item(status="uploaded"), context(), enabled=frozenset({SUBMIT}) + ) + assert caught.value is error + + +@pytest.mark.parametrize( + "actor", + [ + replace(INSPECTOR, tenant_id="other"), + replace(INSPECTOR, roles=()), + replace(INSPECTOR, roles=(("viewer", "laboratory", "other"),)), + ], +) +def test_projection_itself_requires_visibility_even_without_capabilities(actor): + with pytest.raises(ServiceError) as caught: + allowed_item_actions(actor, item(), ItemActionContext(), enabled=frozenset()) + assert caught.value.status == 404 diff --git a/tests/domain/test_upload_completion.py b/tests/domain/test_upload_completion.py new file mode 100644 index 0000000..dc31921 --- /dev/null +++ b/tests/domain/test_upload_completion.py @@ -0,0 +1,122 @@ +"""Completion guards: declared hashes, grant expiry, exact pinned metadata, no I/O.""" + +from dataclasses import replace +from datetime import datetime, timedelta +from uuid import uuid4 + +import pytest + +from packages.domain.security import ServiceError +from packages.domain.uploads import ( + owner_reference, + require_completion, + require_staging_match, + staging_key, + validate_complete_request, +) +from packages.storage.s3 import ObjectVersion + +NOW = datetime(2026, 10, 1, 2, 0) +TENANT, UPLOAD, ITEM = str(uuid4()), str(uuid4()), str(uuid4()) +ROW = { + "id": UPLOAD, + "tenant_id": TENANT, + "inspection_item_id": ITEM, + "remediation_task_id": None, + "object_key": staging_key(TENANT, UPLOAD), + "expected_sha256": "a" * 64, + "status": "granted", + "expires_at": NOW + timedelta(seconds=1), + "size_bytes": 100, + "mime_type": "image/png", +} +PINNED = ObjectVersion(ROW["object_key"], "fixed-version", 100, "image/png") + + +@pytest.mark.parametrize( + "body", + [ + {}, + None, + {"upload_id": UPLOAD}, + {"upload_id": UPLOAD, "sha256": "A" * 64}, + {"upload_id": "bad", "sha256": "a" * 64}, + {"upload_id": UPLOAD.upper(), "sha256": "a" * 64}, + {"upload_id": UPLOAD, "sha256": "a" * 64, "version_id": "untrusted"}, + ], +) +def test_completion_body_is_exact(body): + with pytest.raises(ServiceError) as error: + validate_complete_request(body) + assert error.value.status == 422 + + +def test_granted_matching_metadata_is_only_acceptance_not_image_validation(): + validate_complete_request({"upload_id": UPLOAD, "sha256": "a" * 64}) + require_completion(ROW, "a" * 64, NOW) + require_staging_match(ROW, PINNED) + assert ROW["status"] == "granted" + + +@pytest.mark.parametrize("status", ["validating", "ready", "rejected", "expired"]) +def test_non_granted_without_existing_image_is_not_new_work(status): + with pytest.raises(ServiceError) as error: + require_completion({**ROW, "status": status}, "a" * 64, NOW) + assert error.value.code == "STATE_CONFLICT" + + +@pytest.mark.parametrize("delta", [0, -1, -600]) +def test_expiry_is_exclusive(delta): + with pytest.raises(ServiceError) as error: + require_completion({**ROW, "expires_at": NOW + timedelta(seconds=delta)}, "a" * 64, NOW) + assert error.value.status == 409 + + +@pytest.mark.parametrize("status", ["validating", "ready", "rejected"]) +def test_existing_image_does_not_recheck_original_grant_expiry(status): + row = {**ROW, "status": status, "expires_at": NOW - timedelta(hours=1)} + require_completion(row, "a" * 64, NOW, already_completed=True) + with pytest.raises(ServiceError) as error: + require_completion(row, "b" * 64, NOW, already_completed=True) + assert error.value.code == "HASH_MISMATCH" + + +@pytest.mark.parametrize("status", ["granted", "expired"]) +def test_existing_image_requires_consistent_persisted_state(status): + with pytest.raises(RuntimeError): + require_completion({**ROW, "status": status}, "a" * 64, NOW, already_completed=True) + + +@pytest.mark.parametrize( + "values", + [ + {"version_id": ""}, + {"version_id": "null"}, + {"version_id": "x" * 201}, + {"version_id": "x\n"}, + {"version_id": None}, + {"key": "other"}, + {"size_bytes": 99}, + {"size_bytes": 101}, + {"size_bytes": True}, + {"mime_type": "image/jpeg"}, + {"mime_type": "image/gif"}, + ], +) +def test_head_metadata_must_match_exact_grant(values): + with pytest.raises(ServiceError) as error: + require_staging_match(ROW, replace(PINNED, **values)) + assert error.value.code == "IMAGE_INVALID" + + +def test_invalid_persisted_reference_is_not_a_client_controlled_object(): + with pytest.raises(RuntimeError): + require_completion({**ROW, "object_key": "other"}, "a" * 64, NOW) + assert owner_reference(ROW) == ("inspection_item", ITEM) + assert owner_reference({**ROW, "inspection_item_id": None, "remediation_task_id": ITEM}) == ( + "remediation_task", + ITEM, + ) + for row in ({**ROW, "inspection_item_id": None}, {**ROW, "remediation_task_id": ITEM}): + with pytest.raises(RuntimeError): + owner_reference(row) diff --git a/tests/domain/test_uploads.py b/tests/domain/test_uploads.py new file mode 100644 index 0000000..cfcaf30 --- /dev/null +++ b/tests/domain/test_uploads.py @@ -0,0 +1,148 @@ +"""Upload ownership/state, metadata and quota policies without I/O.""" + +from dataclasses import replace +from datetime import datetime, timedelta +from uuid import uuid4 + +import pytest + +from packages.domain.security import ServiceError +from packages.domain.uploads import ( + CAPTURE_STATES, + EVIDENCE_STATES, + MAX_BYTES, + authorize_upload, + capture_time, + require_grant_capacity, + staging_key, + validate_upload_request, +) +from packages.domain.workflow import ItemStatus, TaskStatus +from tests.domain.test_workflow import ADMIN, INSPECTOR, OTHER_INSPECTOR, REMEDIATOR, item, task + + +def upload_body(owner_id=None, **overrides): + return { + "owner_type": "inspection_item", + "owner_id": owner_id or str(uuid4()), + "filename": "测试图片.png", + "mime_type": "image/png", + "size_bytes": 100, + "sha256": "a" * 64, + "captured_at": "2026-10-01T10:00:00.123+08:00", + **overrides, + } + + +@pytest.mark.parametrize("status", list(ItemStatus)) +@pytest.mark.parametrize("parent", ["draft", "in_progress", "completed", "cancelled"]) +@pytest.mark.parametrize("actor", [INSPECTOR, OTHER_INSPECTOR, ADMIN, REMEDIATOR]) +def test_capture_state_and_creator_matrix(status, parent, actor): + snapshot = item(status=status, inspection_status=parent) + if ( + actor in (INSPECTOR, ADMIN) + and status in CAPTURE_STATES + and parent in {"draft", "in_progress"} + ): + assert authorize_upload(actor, snapshot) is None + else: + with pytest.raises(ServiceError) as caught: + authorize_upload(actor, snapshot) + assert caught.value.status == (403 if actor in (OTHER_INSPECTOR, REMEDIATOR) else 409) + + +@pytest.mark.parametrize("status", list(TaskStatus)) +@pytest.mark.parametrize( + "actor", [REMEDIATOR, ADMIN, INSPECTOR, replace(REMEDIATOR, user_id="other")] +) +def test_evidence_task_state_and_assignee_matrix(status, actor): + if actor == REMEDIATOR and status in EVIDENCE_STATES: + assert authorize_upload(actor, task(status=status)) is None + else: + with pytest.raises(ServiceError) as caught: + authorize_upload(actor, task(status=status)) + assert caught.value.status == (409 if actor == REMEDIATOR else 403) + + +@pytest.mark.parametrize( + "owner", + [ + item(tenant_id="foreign"), + item(laboratory_id="foreign"), + task(tenant_id="foreign"), + task(laboratory_id="foreign"), + ], +) +def test_cross_scope_owner_is_hidden(owner): + with pytest.raises(ServiceError) as caught: + authorize_upload(REMEDIATOR, owner) + assert caught.value.status == 404 + + +def test_replay_rechecks_authority_without_rerunning_state_or_creating_new_intent(): + assert authorize_upload(INSPECTOR, item(status="completed"), check_state=False) is None + with pytest.raises(ServiceError) as caught: + authorize_upload(OTHER_INSPECTOR, item(status="completed"), check_state=False) + assert caught.value.status == 403 + + +@pytest.mark.parametrize( + "field,value,code", + [ + ("owner_type", "file", "VALIDATION_ERROR"), + ("owner_id", "../x", "VALIDATION_ERROR"), + ("filename", "", "VALIDATION_ERROR"), + ("filename", "a" * 256, "VALIDATION_ERROR"), + ("filename", "../x.png", "VALIDATION_ERROR"), + ("filename", "C:\\x.png", "VALIDATION_ERROR"), + ("filename", "x\n.png", "VALIDATION_ERROR"), + ("filename", "..", "VALIDATION_ERROR"), + ("size_bytes", True, "VALIDATION_ERROR"), + ("size_bytes", "1", "VALIDATION_ERROR"), + ("size_bytes", 0, "VALIDATION_ERROR"), + ("size_bytes", MAX_BYTES + 1, "IMAGE_TOO_LARGE"), + ("mime_type", "image/svg+xml", "UNSUPPORTED_MEDIA_TYPE"), + ("sha256", "A" * 64, "VALIDATION_ERROR"), + ("sha256", "a" * 64 + "\n", "VALIDATION_ERROR"), + ("captured_at", "2026-10-01", "VALIDATION_ERROR"), + ("captured_at", "2026-10-01T10:00:00", "VALIDATION_ERROR"), + ("captured_at", "0999-01-01T00:00:00Z", "VALIDATION_ERROR"), + ("captured_at", "2026-02-30T00:00:00Z", "VALIDATION_ERROR"), + ("captured_at", "2026-10-01T00:00:00+00:60", "VALIDATION_ERROR"), + ("captured_at", "2026-10-01T00:00:00+24:00", "VALIDATION_ERROR"), + ("owner_type", [], "VALIDATION_ERROR"), + ("mime_type", [], "UNSUPPORTED_MEDIA_TYPE"), + ], +) +def test_invalid_upload_metadata(field, value, code): + with pytest.raises(ServiceError) as caught: + validate_upload_request(upload_body(**{field: value})) + assert caught.value.code == code + + +def test_valid_sizes_media_and_utc_milliseconds_are_preserved(): + for size in (1, MAX_BYTES): + for mime in ("image/jpeg", "image/png", "image/webp"): + assert validate_upload_request( + upload_body(size_bytes=size, mime_type=mime) + ) == datetime(2026, 10, 1, 2, 0, 0, 123000) + assert capture_time("2026-10-01t02:00:00.123999z") == datetime(2026, 10, 1, 2, 0, 0, 123000) + for body in ([], {**upload_body(), "tenant_id": str(uuid4())}, {}): + with pytest.raises(ServiceError): + validate_upload_request(body) + + +def test_quota_expiration_boundary_and_key_path_are_server_owned(): + now = datetime(2026, 10, 1) + require_grant_capacity([now] * 10, now) + require_grant_capacity([now + timedelta(seconds=20)] * 9, now) + with pytest.raises(ServiceError) as caught: + require_grant_capacity( + [now + timedelta(seconds=20)] * 9 + [now + timedelta(milliseconds=1500)], now + ) + assert caught.value.status == 429 and caught.value.retry_after == 2 + tenant, upload = str(uuid4()), str(uuid4()) + assert staging_key(tenant, upload) == f"staging/{tenant}/{upload}" + for value in ("../x", tenant.upper(), tenant.replace("-", "")): + with pytest.raises(ServiceError): + staging_key(value, upload) diff --git a/tests/domain/test_workflow.py b/tests/domain/test_workflow.py new file mode 100644 index 0000000..5ed2ca9 --- /dev/null +++ b/tests/domain/test_workflow.py @@ -0,0 +1,536 @@ +from datetime import date, datetime, timedelta, timezone + +import pytest + +from packages.domain.security import Principal, ServiceError +from packages.domain.workflow import ( + Evaluation, + Evidence, + FailedRun, + Finding, + FindingStatus, + Image, + ImageSelection, + Item, + ItemStatus, + PinnedInputs, + RemediationDecision, + ReviewOutcome, + RunStatus, + Task, + TaskStatus, + accept_task, + cannot_determine_finding, + cannot_remediate, + complete_item, + confirm_finding, + dispatch_finding, + reassign_task, + recheck_task, + reject_finding, + reject_task, + retry_item, + submit_evidence, + submit_item, +) + +TENANT = "tenant-1" +LAB = "lab-1" +INSPECTOR = Principal( + user_id="inspector-1", + tenant_id=TENANT, + username="inspector", + roles=(("inspector", "laboratory", LAB),), +) +ADMIN = Principal( + user_id="admin-1", + tenant_id=TENANT, + username="admin", + roles=(("safety_admin", "tenant", None),), +) +DISPATCHER = Principal( + user_id="manager-1", + tenant_id=TENANT, + username="manager", + roles=(("lab_manager", "laboratory", LAB),), +) +REMEDIATOR = Principal( + user_id="remediator-1", + tenant_id=TENANT, + username="remediator", + roles=(("remediator", "laboratory", LAB),), +) +OTHER_REMEDIATOR = Principal( + user_id="remediator-2", + tenant_id=TENANT, + username="remediator2", + roles=(("remediator", "laboratory", LAB),), +) +OTHER_INSPECTOR = Principal( + user_id="inspector-2", + tenant_id=TENANT, + username="inspector2", + roles=(("inspector", "laboratory", LAB),), +) + + +def resource(resource_type, **kwargs): + common = dict(id="item-1", tenant_id=TENANT, laboratory_id=LAB, version=1) + common.update(kwargs) + return resource_type(**common) + + +def item(**kwargs): + values = dict( + status=ItemStatus.NEEDS_REVIEW, + inspection_status="in_progress", + creator_id=INSPECTOR.user_id, + location_id="loc-1", + current_run_id="run-1", + current_evaluation_id="eval-1", + ) + values.update(kwargs) + return resource(Item, **values) + + +def finding(**kwargs): + values = dict( + status=FindingStatus.NEEDS_REVIEW, + item_id="item-1", + run_id="run-1", + evaluation_id="eval-1", + superseded=False, + evidence_ready=True, + ) + values.update(kwargs) + return resource(Finding, **values) + + +def task(**kwargs): + values = dict( + id="task-1", + status=TaskStatus.PENDING_DISPATCH, + finding_id="finding-1", + assignee_id=REMEDIATOR.user_id, + latest_evidence_id=None, + ) + values.update(kwargs) + return resource(Task, **values) + + +def image(owner_type, owner_id, *, image_id="image-1", location_id="loc-1", status="ready"): + return Image( + id=image_id, + tenant_id=TENANT, + laboratory_id=LAB, + owner_type=owner_type, + owner_id=owner_id, + location_id=location_id, + status=status, + available=True, + ) + + +def evidence(*, evidence_id="evidence-1", task_id="task-1", submitted_by=REMEDIATOR.user_id): + return Evidence( + id=evidence_id, + tenant_id=TENANT, + task_id=task_id, + submitted_by=submitted_by, + ) + + +def evaluation(**kwargs): + values = dict( + id="eval-1", + tenant_id=TENANT, + item_id="item-1", + run_id="run-1", + status="completed", + has_unknown=False, + insufficient_facts=False, + ) + values.update(kwargs) + return Evaluation(**values) + + +def failed_run(**kwargs): + values = dict( + id="run-1", + tenant_id=TENANT, + item_id="item-1", + status=RunStatus.FAILED, + pinned=PinnedInputs( + model_bundle_id="model", + dictionary_version_id="dict", + rule_bundle_id="rules", + pipeline_version="vision-v1", + device_profile="cpu", + reference_date=date(2026, 9, 30), + images=(ImageSelection(image_id="image-1", role="overview", parent_image_id=None),), + ), + configuration_available=True, + ) + values.update(kwargs) + return FailedRun(**values) + + +def call_error(callable_, *args, **kwargs): + with pytest.raises(ServiceError) as exc: + callable_(*args, **kwargs) + return exc.value.code + + +def test_submit_requires_real_server_loaded_scope_and_ready_owned_images(): + assert ( + submit_item( + INSPECTOR, + item(status=ItemStatus.UPLOADED), + expected_version=1, + images=(image("inspection_item", "item-1"),), + selections=(ImageSelection(image_id="image-1", role="overview", parent_image_id=None),), + current_findings=(), + ) + is ItemStatus.QUEUED + ) + assert ( + call_error( + submit_item, + OTHER_INSPECTOR, + item(status=ItemStatus.UPLOADED), + expected_version=1, + images=(image("inspection_item", "item-1"),), + selections=(ImageSelection(image_id="image-1", role="overview", parent_image_id=None),), + current_findings=(), + ) + == "FORBIDDEN" + ) + assert ( + call_error( + submit_item, + INSPECTOR, + item(status=ItemStatus.UPLOADED), + expected_version=1, + images=(image("wrong", "item-1"),), + selections=(ImageSelection(image_id="image-1", role="overview", parent_image_id=None),), + current_findings=(), + ) + == "VALIDATION_ERROR" + ) + assert ( + call_error( + submit_item, + INSPECTOR, + item(status=ItemStatus.QUEUED), + expected_version=1, + images=(image("inspection_item", "item-1"),), + selections=(ImageSelection(image_id="image-1", role="overview", parent_image_id=None),), + current_findings=(), + ) + == "STATE_CONFLICT" + ) + + +def test_retry_preserves_failed_run_images_and_pinned_configuration(): + assert ( + retry_item( + INSPECTOR, + item(status=ItemStatus.FAILED), + expected_version=1, + old_run=failed_run(), + images=(image("inspection_item", "item-1"),), + current_findings=(), + ).item_status + is ItemStatus.QUEUED + ) + assert ( + call_error( + retry_item, + INSPECTOR, + item(status=ItemStatus.FAILED), + expected_version=1, + old_run=failed_run(configuration_available=False), + images=(image("inspection_item", "item-1"),), + current_findings=(), + ) + == "STATE_CONFLICT" + ) + assert ( + call_error( + retry_item, + INSPECTOR, + item(status=ItemStatus.FAILED), + expected_version=1, + old_run=failed_run(), + images=(image("inspection_item", "item-1", image_id="other"),), + current_findings=(), + ) + == "STATE_CONFLICT" + ) + + +def test_complete_uses_server_evaluation_and_three_valued_outcome(): + current = (finding(status=FindingStatus.REJECTED),) + assert ( + complete_item( + INSPECTOR, + item(), + expected_version=1, + evaluation=evaluation(), + current_findings=current, + outcome=ReviewOutcome.NO_ISSUE, + reason="reviewed", + ) + is ItemStatus.COMPLETED + ) + assert ( + call_error( + complete_item, + INSPECTOR, + item(), + expected_version=1, + evaluation=evaluation(has_unknown=True), + current_findings=current, + outcome=ReviewOutcome.NO_ISSUE, + reason="reviewed", + ) + == "STATE_CONFLICT" + ) + assert ( + complete_item( + INSPECTOR, + item(), + expected_version=1, + evaluation=evaluation(has_unknown=True), + current_findings=current, + outcome=ReviewOutcome.CANNOT_DETERMINE, + reason="reviewed", + ) + is ItemStatus.COMPLETED + ) + assert ( + complete_item( + INSPECTOR, + item(), + expected_version=1, + evaluation=evaluation(), + current_findings=(finding(status=FindingStatus.CONFIRMED),), + outcome=ReviewOutcome.ISSUES_CONFIRMED, + reason="risk retained", + ) + is ItemStatus.COMPLETED + ) + assert ( + call_error( + complete_item, + INSPECTOR, + item(), + expected_version=1, + evaluation=evaluation(), + current_findings=(finding(status=FindingStatus.NEEDS_REVIEW),), + outcome=ReviewOutcome.NO_ISSUE, + reason="not yet", + ) + == "STATE_CONFLICT" + ) + + +def test_finding_review_requires_reason_evidence_and_rejects_superseded(): + assert ( + confirm_finding(INSPECTOR, finding(), expected_version=1, reason="evidence checked") + is FindingStatus.CONFIRMED + ) + assert ( + call_error( + confirm_finding, + INSPECTOR, + finding(evidence_ready=False), + expected_version=1, + reason="review", + ) + == "STATE_CONFLICT" + ) + assert ( + reject_finding(INSPECTOR, finding(), expected_version=1, reason="false positive") + is FindingStatus.REJECTED + ) + assert ( + cannot_determine_finding( + INSPECTOR, + finding(), + expected_version=1, + reason="unreadable", + reason_code="label_unreadable", + ) + is FindingStatus.CANNOT_DETERMINE + ) + assert ( + call_error( + cannot_determine_finding, + INSPECTOR, + finding(), + expected_version=1, + reason="unknown", + reason_code="bad", + ) + == "VALIDATION_ERROR" + ) + assert ( + call_error( + confirm_finding, INSPECTOR, finding(superseded=True), expected_version=1, reason="old" + ) + == "STATE_CONFLICT" + ) + + +def test_dispatch_creates_pending_task_and_rejects_cross_tenant_assignee(): + result = dispatch_finding( + DISPATCHER, + finding(status=FindingStatus.CONFIRMED), + expected_version=1, + assignee=REMEDIATOR, + assignee_active=True, + due_at=datetime(2026, 9, 30, tzinfo=timezone.utc) + timedelta(hours=1), + now=datetime(2026, 9, 30, tzinfo=timezone.utc), + ) + assert result == RemediationDecision(TaskStatus.PENDING_DISPATCH, FindingStatus.DISPATCHED) + foreign = Principal( + user_id="foreign", + tenant_id="tenant-2", + username="foreign", + roles=(("remediator", "laboratory", LAB),), + ) + assert ( + call_error( + dispatch_finding, + DISPATCHER, + finding(status=FindingStatus.CONFIRMED), + expected_version=1, + assignee=foreign, + assignee_active=True, + due_at=datetime(2026, 9, 30, tzinfo=timezone.utc) + timedelta(hours=1), + now=datetime(2026, 9, 30, tzinfo=timezone.utc), + ) + == "NOT_FOUND" + ) + + +def test_remediation_lifecycle_uses_actual_actor_and_latest_evidence(): + t = task() + f = finding(id="finding-1", status=FindingStatus.DISPATCHED) + assert accept_task(REMEDIATOR, t, f, expected_version=1) == RemediationDecision( + TaskStatus.IN_PROGRESS, FindingStatus.DISPATCHED + ) + imgs = (image("remediation_task", "task-1"),) + assert submit_evidence( + REMEDIATOR, + task(status=TaskStatus.IN_PROGRESS), + f, + expected_version=1, + images=imgs, + description="after cleanup", + ) == RemediationDecision(TaskStatus.PENDING_RECHECK, FindingStatus.DISPATCHED) + pending = task(status=TaskStatus.PENDING_RECHECK, latest_evidence_id="evidence-1") + ev = evidence() + assert recheck_task( + OTHER_INSPECTOR, + pending, + f, + expected_version=1, + evidence=ev, + evidence_id="evidence-1", + reason="independent check", + ) == RemediationDecision(TaskStatus.CLOSED, FindingStatus.CLOSED) + assert ( + call_error( + recheck_task, + REMEDIATOR, + pending, + f, + expected_version=1, + evidence=ev, + evidence_id="evidence-1", + reason="self check", + ) + == "FORBIDDEN" + ) + assert ( + call_error( + recheck_task, + OTHER_INSPECTOR, + pending, + f, + expected_version=1, + evidence=ev, + evidence_id="wrong", + reason="wrong evidence", + ) + == "STATE_CONFLICT" + ) + assert reject_task( + OTHER_INSPECTOR, + pending, + f, + expected_version=1, + evidence=ev, + evidence_id="evidence-1", + reason="insufficient cleanup", + ) == RemediationDecision(TaskStatus.REJECTED, FindingStatus.DISPATCHED) + + +def test_admin_cannot_remediate_keeps_finding_open_and_reassignment_requires_future_due(): + t = task(status=TaskStatus.IN_PROGRESS) + f = finding(id="finding-1", status=FindingStatus.DISPATCHED) + assert cannot_remediate( + ADMIN, t, f, expected_version=1, reason="vendor unavailable" + ) == RemediationDecision(TaskStatus.CANNOT_REMEDIATE, FindingStatus.DISPATCHED) + now = datetime(2026, 9, 30, tzinfo=timezone.utc) + assert reassign_task( + DISPATCHER, + t, + f, + expected_version=1, + assignee=OTHER_REMEDIATOR, + assignee_active=True, + due_at=now + timedelta(hours=1), + now=now, + reason="capacity change", + ) == RemediationDecision(TaskStatus.PENDING_DISPATCH, FindingStatus.DISPATCHED) + assert ( + call_error( + reassign_task, + DISPATCHER, + t, + f, + expected_version=1, + assignee=OTHER_REMEDIATOR, + assignee_active=True, + due_at=now, + now=now, + reason="late", + ) + == "VALIDATION_ERROR" + ) + + +def test_version_conflict_and_run_enum(): + with pytest.raises(ServiceError) as exc: + submit_item( + INSPECTOR, + item(status=ItemStatus.UPLOADED), + expected_version=2, + images=(image("inspection_item", "item-1"),), + selections=(ImageSelection(image_id="image-1", role="overview", parent_image_id=None),), + current_findings=(), + ) + assert exc.value.code == "VERSION_CONFLICT" + assert {status.value for status in RunStatus} == { + "queued", + "processing", + "retrying", + "completed", + "needs_retake", + "needs_review", + "failed", + "superseded", + } diff --git a/tests/domain/test_workflow_boundaries.py b/tests/domain/test_workflow_boundaries.py new file mode 100644 index 0000000..b477991 --- /dev/null +++ b/tests/domain/test_workflow_boundaries.py @@ -0,0 +1,281 @@ +"""Snapshot consistency and boundary cases for I-02 command guards.""" + +from dataclasses import replace +from datetime import datetime, timedelta, timezone + +import pytest + +from packages.domain import workflow as w +from tests.domain import test_workflow as f +from tests.domain.test_workflow_matrix import ITEM_IMAGES, MATRIX, SELECTION, case, expect_error + + +@pytest.mark.parametrize("name", ["submit_item", "retry_item", "complete_item"]) +@pytest.mark.parametrize("parent_state", ["completed", "cancelled", "unknown"]) +def test_terminal_inspection_blocks_item_commands(name, parent_state): + command, actor, target, siblings, args = case(name) + expect_error( + "STATE_CONFLICT", + command, + actor, + replace(target, inspection_status=parent_state), + *siblings, + **args, + ) + + +@pytest.mark.parametrize("name", ["submit_item", "retry_item", "complete_item"]) +@pytest.mark.parametrize( + "change,code", + [ + ({"tenant_id": "foreign"}, "NOT_FOUND"), + ({"laboratory_id": "foreign"}, "NOT_FOUND"), + ({"item_id": "foreign"}, "NOT_FOUND"), + ({"run_id": "old"}, "STATE_CONFLICT"), + ({"evaluation_id": "old"}, "STATE_CONFLICT"), + ({"superseded": None}, "STATE_CONFLICT"), + ({"status": "unknown"}, "STATE_CONFLICT"), + ], +) +def test_current_finding_must_match_loaded_aggregate(name, change, code): + command, actor, target, siblings, args = case(name) + args["current_findings"] = ( + f.finding(status="rejected") + if not change + else replace(f.finding(status="rejected"), **change), + ) + expect_error(code, command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize( + "change,code", + [ + ({"tenant_id": "foreign"}, "NOT_FOUND"), + ({"id": "old"}, "STATE_CONFLICT"), + ({"run_id": "old"}, "STATE_CONFLICT"), + ({"item_id": "other"}, "STATE_CONFLICT"), + ({"status": "queued"}, "STATE_CONFLICT"), + ({"status": "failed"}, "STATE_CONFLICT"), + ({"has_unknown": None}, "VALIDATION_ERROR"), + ({"has_unknown": 1}, "VALIDATION_ERROR"), + ({"insufficient_facts": "false"}, "VALIDATION_ERROR"), + ], +) +def test_completion_uses_only_current_typed_evaluation(change, code): + command, actor, target, siblings, args = case("complete_item") + args["evaluation"] = f.evaluation(**change) + expect_error(code, command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize("outcome", ["unknown", None, True, 1]) +def test_invalid_completion_outcome_is_state_conflict(outcome): + command, actor, target, siblings, args = case("complete_item") + args["outcome"] = outcome + expect_error("STATE_CONFLICT", command, actor, target, *siblings, **args) + + +def test_superseded_uncertainty_is_not_current_and_duplicate_findings_are_rejected(): + command, actor, target, siblings, args = case("complete_item") + args["current_findings"] = ( + f.finding(status="cannot_determine", superseded=True, run_id="old", evaluation_id="old"), + ) + assert command(actor, target, *siblings, **args) is w.ItemStatus.COMPLETED + args["current_findings"] = (f.finding(status="rejected"),) * 2 + expect_error("STATE_CONFLICT", command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize("run_state", [*w.RunStatus, "unknown"]) +def test_only_technical_failure_can_be_retried(run_state): + command, actor, target, siblings, args = case("retry_item") + args["old_run"] = f.failed_run(status=run_state) + if run_state == "failed": + assert command(actor, target, *siblings, **args).replay_of == "run-1" + else: + expect_error("STATE_CONFLICT", command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize( + "change,code", + [ + ({"tenant_id": "other"}, "NOT_FOUND"), + ({"id": "old"}, "STATE_CONFLICT"), + ({"item_id": "other"}, "STATE_CONFLICT"), + ({"configuration_available": False}, "STATE_CONFLICT"), + ({"configuration_available": 1}, "STATE_CONFLICT"), + ], +) +def test_retry_rejects_foreign_stale_or_unavailable_run(change, code): + command, actor, target, siblings, args = case("retry_item") + args["old_run"] = f.failed_run(**change) + expect_error(code, command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize( + "change", + [ + {"model_bundle_id": ""}, + {"dictionary_version_id": None}, + {"rule_bundle_id": " "}, + {"pipeline_version": "latest"}, + {"device_profile": "auto"}, + {"reference_date": "2026-09-30"}, + {"reference_date": datetime(2026, 9, 30, tzinfo=timezone.utc)}, + ], +) +def test_retry_never_falls_back_to_latest_configuration(change): + command, actor, target, siblings, args = case("retry_item") + args["old_run"] = f.failed_run(pinned=replace(f.failed_run().pinned, **change)) + expect_error("STATE_CONFLICT", command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize("name", ["submit_item", "retry_item", "submit_evidence"]) +def test_ready_but_unavailable_image_cannot_be_used(name): + command, actor, target, siblings, args = case(name) + args["images"] = (replace(args["images"][0], available=False),) + expect_error("STATE_CONFLICT", command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize("name,maximum", [("submit_item", 3), ("submit_evidence", 10)]) +def test_image_count_exact_limit_and_overflow(name, maximum): + command, actor, target, siblings, args = case(name) + template = args["images"][0] + for count in (1, maximum, maximum + 1): + args["images"] = tuple(replace(template, id=str(i)) for i in range(count)) + if name == "submit_item": + args["selections"] = tuple( + w.ImageSelection( + image_id=str(i), + role="overview" if i == 0 else "detail", + parent_image_id=None if i == 0 else "0", + ) + for i in range(count) + ) + if count <= maximum: + assert command(actor, target, *siblings, **args) is not None + else: + expect_error("VALIDATION_ERROR", command, actor, target, *siblings, **args) + + +def test_item_images_must_share_location(): + expect_error( + "VALIDATION_ERROR", + w.submit_item, + f.INSPECTOR, + f.item(status="uploaded"), + expected_version=1, + images=(replace(ITEM_IMAGES[0], location_id="other"),), + selections=(SELECTION,), + current_findings=(), + ) + + +@pytest.mark.parametrize( + "name", + [ + "accept_task", + "submit_evidence", + "recheck_task", + "reject_task", + "cannot_remediate", + "reassign_task", + ], +) +@pytest.mark.parametrize( + "change,code", + [ + ({"tenant_id": "other"}, "NOT_FOUND"), + ({"laboratory_id": "other"}, "NOT_FOUND"), + ({"id": "other"}, "STATE_CONFLICT"), + ({"status": "closed"}, "STATE_CONFLICT"), + ({"superseded": True}, "STATE_CONFLICT"), + ], +) +def test_remediation_requires_its_current_dispatched_finding(name, change, code): + command, actor, target, siblings, args = case(name) + expect_error(code, command, actor, target, replace(siblings[0], **change), **args) + + +@pytest.mark.parametrize("name", ["recheck_task", "reject_task"]) +@pytest.mark.parametrize( + "change,code", + [ + ({"tenant_id": "other"}, "NOT_FOUND"), + ({"id": "old"}, "STATE_CONFLICT"), + ({"task_id": "other"}, "STATE_CONFLICT"), + ({"submitted_by": ""}, "STATE_CONFLICT"), + ], +) +def test_recheck_evidence_must_match_task_and_latest_identity(name, change, code): + command, actor, target, siblings, args = case(name) + args["evidence"] = replace(f.evidence(), **change) + expect_error(code, command, actor, target, *siblings, **args) + + +@pytest.mark.parametrize("name", ["recheck_task", "reject_task"]) +@pytest.mark.parametrize( + "change", [{"latest_evidence_id": None}, {"latest_evidence_id": "new"}, {"assignee_id": ""}] +) +def test_recheck_requires_complete_task_snapshot(name, change): + command, actor, target, siblings, args = case(name) + expect_error("STATE_CONFLICT", command, actor, replace(target, **change), *siblings, **args) + + +@pytest.mark.parametrize("reason_code", [*w.UNCERTAIN_REASONS, "MISSING_EXPIRY", " ", None]) +def test_uncertainty_reason_code_is_exact_contract_enum(reason_code): + args = dict(expected_version=1, reason="unresolved", reason_code=reason_code) + if reason_code in w.UNCERTAIN_REASONS: + assert ( + w.cannot_determine_finding(f.INSPECTOR, f.finding(), **args) + is w.FindingStatus.CANNOT_DETERMINE + ) + else: + expect_error( + "VALIDATION_ERROR", w.cannot_determine_finding, f.INSPECTOR, f.finding(), **args + ) + + +@pytest.mark.parametrize("version", [False, 0, -1, 2147483648, "1", 1.0, None]) +def test_expected_version_is_strict_integer(version): + command, actor, target, siblings, args = case("accept_task") + args["expected_version"] = version + expect_error("VALIDATION_ERROR", command, actor, target, *siblings, **args) + + +def test_review_does_not_require_creator_but_capture_does(): + assert ( + w.complete_item( + f.OTHER_INSPECTOR, + f.item(), + expected_version=1, + evaluation=f.evaluation(), + current_findings=(), + outcome="no_issue", + reason="independent review", + ) + is w.ItemStatus.COMPLETED + ) + assert ( + w.submit_item( + f.ADMIN, + f.item(status="uploaded"), + expected_version=1, + images=ITEM_IMAGES, + selections=(SELECTION,), + current_findings=(), + ) + is w.ItemStatus.QUEUED + ) + + +@pytest.mark.parametrize("name", MATRIX) +def test_successful_decision_does_not_mutate_input_snapshots(name): + command, actor, target, siblings, args = case(name) + original = repr((actor, target, siblings, args)) + command(actor, target, *siblings, **args) + assert repr((actor, target, siblings, args)) == original + + +def test_deadline_comparison_accepts_different_timezone_for_same_instant(): + command, actor, target, siblings, args = case("dispatch_finding") + args["due_at"] = (args["now"] + timedelta(seconds=1)).astimezone(timezone(timedelta(hours=8))) + assert command(actor, target, *siblings, **args).finding_status is w.FindingStatus.DISPATCHED diff --git a/tests/domain/test_workflow_matrix.py b/tests/domain/test_workflow_matrix.py new file mode 100644 index 0000000..85b8558 --- /dev/null +++ b/tests/domain/test_workflow_matrix.py @@ -0,0 +1,391 @@ +"""Command/state, authorization and IRR-02 matrices; no services or persistence.""" + +import json +import re +from dataclasses import FrozenInstanceError, replace +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import pytest +import yaml + +from packages.domain import workflow as w +from packages.domain.security import Principal, ServiceError +from tests.domain import test_workflow as f + +NOW = datetime(2026, 9, 30, tzinfo=timezone.utc) +SELECTION = w.ImageSelection(image_id="image-1", role="overview", parent_image_id=None) +ITEM_IMAGES = (f.image("inspection_item", "item-1"),) +DISPATCHED = f.finding(id="finding-1", status=w.FindingStatus.DISPATCHED) + + +def case(name): + """Build a valid server-loaded context for each public command guard.""" + args = {"expected_version": 1} + sibling = () + if name == "submit_item": + actor, target = f.INSPECTOR, f.item(status="uploaded") + args.update(images=ITEM_IMAGES, selections=(SELECTION,), current_findings=()) + elif name == "retry_item": + actor, target = f.INSPECTOR, f.item(status="failed") + args.update(images=ITEM_IMAGES, old_run=f.failed_run(), current_findings=()) + elif name == "complete_item": + actor, target = f.INSPECTOR, f.item() + args.update(evaluation=f.evaluation(), current_findings=(), outcome="no_issue", reason="ok") + elif name in {"confirm_finding", "reject_finding", "cannot_determine_finding"}: + actor, target = f.INSPECTOR, f.finding() + args.update(reason="reviewed") + if name == "cannot_determine_finding": + args.update(reason_code="other") + elif name == "dispatch_finding": + actor, target = f.DISPATCHER, f.finding(status="confirmed") + args.update( + assignee=f.REMEDIATOR, assignee_active=True, due_at=NOW + timedelta(days=1), now=NOW + ) + else: + sibling = (DISPATCHED,) + target = f.task() + if name == "accept_task": + actor = f.REMEDIATOR + elif name == "submit_evidence": + actor, target = f.REMEDIATOR, f.task(status="in_progress") + args.update(images=(f.image("remediation_task", "task-1"),), description="done") + elif name in {"recheck_task", "reject_task"}: + actor = f.INSPECTOR + target = f.task(status="pending_recheck", latest_evidence_id="evidence-1") + args.update(evidence=f.evidence(), evidence_id="evidence-1", reason="checked") + elif name == "cannot_remediate": + actor = f.ADMIN + args.update(reason="no safe repair available") + elif name == "reassign_task": + actor = f.DISPATCHER + args.update( + assignee=f.OTHER_REMEDIATOR, + assignee_active=True, + due_at=NOW + timedelta(days=1), + now=NOW, + reason="new owner", + ) + else: + raise AssertionError(name) + return getattr(w, name), actor, target, sibling, args + + +MATRIX = { + "submit_item": (w.ItemStatus, {"uploaded", "needs_retake", "needs_review"}), + "retry_item": (w.ItemStatus, {"failed"}), + "complete_item": (w.ItemStatus, {"needs_review"}), + "confirm_finding": (w.FindingStatus, {"needs_review"}), + "reject_finding": (w.FindingStatus, {"needs_review"}), + "cannot_determine_finding": (w.FindingStatus, {"needs_review"}), + "dispatch_finding": (w.FindingStatus, {"confirmed"}), + "accept_task": (w.TaskStatus, {"pending_dispatch"}), + "submit_evidence": (w.TaskStatus, {"in_progress", "rejected"}), + "recheck_task": (w.TaskStatus, {"pending_recheck"}), + "reject_task": (w.TaskStatus, {"pending_recheck"}), + "cannot_remediate": ( + w.TaskStatus, + {"pending_dispatch", "in_progress", "pending_recheck", "rejected"}, + ), + "reassign_task": ( + w.TaskStatus, + {"pending_dispatch", "in_progress", "rejected", "cannot_remediate"}, + ), +} + + +def expect_error(code, command, *args, **kwargs): + with pytest.raises(ServiceError) as exc: + command(*args, **kwargs) + assert exc.value.code == code + assert ( + exc.value.status + == { + "NOT_FOUND": 404, + "FORBIDDEN": 403, + "VALIDATION_ERROR": 422, + "STATE_CONFLICT": 409, + "VERSION_CONFLICT": 409, + }[code] + ) + + +@pytest.mark.parametrize( + "name,state", + [ + (name, state) + for name, (enum, _) in MATRIX.items() + for state in [*enum, "unrecognized", None] + ], +) +def test_every_command_state_pair(name, state): + command, actor, target, sibling, args = case(name) + target = replace(target, status=state) + if state in MATRIX[name][1]: + assert command(actor, target, *sibling, **args) is not None + else: + expect_error("STATE_CONFLICT", command, actor, target, *sibling, **args) + + +@pytest.mark.parametrize("name", MATRIX) +@pytest.mark.parametrize( + "variant,code", + [ + ("tenant", "NOT_FOUND"), + ("lab", "NOT_FOUND"), + ("viewer", "FORBIDDEN"), + ("version", "VERSION_CONFLICT"), + ("boolean_version", "VALIDATION_ERROR"), + ], +) +def test_every_command_enforces_scope_permission_and_version(name, variant, code): + command, actor, target, sibling, args = case(name) + if variant == "tenant": + actor = replace(actor, tenant_id="foreign") + args["expected_version"] = 99 # Must not leak the version of a foreign object. + elif variant == "lab": + actor = replace(actor, roles=(("inspector", "laboratory", "other-lab"),)) + elif variant == "viewer": + actor = replace(actor, roles=(("viewer", "laboratory", f.LAB),)) + else: + args["expected_version"] = True if variant == "boolean_version" else 2 + expect_error(code, command, actor, target, *sibling, **args) + + +@pytest.mark.parametrize("risk_state", [None, "rejected", "confirmed", "dispatched", "closed"]) +@pytest.mark.parametrize("unknown", [False, True]) +@pytest.mark.parametrize("insufficient", [False, True]) +@pytest.mark.parametrize("uncertain_finding", [False, True]) +@pytest.mark.parametrize("outcome", w.ReviewOutcome) +def test_complete_truth_table(risk_state, unknown, insufficient, uncertain_finding, outcome): + findings = () if risk_state is None else (f.finding(id="risk", status=risk_state),) + if uncertain_finding: + findings += (f.finding(id="uncertain", status="cannot_determine"),) + u = unknown or insufficient or uncertain_finding + c = risk_state in {"confirmed", "dispatched", "closed"} + valid = ( + (outcome == "cannot_determine" and u) + or (outcome == "no_issue" and not u and not c) + or (outcome == "issues_confirmed" and not u and c) + ) + args = dict( + expected_version=1, + evaluation=f.evaluation(has_unknown=unknown, insufficient_facts=insufficient), + current_findings=findings, + outcome=outcome, + reason="final decision", + ) + if valid: + assert w.complete_item(f.INSPECTOR, f.item(), **args) is w.ItemStatus.COMPLETED + else: + expect_error("STATE_CONFLICT", w.complete_item, f.INSPECTOR, f.item(), **args) + + +@pytest.mark.parametrize("outcome", [*w.ReviewOutcome, "unsafe", None]) +def test_pending_finding_always_blocks_completion(outcome): + expect_error( + "STATE_CONFLICT", + w.complete_item, + f.INSPECTOR, + f.item(), + expected_version=1, + evaluation=f.evaluation(has_unknown=True), + current_findings=(f.finding(),), + outcome=outcome, + reason="pending", + ) + + +@pytest.mark.parametrize("name", ["submit_item", "retry_item"]) +@pytest.mark.parametrize("status", ["confirmed", "dispatched", "closed"]) +def test_current_risk_blocks_resubmission_and_retry(name, status): + command, actor, target, sibling, args = case(name) + args["current_findings"] = (f.finding(status=status),) + expect_error("STATE_CONFLICT", command, actor, target, *sibling, **args) + args["current_findings"] = (f.finding(status=status, superseded=True),) + assert command(actor, target, *sibling, **args) is not None + + +@pytest.mark.parametrize( + "name", ["confirm_finding", "reject_finding", "cannot_determine_finding", "dispatch_finding"] +) +def test_superseded_finding_rejects_all_commands(name): + command, actor, target, sibling, args = case(name) + expect_error( + "STATE_CONFLICT", command, actor, replace(target, superseded=True), *sibling, **args + ) + + +@pytest.mark.parametrize("name", ["recheck_task", "reject_task"]) +@pytest.mark.parametrize("identity", ["assignee", "submitter"]) +@pytest.mark.parametrize("actor", [f.INSPECTOR, f.ADMIN]) +def test_even_authorized_admin_or_inspector_cannot_recheck_own_work(name, identity, actor): + command, _, target, sibling, args = case(name) + if identity == "assignee": + target = replace(target, assignee_id=actor.user_id) + else: + args["evidence"] = f.evidence(submitted_by=actor.user_id) + expect_error("FORBIDDEN", command, actor, target, *sibling, **args) + + +@pytest.mark.parametrize("name", ["accept_task", "submit_evidence"]) +@pytest.mark.parametrize("actor", [f.OTHER_REMEDIATOR, f.ADMIN]) +def test_assignee_cannot_be_impersonated(name, actor): + command, _, target, sibling, args = case(name) + expect_error("FORBIDDEN", command, actor, target, *sibling, **args) + + +@pytest.mark.parametrize("name", ["submit_item", "submit_evidence"]) +@pytest.mark.parametrize( + "problem,code", + [ + ("empty", "VALIDATION_ERROR"), + ("duplicate", "VALIDATION_ERROR"), + ("capacity", "VALIDATION_ERROR"), + ("owner", "VALIDATION_ERROR"), + ("owner_type", "VALIDATION_ERROR"), + ("tenant", "NOT_FOUND"), + ("lab", "NOT_FOUND"), + ("unready", "STATE_CONFLICT"), + ], +) +def test_image_lists_fail_closed(name, problem, code): + command, actor, target, sibling, args = case(name) + img = args["images"][0] + if problem == "empty": + args["images"] = () + elif problem == "duplicate": + args["images"] = (img, img) + elif problem == "capacity": + args["images"] = tuple(replace(img, id=str(i)) for i in range(11)) + else: + field, value = { + "owner": ("owner_id", "other"), + "owner_type": ("owner_type", "other"), + "tenant": ("tenant_id", "foreign"), + "lab": ("laboratory_id", "foreign"), + "unready": ("status", "validating"), + }[problem] + args["images"] = (replace(img, **{field: value}),) + expect_error(code, command, actor, target, *sibling, **args) + + +def test_image_selection_and_retry_retain_roles_parent_and_order(): + images = ITEM_IMAGES + (replace(ITEM_IMAGES[0], id="detail"),) + selections = ( + SELECTION, + w.ImageSelection(image_id="detail", role="detail", parent_image_id="image-1"), + ) + assert ( + w.submit_item( + f.INSPECTOR, + f.item(status="uploaded"), + expected_version=1, + images=images, + selections=selections, + current_findings=(), + ) + is w.ItemStatus.QUEUED + ) + pinned = replace(f.failed_run().pinned, images=selections) + decision = w.retry_item( + f.INSPECTOR, + f.item(status="failed"), + expected_version=1, + images=images, + old_run=f.failed_run(pinned=pinned), + current_findings=(), + ) + assert decision.pinned is pinned + assert decision.replay_of == "run-1" + for bad in [ + (), + selections[::-1], + (SELECTION, SELECTION), + (SELECTION, replace(selections[1], parent_image_id="foreign")), + (replace(SELECTION, parent_image_id="image-1"), selections[1]), + (SELECTION, replace(selections[1], role="overview")), + ]: + expect_error( + "VALIDATION_ERROR", + w.submit_item, + f.INSPECTOR, + f.item(status="uploaded"), + expected_version=1, + images=images, + selections=bad, + current_findings=(), + ) + + +@pytest.mark.parametrize("name", ["dispatch_finding", "reassign_task"]) +@pytest.mark.parametrize("problem", ["disabled", "role", "wrong_lab", "naive", "equal", "past"]) +def test_assignment_validation(name, problem): + command, actor, target, sibling, args = case(name) + if problem == "disabled": + args["assignee_active"] = False + elif problem in {"role", "wrong_lab"}: + args["assignee"] = replace( + f.REMEDIATOR, + roles=(("viewer", "laboratory", f.LAB),) + if problem == "role" + else (("remediator", "laboratory", "other"),), + ) + else: + args["due_at"] = { + "naive": NOW.replace(tzinfo=None), + "equal": NOW, + "past": NOW - timedelta(seconds=1), + }[problem] + expect_error("VALIDATION_ERROR", command, actor, target, *sibling, **args) + + +@pytest.mark.parametrize( + "name", + [ + "complete_item", + "confirm_finding", + "reject_finding", + "cannot_determine_finding", + "recheck_task", + "reject_task", + "cannot_remediate", + "reassign_task", + ], +) +@pytest.mark.parametrize("reason", ["", " ", None, "x" * 2001]) +def test_required_reasons(name, reason): + command, actor, target, sibling, args = case(name) + args["reason"] = reason + expect_error("VALIDATION_ERROR", command, actor, target, *sibling, **args) + + +def test_schema_enum_and_capacity_consistency(): + root = Path(__file__).resolve().parents[2] + schema = json.loads( + (root / "packages/persistence/migrations/snapshots/0001_initial.json").read_text( + encoding="utf-8" + ) + ) + for table, enum in [ + ("inspection_items", w.ItemStatus), + ("inference_runs", w.RunStatus), + ("findings", w.FindingStatus), + ("remediation_tasks", w.TaskStatus), + ]: + enum_sql = schema["tables"][table]["columns"]["status"]["type"] + assert {value.value for value in enum} == set(re.findall("'([^']+)'", enum_sql)) + with (root / "contracts/public-api-v1.yaml").open(encoding="utf-8") as stream: + spec = yaml.safe_load(stream)["components"]["schemas"] + assert set(spec["FindingUncertain"]["properties"]["reason_code"]["enum"]) == w.UNCERTAIN_REASONS + assert spec["ItemSubmit"]["properties"]["images"]["maxItems"] == 3 + assert spec["EvidenceSubmit"]["properties"]["image_ids"]["maxItems"] == 10 + + +def test_snapshots_are_immutable_and_no_generic_status_setter_exists(): + with pytest.raises(FrozenInstanceError): + f.item().status = "completed" + assert not hasattr(w, "transition") + assert not hasattr(w, "task_transition") + assert f.failed_run().pinned.reference_date == date(2026, 9, 30) + assert isinstance(f.INSPECTOR, Principal) diff --git a/tests/persistence/conftest.py b/tests/persistence/conftest.py index 19c75d1..d225c9f 100644 --- a/tests/persistence/conftest.py +++ b/tests/persistence/conftest.py @@ -1,13 +1,25 @@ import json import os +import secrets +import shutil +import socket +import subprocess +import time import pytest from alembic import command +from redis import Redis +from redis.exceptions import ConnectionError from sqlalchemy import inspect, text +from apps.api.app.main import create_app +from packages.application.identity import IdentityApplication +from packages.application.rate_limit import RateLimits from packages.persistence.cli import migration_config from packages.persistence.database import database_engine from packages.persistence.schema import verify_schema +from packages.persistence.security import SessionService +from tests.persistence.test_security_mysql import create_tenant @pytest.fixture(scope="session") @@ -59,3 +71,67 @@ def transaction(database): yield connection finally: transaction.rollback() + + +@pytest.fixture(scope="module") +def isolated_redis(tmp_path_factory): + if os.getenv("CI") == "true" and os.getenv("LABSAFE_CI_REDIS_PORT"): + client = Redis( + host="127.0.0.1", port=int(os.environ["LABSAFE_CI_REDIS_PORT"]), socket_timeout=2 + ) + assert client.ping() + yield client + client.close() + return + executable = os.getenv("LABSAFE_TEST_REDIS_SERVER") or shutil.which("redis-server") + if not executable: + pytest.fail("Set LABSAFE_TEST_REDIS_SERVER to start a disposable Redis child") + folder = tmp_path_factory.mktemp("redis") + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + child = subprocess.Popen( + [ + executable, + "--port", + str(port), + "--bind", + "127.0.0.1", + "--save", + "", + "--appendonly", + "no", + "--dir", + str(folder), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + client = Redis(host="127.0.0.1", port=port, socket_timeout=2) + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if child.poll() is not None: + raise RuntimeError("Disposable Redis failed to start") + try: + if client.ping(): + break + except ConnectionError: + time.sleep(0.05) + assert client.info("server")["process_id"] == child.pid + yield client + finally: + client.close() + child.terminate() # Only the child this fixture created; never a discovered service. + child.wait(timeout=10) + + +@pytest.fixture +def identity(database, isolated_redis): + tenant = create_tenant(database, "identity") + service = IdentityApplication( + database, SessionService(secrets.token_bytes(32)), RateLimits(isolated_redis), "test" + ) + app = create_app(identity=service, public_origin="https://labsafe.test") + return tenant, service, app diff --git a/tests/persistence/test_foundations_mysql.py b/tests/persistence/test_foundations_mysql.py new file mode 100644 index 0000000..ed5e877 --- /dev/null +++ b/tests/persistence/test_foundations_mysql.py @@ -0,0 +1,808 @@ +"""Foundation APIs against disposable MySQL/Redis, without seeded business success.""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from threading import Barrier +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from redis.exceptions import ConnectionError +from sqlalchemy import event, text + +from packages.persistence.security import load_principal +from tests.persistence.test_identity_mysql import ORIGIN, assert_schema, headers, login +from tests.persistence.test_roles_mysql import command, create_user, role_write, scalar +from tests.persistence.test_security_mysql import create_tenant + + +def post(http, session, path, body, *, key=None): + return http.post("/api/v1" + path, json=body, headers=headers(session, key)) + + +def created(http, session, path, body, schema): + response = post(http, session, path, body) + assert response.status_code == 201, response.text + assert_schema(response, schema) + return response.json()["data"] + + +def template_body(count=2): + return { + "name": "Synthetic template", + "items": [ + { + "id": str(uuid4()), + "code": f"item-{i}", + "title": f"Item {i}", + "capture_hint": "Synthetic capture hint", + "sort_order": i, + "required": i % 2 == 0, + } + for i in range(count) + ], + } + + +def organization(http, session): + college = created( + http, session, "/colleges", {"code": str(uuid4()), "name": "Synthetic college"}, "College" + ) + lab = created( + http, + session, + "/laboratories", + {"college_id": college["id"], "code": str(uuid4()), "name": "Synthetic lab"}, + "Laboratory", + ) + room = created( + http, + session, + f"/laboratories/{lab['id']}/locations", + {"parent_id": None, "type": "room", "label": "Room"}, + "Location", + ) + return college, lab, room + + +def published(http, session, count=2): + template = created(http, session, "/templates", template_body(count), "Template") + response = post( + http, + session, + f"/templates/{template['id']}/publish", + {"expected_version": 1, "reason": "Ready for synthetic test"}, + ) + assert response.status_code == 200, response.text + assert_schema(response, "Template") + return response.json()["data"] + + +def inspection_body(lab, template, *locations): + return { + "laboratory_id": lab["id"], + "template_id": template["id"], + "location_ids": [location["id"] for location in locations], + } + + +def count_rows(database, tenant, table): + # Table names below are fixed test literals, not user input. + return scalar(database, f"SELECT COUNT(*) FROM {table} WHERE tenant_id=:tenant", tenant) + + +def test_complete_foundation_chain_and_all_response_schemas(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + college, lab, room = organization(http, session) + area = created( + http, + session, + f"/laboratories/{lab['id']}/locations", + {"parent_id": room["id"], "type": "area", "label": "Area"}, + "Location", + ) + template = published(http, session) + result = created( + http, session, "/inspections", inspection_body(lab, template, room, area), "Inspection" + ) + assert result["status"] == "draft" and len(set(result["item_ids"])) == 4 + assert result["inspector_id"] == tenant["admin_id"] + assert template["status"] == "published" and template["version"] == 2 + for path, resource, schema in [ + ("colleges", college, "College"), + ("laboratories", lab, "Laboratory"), + ("templates", template, "Template"), + ("inspections", result, "Inspection"), + ]: + assert_schema(http.get(f"/api/v1/{path}/{resource['id']}"), schema) + page = http.get(f"/api/v1/{path}?page_size=1") + assert_schema(page, schema + "Page") + assert page.json()["data"]["total"] == 1 + assert "tenant_id" not in page.json()["data"]["items"][0] + assert_schema(http.get(f"/api/v1/laboratories/{lab['id']}/locations"), "LocationPage") + with database.begin() as connection: + rows = ( + connection.execute( + text("SELECT * FROM inspection_items WHERE inspection_id=:id"), {"id": result["id"]} + ) + .mappings() + .all() + ) + assert {(r["template_item_id"], r["location_id"]) for r in rows} == { + (i["id"], loc["id"]) for i in template["items"] for loc in (room, area) + } + for row in rows: + assert row["status"] == "draft" and row["submission_revision"] == 0 + assert row["current_run_id"] is row["current_fact_revision_id"] is None + assert row["current_evaluation_id"] is row["review_outcome"] is None + + +def test_template_publish_clone_versions_and_replay(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + body, key = template_body(), str(uuid4()) + original = post(http, session, "/templates", body, key=key) + assert original.status_code == 201 + assert post(http, session, "/templates", body, key=key).content == original.content + assert ( + post(http, session, "/templates", {**body, "name": "Other"}, key=key).status_code == 409 + ) + source = original.json()["data"] + path = f"/templates/{source['id']}/publish" + publish_key = str(uuid4()) + version = {"expected_version": 1, "reason": "Publish reason"} + result = post(http, session, path, version, key=publish_key) + assert result.status_code == 200 + assert post(http, session, path, version, key=publish_key).content == result.content + assert post(http, session, path, version).json()["error"]["code"] == "VERSION_CONFLICT" + assert post(http, session, path, {**version, "expected_version": 2}).status_code == 409 + clone_path, clone_key = f"/templates/{source['id']}/clone", str(uuid4()) + reason = {"expected_version": 2, "reason": "Clone audit reason"} + first = post(http, session, clone_path, reason, key=clone_key) + assert first.status_code == 201, first.text + assert_schema(first, "Template") + assert post(http, session, clone_path, reason, key=clone_key).content == first.content + clone = first.json()["data"] + assert (clone["revision"], clone["version"], clone["status"]) == (2, 1, "draft") + assert not ({i["id"] for i in source["items"]} & {i["id"] for i in clone["items"]}) + assert [{k: v for k, v in i.items() if k != "id"} for i in source["items"]] == [ + {k: v for k, v in i.items() if k != "id"} for i in clone["items"] + ] + second = post( + http, + session, + f"/templates/{clone['id']}/clone", + {"expected_version": 1, "reason": "Clone later revision"}, + ) + assert second.json()["data"]["revision"] == 3 + current = http.get(f"/api/v1/templates/{source['id']}").json()["data"] + assert current == result.json()["data"] + assert ( + scalar( + database, + "SELECT reason FROM audit_events WHERE tenant_id=:tenant " + "AND action='template.clone' AND resource_id=:id", + tenant, + id=clone["id"], + ) + == reason["reason"] + ) + assert count_rows(database, tenant, "inspection_templates") == 3 + + +@pytest.mark.parametrize("field", ["id", "code", "sort_order", "normalized_code"]) +def test_duplicate_template_fields_roll_back(identity, database, field): + tenant, _, app = identity + body = template_body() + if field == "normalized_code": + body["items"][1]["code"] = " " + body["items"][0]["code"] + " " + else: + body["items"][1][field] = body["items"][0][field] + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + assert post(http, session, "/templates", body).status_code == 422 + assert count_rows(database, tenant, "inspection_templates") == 0 + assert count_rows(database, tenant, "template_items") == 0 + assert count_rows(database, tenant, "api_idempotency") == 0 + + +def test_duplicate_database_codes_and_child_ids_are_409(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + college, lab, _ = organization(http, session) + for path, body in [ + ("/colleges", {"name": "Other", "code": college["code"]}), + ("/laboratories", {"name": "Other", "code": lab["code"], "college_id": college["id"]}), + ]: + result = post(http, session, path, body) + assert result.status_code == 409 and result.json()["error"]["code"] == "STATE_CONFLICT" + body = template_body() + created(http, session, "/templates", body, "Template") + assert post(http, session, "/templates", body).status_code == 409 + assert count_rows(database, tenant, "inspection_templates") == 1 + assert count_rows(database, tenant, "api_idempotency") == 4 + + +@pytest.mark.parametrize("role", ["inspector", "viewer", "rule_expert", "none"]) +def test_scoped_visibility_public_projections_and_capture(identity, database, role): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as reader: + session = login(admin, tenant).json() + college, lab, room = organization(admin, session) + _, other_lab, other_room = organization(admin, session) + template = published(admin, session) + draft = created(admin, session, "/templates", template_body(), "Template") + inspection = created( + admin, session, "/inspections", inspection_body(lab, template, room), "Inspection" + ) + other = created( + admin, + session, + "/inspections", + inspection_body(other_lab, template, other_room), + "Inspection", + ) + user = create_user(admin, session) + if role != "none": + assert ( + role_write( + admin, + session, + user["id"], + command(role=role, lab=None if role == "rule_expert" else lab["id"]), + ).status_code + == 200 + ) + actor = login(reader, tenant, username="target").json() + public_status = 403 if role == "none" else 200 + assert reader.get(f"/api/v1/colleges/{college['id']}").status_code == public_status + assert reader.get(f"/api/v1/templates/{template['id']}").status_code == public_status + assert reader.get(f"/api/v1/templates/{draft['id']}").status_code == ( + 403 if role == "none" else 404 + ) + if role != "none": + assert reader.get("/api/v1/templates").json()["data"]["total"] == 1 + for path in ("/laboratories", "/inspections"): + result = reader.get("/api/v1" + path + "?page_size=1") + assert result.status_code == (200 if role in {"inspector", "viewer"} else 403) + if result.status_code == 200: + assert result.json()["data"]["total"] == 1 + assert ( + result.json()["data"]["items"][0]["id"] + == (lab if path == "/laboratories" else inspection)["id"] + ) + assert ( + reader.get("/api/v1" + path + f"?laboratory_id={other_lab['id']}").status_code + == 404 + ) + assert reader.get(f"/api/v1/inspections/{other['id']}").status_code == 404 + assert reader.get(f"/api/v1/laboratories/{other_lab['id']}/locations").status_code == 404 + result = post(reader, actor, "/inspections", inspection_body(lab, template, room)) + assert result.status_code == ( + 201 if role == "inspector" else 403 if role == "viewer" else 404 + ) + assert ( + post(reader, actor, "/colleges", {"name": "Forbidden", "code": "f"}).status_code == 403 + ) + assert post(reader, actor, "/templates", template_body()).status_code == 403 + + +def test_cross_tenant_resources_are_hidden(identity, database): + tenant, _, app = identity + foreign = create_tenant(database, "foundationforeign") + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, foreign).json() + college, lab, room = organization(http, session) + template = published(http, session) + inspection = created( + http, session, "/inspections", inspection_body(lab, template, room), "Inspection" + ) + session = login(http, tenant).json() + for kind, obj in [ + ("colleges", college), + ("laboratories", lab), + ("templates", template), + ("inspections", inspection), + ]: + assert http.get(f"/api/v1/{kind}/{obj['id']}").status_code == 404 + assert http.get(f"/api/v1/{kind}").json()["data"]["total"] == 0 + assert ( + post( + http, + session, + "/laboratories", + {"college_id": college["id"], "name": "Foreign", "code": "foreign"}, + ).status_code + == 404 + ) + assert ( + post( + http, + session, + f"/laboratories/{lab['id']}/locations", + {"parent_id": None, "type": "room", "label": "Foreign"}, + ).status_code + == 404 + ) + assert ( + post( + http, + session, + f"/templates/{template['id']}/clone", + {"expected_version": 2, "reason": "Forbidden"}, + ).status_code + == 404 + ) + assert ( + post(http, session, "/inspections", inspection_body(lab, template, room)).status_code + == 404 + ) + + +def test_location_hierarchy_and_archived_parents(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + college, lab, room = organization(http, session) + _, _, other_room = organization(http, session) + path = f"/laboratories/{lab['id']}/locations" + for kind, parent in [ + ("room", room["id"]), + ("area", None), + ("shelf", None), + ("cabinet", None), + ]: + assert ( + post( + http, session, path, {"parent_id": parent, "type": kind, "label": "Bad"} + ).status_code + == 422 + ) + assert ( + post( + http, session, path, {"parent_id": other_room["id"], "type": "area", "label": "Bad"} + ).status_code + == 404 + ) + area = created( + http, session, path, {"parent_id": room["id"], "type": "area", "label": "A"}, "Location" + ) + for kind in ("shelf", "cabinet"): + for parent in (room, area): + created( + http, + session, + path, + {"parent_id": parent["id"], "type": kind, "label": kind}, + "Location", + ) + with database.begin() as connection: + connection.execute( + text("UPDATE locations SET status='archived' WHERE id=:id"), {"id": room["id"]} + ) + connection.execute( + text("UPDATE colleges SET status='archived' WHERE id=:id"), {"id": college["id"]} + ) + assert ( + post( + http, session, path, {"parent_id": room["id"], "type": "area", "label": "Bad"} + ).status_code + == 409 + ) + assert ( + post( + http, + session, + "/laboratories", + {"college_id": college["id"], "code": "bad", "name": "Bad"}, + ).status_code + == 409 + ) + with database.begin() as connection: + connection.execute( + text("UPDATE laboratories SET status='archived' WHERE id=:id"), {"id": lab["id"]} + ) + assert ( + post( + http, session, path, {"parent_id": None, "type": "room", "label": "Bad"} + ).status_code + == 409 + ) + + +@pytest.mark.parametrize( + "failure", + ["draft", "foreign_location", "archived_location", "archived_lab", "duplicate_location"], +) +def test_inspection_preconditions_leave_no_partial_writes(identity, database, failure): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + _, lab, room = organization(http, session) + template = ( + created(http, session, "/templates", template_body(), "Template") + if failure == "draft" + else published(http, session) + ) + if failure == "foreign_location": + _, _, room = organization(http, session) + if failure.startswith("archived"): + table, obj = ( + ("locations", room) if failure == "archived_location" else ("laboratories", lab) + ) + with database.begin() as connection: + connection.execute( + text(f"UPDATE {table} SET status='archived' WHERE id=:id"), {"id": obj["id"]} + ) + before = count_rows(database, tenant, "api_idempotency") + body = inspection_body(lab, template, room) + if failure == "duplicate_location": + body["location_ids"] *= 2 + response = post(http, session, "/inspections", body) + assert response.status_code == ( + 404 + if failure == "foreign_location" + else 422 + if failure == "duplicate_location" + else 409 + ), response.text + assert count_rows(database, tenant, "inspections") == 0 + assert count_rows(database, tenant, "inspection_items") == 0 + assert count_rows(database, tenant, "api_idempotency") == before + + +def test_inspection_100_boundary_and_oversize_are_atomic(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + _, lab, room = organization(http, session) + other_room = created( + http, + session, + f"/laboratories/{lab['id']}/locations", + {"parent_id": None, "type": "room", "label": "Second"}, + "Location", + ) + template = published(http, session, 100) + accepted = created( + http, session, "/inspections", inspection_body(lab, template, room), "Inspection" + ) + assert len(accepted["item_ids"]) == 100 + assert ( + post( + http, session, "/inspections", inspection_body(lab, template, room, other_room) + ).status_code + == 422 + ) + assert post(http, session, "/templates", template_body(101)).status_code == 422 + assert count_rows(database, tenant, "inspections") == 1 + assert count_rows(database, tenant, "inspection_items") == 100 + + +@pytest.mark.parametrize("stage", ["template_audit", "inspection_audit", "second_child"]) +def test_atomic_rollback_including_children_audit_and_idempotency(identity, database, stage): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + _, lab, room = organization(http, session) + template = published(http, session) + counts = { + table: count_rows(database, tenant, table) + for table in ( + "inspection_templates", + "template_items", + "inspections", + "inspection_items", + "audit_events", + "api_idempotency", + ) + } + if stage == "second_child": + # First child is inserted before the second hits an existing global child PK. + body = template_body() + body["items"][1]["id"] = template["items"][0]["id"] + response = post(http, session, "/templates", body) + assert response.status_code == 409 + else: + module = "templates" if stage == "template_audit" else "inspections" + path, body = ( + ("/templates", template_body()) + if stage == "template_audit" + else ("/inspections", inspection_body(lab, template, room)) + ) + with patch( + f"packages.persistence.{module}.append_audit", + side_effect=RuntimeError("private failure"), + ): + response = post(http, session, path, body) + assert response.status_code == 500 and "private failure" not in response.text + assert {table: count_rows(database, tenant, table) for table in counts} == counts + + +@pytest.mark.parametrize("kind", ["college", "template", "inspection"]) +def test_ten_concurrent_same_key_creates_once(identity, database, kind): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + if kind == "inspection": + _, lab, room = organization(http, session) + template = published(http, session) + body = inspection_body(lab, template, room) + else: + body = ( + {"name": "Concurrent", "code": "concurrent"} + if kind == "college" + else template_body() + ) + cookie = http.cookies.get("labsafe_session") + gate, key = Barrier(10), str(uuid4()) + path = {"college": "/colleges", "template": "/templates", "inspection": "/inspections"}[kind] + + def invoke(_): + with TestClient(app, base_url=ORIGIN) as http: + gate.wait(timeout=15) + return http.post( + "/api/v1" + path, + json=body, + headers={**headers(session, key), "Cookie": f"labsafe_session={cookie}"}, + ) + + with ThreadPoolExecutor(max_workers=10) as pool: + replies = list(pool.map(invoke, range(10))) + successes = [r for r in replies if r.status_code == 201] + assert successes and len({r.content for r in successes}) == 1, [r.text for r in replies] + assert all(r.status_code in {201, 409} for r in replies), [r.text for r in replies] + for reply in replies: + if reply.status_code == 409: + assert reply.json()["error"]["code"] == "REQUEST_IN_PROGRESS" + assert ( + scalar( + database, + "SELECT COUNT(*) FROM audit_events WHERE tenant_id=:tenant AND action=:action", + tenant, + action=kind + ".create", + ) + == 1 + ) + + +@pytest.mark.parametrize("mixed_sources", [False, True]) +def test_concurrent_clone_allocates_distinct_revisions_across_sessions( + identity, database, mixed_sources +): + tenant, _, app = identity + sessions = [] + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + template = published(http, session) + sources = [template] * 4 + if mixed_sources: + child = post( + http, + session, + f"/templates/{template['id']}/clone", + {"expected_version": 2, "reason": "Create second clone source"}, + ) + assert child.status_code == 201 + sources = [template, child.json()["data"]] * 2 + for _ in range(4): + session = login(http, tenant).json() + sessions.append((session, http.cookies.get("labsafe_session"))) + gate = Barrier(4) + + def invoke(index): + session, cookie = sessions[index] + with TestClient(app, base_url=ORIGIN) as http: + gate.wait(timeout=15) + return http.post( + f"/api/v1/templates/{sources[index]['id']}/clone", + json={"expected_version": sources[index]["version"], "reason": "Concurrent clone"}, + headers={**headers(session), "Cookie": f"labsafe_session={cookie}"}, + ) + + with ThreadPoolExecutor(max_workers=4) as pool: + replies = list(pool.map(invoke, range(4))) + assert all(r.status_code == 201 for r in replies), [r.text for r in replies] + first = 3 if mixed_sources else 2 + assert sorted(r.json()["data"]["revision"] for r in replies) == list(range(first, first + 4)) + assert count_rows(database, tenant, "inspection_templates") == first + 3 + + +def test_revoke_capture_blocks_successful_key_replay(identity): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as target: + session = login(admin, tenant).json() + _, lab, room = organization(admin, session) + template = published(admin, session) + user = create_user(admin, session) + assert ( + role_write( + admin, session, user["id"], command(role="inspector", lab=lab["id"]) + ).status_code + == 200 + ) + actor, key = login(target, tenant, username="target").json(), str(uuid4()) + body = inspection_body(lab, template, room) + original = post(target, actor, "/inspections", body, key=key) + assert original.status_code == 201 + assert post(target, actor, "/inspections", body, key=key).content == original.content + assert ( + role_write( + admin, session, user["id"], command(2, "inspector", lab["id"]), revoke=True + ).status_code + == 200 + ) + assert post(target, actor, "/inspections", body, key=key).status_code == 401 + actor = login(target, tenant, username="target").json() + assert post(target, actor, "/inspections", body, key=key).status_code == 404 + + +def test_redis_failure_prevents_foundation_write(identity, database): + tenant, service, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + with patch.object(service.limits.redis, "eval", side_effect=ConnectionError("offline")): + assert post(http, session, "/colleges", {"name": "N", "code": "C"}).status_code == 503 + assert count_rows(database, tenant, "colleges") == 0 + assert count_rows(database, tenant, "api_idempotency") == 0 + + +def test_concurrent_authentication_keeps_user_lock_shared(identity): + tenant, service, app = identity + tokens = [] + with TestClient(app, base_url=ORIGIN) as http: + for _ in range(4): + assert login(http, tenant).status_code == 200 + tokens.append(http.cookies.get("labsafe_session")) + gate = Barrier(4) + + def synchronized_principal(*args): + principal = load_principal(*args) + gate.wait(timeout=15) # All four transactions now hold users S concurrently. + return principal + + def authenticate(token): + with service.transaction() as connection: + return service.sessions.authenticate(connection, token).principal.user_id + + with patch("packages.persistence.security.load_principal", side_effect=synchronized_principal): + with ThreadPoolExecutor(max_workers=4) as pool: + assert list(pool.map(authenticate, tokens)) == [tenant["admin_id"]] * 4 + + +def test_replay_does_not_rerun_template_publication_precondition(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as target: + session = login(admin, tenant).json() + _, lab, room = organization(admin, session) + template = published(admin, session) + user = create_user(admin, session) + assert ( + role_write( + admin, session, user["id"], command(role="inspector", lab=lab["id"]) + ).status_code + == 200 + ) + actor, key = login(target, tenant, username="target").json(), str(uuid4()) + body = inspection_body(lab, template, room) + original = post(target, actor, "/inspections", body, key=key) + assert original.status_code == 201 + # Simulate a future retirement command; this batch has no retirement API. + with database.begin() as connection: + connection.execute( + text("UPDATE inspection_templates SET status='retired' WHERE id=:id"), + {"id": template["id"]}, + ) + assert post(target, actor, "/inspections", body, key=key).content == original.content + assert post(target, actor, "/inspections", body).status_code == 404 + + +def test_pagination_snapshot_and_tie_order(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + colleges = [ + created(http, session, "/colleges", {"name": "College", "code": str(i)}, "College") + for i in range(3) + ] + with database.begin() as connection: + connection.execute( + text( + "UPDATE colleges SET created_at='2026-01-01 00:00:00' WHERE tenant_id=:tenant" + ), + {"tenant": tenant["tenant_id"]}, + ) + page = http.get("/api/v1/colleges?page=2&page_size=1").json()["data"] + assert page["total"] == 3 + assert page["items"][0]["id"] == sorted(c["id"] for c in colleges)[1] + inserted = False + + def insert_after_count(connection, cursor, statement, parameters, context, executemany): + nonlocal inserted + if not inserted and statement.startswith("SELECT COUNT(*) FROM colleges WHERE"): + inserted = True + with database.begin() as other: + other.execute( + text( + "INSERT INTO colleges (id,tenant_id,code,name) " + "VALUES (:id,:tenant,'concurrent','Concurrent fixture')" + ), + {"id": str(uuid4()), "tenant": tenant["tenant_id"]}, + ) + + event.listen(database, "after_cursor_execute", insert_after_count) + try: + response = http.get("/api/v1/colleges") + assert response.status_code == 200, response.text + assert response.json()["data"]["total"] == len(response.json()["data"]["items"]) == 3 + assert inserted + finally: + event.remove(database, "after_cursor_execute", insert_after_count) + assert http.get("/api/v1/colleges").json()["data"]["total"] == 4 + + +def test_archived_college_is_not_a_public_projection(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as target: + session = login(admin, tenant).json() + college, lab, _ = organization(admin, session) + draft = created(admin, session, "/templates", template_body(), "Template") + user = create_user(admin, session) + assert ( + role_write( + admin, session, user["id"], command(role="viewer", lab=lab["id"]) + ).status_code + == 200 + ) + assert login(target, tenant, username="target").status_code == 200 + with database.begin() as connection: + connection.execute( + text("UPDATE colleges SET status='archived' WHERE id=:id"), {"id": college["id"]} + ) + assert target.get("/api/v1/colleges").json()["data"]["total"] == 0 + assert target.get(f"/api/v1/colleges/{college['id']}").status_code == 404 + assert admin.get(f"/api/v1/colleges/{college['id']}").status_code == 200 + assert target.get(f"/api/v1/templates/{draft['id']}").status_code == 404 + + +def test_preflight_is_revalidated_before_foundation_commit(identity, database): + tenant, service, app = identity + active = 0 + transaction = service.transaction + + @contextmanager + def tracked_transaction(): + nonlocal active + with transaction() as connection: + active += 1 + try: + yield connection + finally: + active -= 1 + + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + original_limit = service.limits.user + + def revoke_after_preflight(principal, *, write): + original_limit(principal, write=write) + assert active == 0 + with database.begin() as connection: + connection.execute( + text("UPDATE sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + + with ( + patch.object(service, "transaction", side_effect=tracked_transaction), + patch.object(service.limits, "user", side_effect=revoke_after_preflight), + ): + result = post(http, session, "/colleges", {"code": "denied", "name": "Denied"}) + assert result.status_code == 401, result.text + assert count_rows(database, tenant, "api_idempotency") == 0 + assert count_rows(database, tenant, "colleges") == 0 diff --git a/tests/persistence/test_identity_mysql.py b/tests/persistence/test_identity_mysql.py new file mode 100644 index 0000000..f30b325 --- /dev/null +++ b/tests/persistence/test_identity_mysql.py @@ -0,0 +1,513 @@ +"""Real HTTP -> application -> MySQL/Redis tests on disposable local/CI services.""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from datetime import timedelta +from pathlib import Path +from threading import Barrier +from unittest.mock import patch +from uuid import uuid4 + +import pytest +import yaml +from fastapi.testclient import TestClient +from jsonschema import Draft202012Validator, FormatChecker +from redis.exceptions import ConnectionError +from sqlalchemy import text + +from packages.domain.security import ServiceError +from packages.persistence.security import ( + begin_idempotency, + complete_idempotency, + revoke_user_sessions, + utc_now, +) +from tests.persistence.test_security_mysql import PASSWORD, create_tenant + +ORIGIN = "https://labsafe.test" + + +def login(http, tenant, *, username="admin", password=PASSWORD): + return http.post( + "/api/v1/auth/login", + json={"tenant_code": tenant["code"], "username": username, "password": password}, + headers={"Origin": ORIGIN}, + ) + + +def headers(session, key=None): + return { + "Origin": ORIGIN, + "X-CSRF-Token": session["data"]["csrf_token"], + "Idempotency-Key": key or str(uuid4()), + } + + +def user_body(name="newuser"): + return {"username": name, "display_name": "Test user", "initial_password": PASSWORD} + + +def assert_schema(response, schema_name): + spec = yaml.safe_load( + (Path(__file__).resolve().parents[2] / "contracts/public-api-v1.yaml").read_text( + encoding="utf-8" + ) + ) + schema = {"$ref": f"#/components/schemas/{schema_name}", "components": spec["components"]} + Draft202012Validator(schema, format_checker=FormatChecker()).validate(response.json()["data"]) + assert set(response.json()) == {"data", "request_id"} + + +def test_http_login_me_create_replay_disable_logout(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant) + assert session.status_code == 200, session.text + assert_schema(session, "Session") + assert_schema(http.get("/api/v1/me"), "Session") + h = headers(session.json()) + response = http.post("/api/v1/users", json=user_body(), headers=h) + assert response.status_code == 201, response.text + assert_schema(response, "User") + user = response.json()["data"] + replay = http.post("/api/v1/users", json=user_body(), headers=h) + assert replay.json() == response.json() and replay.status_code == 201 + changed = http.post("/api/v1/users", json=user_body("different"), headers=h) + assert changed.json()["error"]["code"] == "IDEMPOTENCY_CONFLICT" + assert_schema(http.get("/api/v1/users"), "UserPage") + assert_schema(http.get(f"/api/v1/users/{user['id']}"), "User") + disable = {"expected_version": 1, "reason": "test disable"} + disabled = http.post( + f"/api/v1/users/{user['id']}/disable", json=disable, headers=headers(session.json()) + ) + assert disabled.status_code == 200, disabled.text + assert disabled.json()["data"]["version"] == 2 + with database.begin() as connection: + count = connection.scalar( + text( + "SELECT COUNT(*) FROM audit_events WHERE tenant_id=:tenant " + "AND action='user.create'" + ), + {"tenant": tenant["tenant_id"]}, + ) + assert count == 1 + records = connection.execute( + text("SELECT request_hash,response FROM api_idempotency WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ).all() + assert PASSWORD not in repr(records) + out = http.post("/api/v1/auth/logout", json={}, headers=headers(session.json())) + assert out.status_code == 200, out.text + assert_schema(out, "Ack") + assert http.get("/api/v1/me").status_code == 401 + + +def test_last_admin_cross_tenant_and_csrf_protection(identity, database): + tenant, _, app = identity + other = create_tenant(database, "foreign") + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + assert http.get(f"/api/v1/users/{other['admin_id']}").status_code == 404 + own = http.post( + f"/api/v1/users/{tenant['admin_id']}/disable", + json={"expected_version": 1, "reason": "last admin"}, + headers=headers(session), + ) + assert own.status_code == 409, own.text + assert own.json()["error"]["code"] == "STATE_CONFLICT" + h = headers(session) + h["X-CSRF-Token"] = "x" * 43 + assert http.post("/api/v1/users", json=user_body(), headers=h).status_code == 403 + assert http.get("/api/v1/me").status_code == 200 + + +def test_login_rate_limits_exact_failed_attempts_and_success_release(identity): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + for _ in range(6): + assert login(http, tenant).status_code == 200 + for _ in range(5): + failed = login(http, tenant, password="wrong-test-password") + assert failed.status_code == 401, failed.text + blocked = login(http, tenant) + assert blocked.status_code == 429 + assert 1 <= int(blocked.headers["Retry-After"]) <= 900 + + +def test_disabled_sessions_and_revoked_replay_are_denied(identity, database): + tenant, service, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + h = headers(session) + assert http.post("/api/v1/users", json=user_body(), headers=h).status_code == 201 + with database.begin() as connection: + revoke_user_sessions(connection, tenant["tenant_id"], tenant["admin_id"]) + assert http.post("/api/v1/users", json=user_body(), headers=h).status_code == 401 + assert http.get("/api/v1/me").status_code == 401 + assert service.environment == "test" + + +def test_user_change_audit_failure_rolls_back_business_and_idempotency(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + with patch( + "packages.persistence.users.append_audit", side_effect=RuntimeError("audit failed") + ): + result = http.post("/api/v1/users", json=user_body(), headers=headers(session)) + assert result.status_code == 500 + with database.begin() as connection: + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM users WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == 1 + ) + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM api_idempotency WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == 0 + ) + + +def test_same_key_ten_requests_create_one_user_and_audit(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + cookie = http.cookies.get("labsafe_session") + gate = Barrier(10) + h = {**headers(session), "Cookie": f"labsafe_session={cookie}"} + + def send(): + with TestClient(app, base_url=ORIGIN) as http: + gate.wait(timeout=10) + return http.post("/api/v1/users", json=user_body(), headers=h) + + with ThreadPoolExecutor(max_workers=10) as pool: + replies = list(pool.map(lambda _: send(), range(10))) + successes = [r for r in replies if r.status_code == 201] + assert successes, [r.text for r in replies] + assert all(r.status_code in {201, 409} for r in replies), [r.text for r in replies] + assert len({r.text for r in successes}) == 1 + with TestClient(app, base_url=ORIGIN) as http: + assert http.post("/api/v1/users", json=user_body(), headers=h).json() == successes[0].json() + with database.begin() as connection: + for query, count in [ + ("SELECT COUNT(*) FROM users WHERE tenant_id=:tenant", 2), + ( + "SELECT COUNT(*) FROM audit_events WHERE tenant_id=:tenant " + "AND action='user.create'", + 1, + ), + ("SELECT COUNT(*) FROM api_idempotency WHERE tenant_id=:tenant", 1), + ]: + assert connection.scalar(text(query), {"tenant": tenant["tenant_id"]}) == count + + +def test_same_version_race_has_single_success(identity): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = http.post("/api/v1/users", json=user_body(), headers=headers(session)).json()["data"] + cookie = http.cookies.get("labsafe_session") + gate = Barrier(2) + + def disable(): + with TestClient(app, base_url=ORIGIN) as http: + gate.wait(timeout=10) + return http.post( + f"/api/v1/users/{user['id']}/disable", + json={"expected_version": 1, "reason": "race"}, + headers={**headers(session), "Cookie": f"labsafe_session={cookie}"}, + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + replies = list(pool.map(lambda _: disable(), range(2))) + assert sorted(r.status_code for r in replies) == [200, 409], [r.text for r in replies] + assert ( + next(r for r in replies if r.status_code == 409).json()["error"]["code"] + == "VERSION_CONFLICT" + ) + + +def test_identity_transactions_enforce_repeatable_read_and_redis_stays_outside(identity): + tenant, service, app = identity + active = 0 + transaction = service.transaction + original_eval = service.limits.redis.eval + + @contextmanager + def tracked_transaction(): + nonlocal active + with transaction() as connection: + active += 1 + try: + isolation = connection.scalar(text("SELECT @@SESSION.transaction_isolation")) + assert isolation == "REPEATABLE-READ" + yield connection + finally: + active -= 1 + + def eval_outside(*args, **kwargs): + assert active == 0 + return original_eval(*args, **kwargs) + + with ( + patch.object(service, "transaction", side_effect=tracked_transaction), + patch.object(service.limits.redis, "eval", side_effect=eval_outside), + TestClient(app, base_url=ORIGIN) as http, + ): + session = login(http, tenant).json() + assert http.get("/api/v1/users").status_code == 200 + response = http.post("/api/v1/users", json=user_body(), headers=headers(session)) + assert response.status_code == 201 + + +def test_login_release_failure_never_delivers_a_cookie(identity, database): + tenant, service, app = identity + failure = ServiceError("DEPENDENCY_UNAVAILABLE", 503, "Rate limiter unavailable") + with TestClient(app, base_url=ORIGIN) as http: + with patch.object(service.limits, "login_succeeded", side_effect=failure): + response = login(http, tenant) + assert response.status_code == 503 and "set-cookie" not in response.headers + assert http.cookies.get("labsafe_session") is None + # MySQL committed before Redis released the slot; no false cross-system atomicity. + with database.begin() as connection: + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM sessions WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == 1 + ) + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM audit_events " + "WHERE tenant_id=:tenant AND action='session.login'" + ), + {"tenant": tenant["tenant_id"]}, + ) + == 1 + ) + + +def test_live_pending_and_expired_owner_cannot_commit(database): + tenant = create_tenant(database, "lease") + args = dict( + tenant_id=tenant["tenant_id"], + actor_id=tenant["admin_id"], + method="POST", + path="/api/v1/users", + key="lease-test-key", + request_hash="a" * 64, + expires_at=utc_now() + timedelta(hours=24), + ) + with database.begin() as connection: + claim = begin_idempotency(connection, **args, lease_owner=str(uuid4())) + with database.begin() as connection: + with pytest.raises(ServiceError) as error: + begin_idempotency(connection, **args, lease_owner=str(uuid4())) + assert error.value.code == "REQUEST_IN_PROGRESS" + connection.execute( + text("UPDATE api_idempotency SET lease_until=:past WHERE id=:id"), + {"past": utc_now() - timedelta(seconds=1), "id": claim.record_id}, + ) + with pytest.raises(ServiceError): + complete_idempotency(connection, claim, status=200, response={}) + takeover = begin_idempotency(connection, **args, lease_owner=str(uuid4())) + with pytest.raises(ServiceError): + complete_idempotency(connection, claim, status=200, response={}) + complete_idempotency(connection, takeover, status=200, response={"data": {"ok": True}}) + + +def test_real_dependencies_ready_and_redis_failure_has_no_write_side_effects(identity, database): + tenant, service, app = identity + with TestClient(app, base_url=ORIGIN) as http: + assert http.get("/ready").status_code == 200 + session = login(http, tenant).json() + with ( + patch.object(service.limits.redis, "eval", side_effect=ConnectionError("private URL")), + patch.object(service.limits.redis, "ping", side_effect=ConnectionError("private URL")), + ): + assert http.get("/ready").status_code == 503 + assert http.get("/health").status_code == 200 + assert login(http, tenant).status_code == 503 + assert http.get("/api/v1/me").status_code == 200 + response = http.post("/api/v1/users", json=user_body(), headers=headers(session)) + assert response.status_code == 503 and "private" not in response.text + assert http.get("/ready").status_code == 200 + with database.begin() as connection: + for table, expected in [("users", 1), ("sessions", 1), ("api_idempotency", 0)]: + assert ( + connection.scalar( + text(f"SELECT COUNT(*) FROM {table} WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == expected + ) + + +@pytest.mark.parametrize("revocation", ["session", "permission"]) +def test_preflight_does_not_authorize_after_session_or_permission_revocation( + identity, database, revocation +): + tenant, service, app = identity + original_limit = service.limits.user + + def revoke_after_preflight(principal, *, write): + original_limit(principal, write=write) + # This commits between preflight and the command, with no open app transaction. + with database.begin() as connection: + if revocation == "session": + revoke_user_sessions(connection, tenant["tenant_id"], tenant["admin_id"]) + else: + connection.execute( + text("DELETE FROM user_roles WHERE tenant_id=:tenant AND user_id=:user"), + {"tenant": tenant["tenant_id"], "user": tenant["admin_id"]}, + ) + + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + with patch.object(service.limits, "user", side_effect=revoke_after_preflight): + response = http.post("/api/v1/users", json=user_body(), headers=headers(session)) + assert response.status_code == (401 if revocation == "session" else 403), response.text + with database.begin() as connection: + for table, expected in [("users", 1), ("api_idempotency", 0)]: + assert ( + connection.scalar( + text(f"SELECT COUNT(*) FROM {table} WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == expected + ) + + +def test_unprivileged_user_cannot_use_administrative_routes(identity): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + admin = login(http, tenant).json() + assert ( + http.post("/api/v1/users", json=user_body(), headers=headers(admin)).status_code == 201 + ) + session = login(http, tenant, username="newuser").json() + assert http.get("/api/v1/me").status_code == 200 + assert http.get("/api/v1/users").status_code == 403 + assert http.get(f"/api/v1/users/{tenant['admin_id']}").status_code == 403 + assert ( + http.post( + "/api/v1/users", json=user_body("notallowed"), headers=headers(session) + ).status_code + == 403 + ) + assert ( + http.post( + f"/api/v1/users/{tenant['admin_id']}/disable", + json={"expected_version": 1, "reason": "not allowed"}, + headers=headers(session), + ).status_code + == 403 + ) + + +def test_normalized_username_collision_and_password_policy(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + collision = http.post( + "/api/v1/users", json=user_body("ADMIN"), headers=headers(session) + ) + assert collision.status_code == 409, collision.text + invalid = http.post( + "/api/v1/users", + json={**user_body(), "initial_password": "x" * 129}, + headers=headers(session), + ) + assert invalid.status_code == 422 and "x" * 129 not in invalid.text + with database.begin() as connection: + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM users WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == 1 + ) + assert ( + connection.scalar( + text("SELECT COUNT(*) FROM api_idempotency WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + == 0 + ) + + +@pytest.mark.parametrize("disable_self", [True, False]) +def test_two_admin_race_keeps_one_active_admin(identity, database, disable_self): + tenant, service, app = identity + with TestClient(app, base_url=ORIGIN) as http: + first = login(http, tenant).json() + first_token = http.cookies.get("labsafe_session") + second = http.post( + "/api/v1/users", json=user_body("admin2"), headers=headers(first) + ).json()["data"] + # Fixture-only grant: this batch deliberately exposes no role mutation API. + with database.begin() as connection: + connection.execute( + text( + "INSERT INTO user_roles (id,tenant_id,user_id,role_id,scope_kind) " + "SELECT :id,:tenant,:user,id,'tenant' FROM roles WHERE code='safety_admin'" + ), + {"id": str(uuid4()), "tenant": tenant["tenant_id"], "user": second["id"]}, + ) + second_session = login(http, tenant, username="admin2").json() + second_token = http.cookies.get("labsafe_session") + gate = Barrier(2) + original_limit = service.limits.user + + def synchronized_preflight(principal, *, write): + original_limit(principal, write=write) + if write: + gate.wait(timeout=10) + + actors = [ + (tenant["admin_id"], first, first_token), + (second["id"], second_session, second_token), + ] + + def disable(index): + actor, session, cookie = actors[index] + target = actor if disable_self else actors[1 - index][0] + with TestClient(app, base_url=ORIGIN) as http: + return http.post( + f"/api/v1/users/{target}/disable", + json={"expected_version": 1, "reason": "admin race"}, + headers={**headers(session), "Cookie": f"labsafe_session={cookie}"}, + ) + + with ( + patch.object(service.limits, "user", side_effect=synchronized_preflight), + ThreadPoolExecutor(max_workers=2) as pool, + ): + replies = list(pool.map(disable, range(2))) + expected = [200, 409] if disable_self else [200, 401] + assert sorted(r.status_code for r in replies) == expected, [r.text for r in replies] + if disable_self: + assert ( + next(r for r in replies if r.status_code == 409).json()["error"]["code"] + == "STATE_CONFLICT" + ) + with database.begin() as connection: + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM users u JOIN user_roles ur ON ur.user_id=u.id " + "AND ur.tenant_id=u.tenant_id JOIN roles r ON r.id=ur.role_id " + "WHERE u.tenant_id=:tenant AND u.status='active' AND r.code='safety_admin'" + ), + {"tenant": tenant["tenant_id"]}, + ) + == 1 + ) diff --git a/tests/persistence/test_item_queries_mysql.py b/tests/persistence/test_item_queries_mysql.py new file mode 100644 index 0000000..9a09675 --- /dev/null +++ b/tests/persistence/test_item_queries_mysql.py @@ -0,0 +1,442 @@ +"""Read-only item APIs on disposable MySQL/Redis; synthetic history is labeled.""" + +from contextlib import contextmanager +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from redis.exceptions import ConnectionError +from sqlalchemy import event, text + +from packages.domain.workflow import ItemStatus +from packages.persistence.inspection_items import PUBLIC_FIELDS +from tests.persistence.factories import insert +from tests.persistence.test_foundations_mysql import ( + count_rows, + created, + inspection_body, + organization, + published, +) +from tests.persistence.test_identity_mysql import ORIGIN, assert_schema, login +from tests.persistence.test_roles_mysql import command, create_user, role_write +from tests.persistence.test_security_mysql import create_tenant + + +def inspection(http, session, *, count=2): + _, lab, room = organization(http, session) + template = published(http, session, count) + result = created( + http, session, "/inspections", inspection_body(lab, template, room), "Inspection" + ) + return result, lab, room, template + + +def paths(value): + return ( + f"/api/v1/inspections/{value['id']}/items", + f"/api/v1/inspection-items/{value['item_ids'][0]}", + ) + + +def business_snapshot(database, tenant): + tables = ( + "inspections", + "inspection_items", + "audit_events", + "inference_runs", + "outbox_events", + "api_idempotency", + "task_runs", + "fact_revisions", + "rule_evaluations", + "review_actions", + "findings", + ) + with database.begin() as connection: + return { + table: [ + dict(row) + for row in connection.execute( + text(f"SELECT * FROM {table} WHERE tenant_id=:tenant ORDER BY id"), + {"tenant": tenant["tenant_id"]}, + ).mappings() + ] + for table in tables + } + + +def test_real_creation_list_detail_contracts_and_no_business_side_effects(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, lab, room, _ = inspection(http, session) + before = business_snapshot(database, tenant) + listed = http.get(paths(value)[0]) + assert listed.status_code == 200, listed.text + assert_schema(listed, "InspectionItemPage") + page = listed.json()["data"] + assert (page["total"], page["page"], page["page_size"]) == (2, 1, 20) + assert {i["id"] for i in page["items"]} == set(value["item_ids"]) + for row in page["items"]: + response = http.get(f"/api/v1/inspection-items/{row['id']}") + assert response.status_code == 200, response.text + assert_schema(response, "InspectionItem") + assert response.json()["data"] == row + assert set(row) == set(PUBLIC_FIELDS) | {"allowed_actions"} + assert row["inspection_id"] == value["id"] + assert row["laboratory_id"] == lab["id"] and row["location_id"] == room["id"] + assert row["status"] == "draft" and row["submission_revision"] == 0 + assert row["current_run_id"] is row["current_fact_revision_id"] is None + assert row["review_outcome"] is None and row["allowed_actions"] == [] + assert row["created_at"].endswith("Z") and row["updated_at"].endswith("Z") + assert response.headers["cache-control"] == "no-store" + assert business_snapshot(database, tenant) == before + + +@pytest.mark.parametrize( + "role", + ["safety_admin", "inspector", "viewer", "remediator", "lab_manager", "rule_expert", "none"], +) +def test_scope_is_checked_before_count_or_data_and_filters_do_not_expand_scope( + identity, database, role +): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as reader: + session = login(admin, tenant).json() + value, lab, _, _ = inspection(admin, session) + other, other_lab, _, _ = inspection(admin, session) + user = create_user(admin, session) + if role != "none": + granted = role_write( + admin, + session, + user["id"], + command( + role=role, lab=None if role in {"safety_admin", "rule_expert"} else lab["id"] + ), + ) + assert granted.status_code == 200, granted.text + assert login(reader, tenant, username="target").status_code == 200 + statements = [] + + def observe(connection, cursor, statement, parameters, context, executemany): + if "FROM inspection_items item" in statement: + statements.append(statement) + + event.listen(database, "before_cursor_execute", observe) + try: + listed = reader.get(paths(value)[0]) + if role in {"rule_expert", "none"}: + assert listed.status_code == 404 + assert statements == [] # Parent authority fails before count/page queries. + else: + assert listed.status_code == 200, listed.text + assert listed.json()["data"]["total"] == 2 + assert len(listed.json()["data"]["items"]) == 2 + finally: + event.remove(database, "before_cursor_execute", observe) + allowed = role not in {"rule_expert", "none"} + assert reader.get(paths(value)[1]).status_code == (200 if allowed else 404) + for path in paths(other): + assert reader.get(path).status_code == (200 if role == "safety_admin" else 404) + filtered = reader.get(paths(value)[0] + f"?laboratory_id={other_lab['id']}") + if role == "safety_admin": + assert filtered.status_code == 200 + assert filtered.json()["data"]["items"] == [] and filtered.json()["data"]["total"] == 0 + else: + assert filtered.status_code == 404 + matching = reader.get(paths(value)[0] + f"?laboratory_id={lab['id']}") + assert matching.status_code == (200 if allowed else 404) + if allowed: + assert matching.json()["data"]["total"] == 2 + assert reader.get(paths(value)[0] + f"?laboratory_id={uuid4()}").status_code == 404 + + +def test_missing_foreign_and_empty_parent_are_distinguished(identity, database): + tenant, _, app = identity + foreign = create_tenant(database, "itemsforeign") + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, foreign).json() + other, _, _, _ = inspection(http, session) + session = login(http, tenant).json() + value, lab, _, template = inspection(http, session) + for path in ( + *paths(other), + f"/api/v1/inspections/{uuid4()}/items", + f"/api/v1/inspection-items/{uuid4()}", + ): + assert http.get(path).status_code == 404 + # Legacy/DDL-only empty parent; the current create API always creates children. + with database.begin() as connection: + empty_id = insert( + connection, + "inspections", + tenant_id=tenant["tenant_id"], + laboratory_id=lab["id"], + template_id=template["id"], + inspector_id=tenant["admin_id"], + status="draft", + ) + result = http.get(f"/api/v1/inspections/{empty_id}/items") + assert result.status_code == 200 + assert result.json()["data"] == {"items": [], "total": 0, "page": 1, "page_size": 20} + assert http.get(paths(value)[0]).json()["data"]["total"] == 2 + + +@pytest.mark.parametrize("status", list(ItemStatus)) +def test_all_item_states_remain_readable_without_advertising_unimplemented_commands( + identity, database, status +): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + # Synthetic status snapshots test projection, not an implemented lifecycle. + with database.begin() as connection: + connection.execute( + text("UPDATE inspection_items SET status=:status WHERE inspection_id=:id"), + {"status": str(status), "id": value["id"]}, + ) + for path in paths(value): + response = http.get(path) + assert response.status_code == 200, response.text + data = response.json()["data"] + for row in data.get("items", [data]): + assert row["status"] == status and row["allowed_actions"] == [] + + +@pytest.mark.parametrize("parent_status", ["completed", "cancelled"]) +def test_closed_parents_remain_readable_with_safe_actions(identity, database, parent_status): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + with database.begin() as connection: + connection.execute( + text("UPDATE inspections SET status=:status WHERE id=:id"), + {"status": parent_status, "id": value["id"]}, + ) + assert http.get(paths(value)[0]).json()["data"]["total"] == 2 + assert http.get(paths(value)[1]).json()["data"]["allowed_actions"] == [] + + +def test_non_null_history_pointers_are_projected_without_exposing_internal_fields( + identity, database +): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, lab, _, _ = inspection(http, session) + target = value["item_ids"][0] + with database.begin() as connection: + + def add(table, **values): + return insert(connection, table, tenant_id=tenant["tenant_id"], **values) + + # Synthetic legal FK history, not model execution or a completed review command. + dictionary = add("dictionary_versions") + model = add("model_versions", dictionary_version_id=dictionary) + rules = add("rule_bundles") + run = add( + "inference_runs", + item_id=target, + laboratory_id=lab["id"], + model_bundle_id=model, + dictionary_version_id=dictionary, + rule_bundle_id=rules, + ) + fact = add("fact_revisions", item_id=target, run_id=run) + evaluation = add( + "rule_evaluations", + item_id=target, + run_id=run, + fact_revision_id=fact, + rule_bundle_id=rules, + ) + connection.execute( + text( + "UPDATE inspection_items SET current_run_id=:run," + "current_fact_revision_id=:fact,current_evaluation_id=:evaluation," + "submission_revision=2,version=3,status='completed',review_outcome='no_issue' " + "WHERE id=:id" + ), + {"id": target, "run": run, "fact": fact, "evaluation": evaluation}, + ) + result = http.get(paths(value)[1]) + assert result.status_code == 200, result.text + assert_schema(result, "InspectionItem") + row = result.json()["data"] + assert (row["current_run_id"], row["current_fact_revision_id"]) == (run, fact) + assert (row["submission_revision"], row["version"], row["review_outcome"]) == ( + 2, + 3, + "no_issue", + ) + assert set(row) == set(PUBLIC_FIELDS) | {"allowed_actions"} + assert row["allowed_actions"] == [] + assert row in http.get(paths(value)[0]).json()["data"]["items"] + + +def test_full_capacity_paging_tie_order_and_empty_late_page(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session, count=100) + with database.begin() as connection: + connection.execute( + text( + "UPDATE inspection_items SET created_at='2026-01-01 00:00:00' " + "WHERE inspection_id=:id" + ), + {"id": value["id"]}, + ) + url = paths(value)[0] + page = http.get(url + "?page_size=100") + assert page.status_code == 200, page.text + assert_schema(page, "InspectionItemPage") + expected = sorted(value["item_ids"], reverse=True) + assert [i["id"] for i in page.json()["data"]["items"]] == expected + assert page.json()["data"]["total"] == 100 + middle = http.get(url + "?page=3&page_size=20").json()["data"] + assert [i["id"] for i in middle["items"]] == expected[40:60] + late = http.get(url + "?page=6&page_size=20").json()["data"] + assert late == {"items": [], "total": 100, "page": 6, "page_size": 20} + + +@pytest.mark.parametrize("mutation", ["update", "insert"]) +def test_count_and_item_projection_share_one_snapshot_during_concurrent_change( + identity, database, mutation +): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, lab, _, template = inspection(http, session) + extra_room = created( + http, + session, + f"/laboratories/{lab['id']}/locations", + {"parent_id": None, "type": "room", "label": "Extra room"}, + "Location", + ) + changed = False + reads = [] + + def change_after_count(connection, cursor, statement, parameters, context, executemany): + nonlocal changed + if any( + table in statement + for table in ("FROM inspection_items", "FROM inspections", "FROM laboratories") + ): + reads.append(statement) + if not changed and statement.startswith("SELECT COUNT(*) FROM inspection_items item"): + changed = True + with database.begin() as other: + if mutation == "update": + other.execute( + text( + "UPDATE inspection_items SET status='uploaded',version=2 " + "WHERE inspection_id=:id" + ), + {"id": value["id"]}, + ) + else: + # Synthetic concurrent insert tests isolation, not a public add-item API. + insert( + other, + "inspection_items", + tenant_id=tenant["tenant_id"], + inspection_id=value["id"], + laboratory_id=lab["id"], + template_item_id=template["items"][0]["id"], + location_id=extra_room["id"], + ) + + event.listen(database, "after_cursor_execute", change_after_count) + try: + response = http.get(paths(value)[0] + f"?laboratory_id={lab['id']}") + assert response.status_code == 200, response.text + data = response.json()["data"] + assert changed and data["total"] == len(data["items"]) == 2 + assert {(i["status"], i["version"]) for i in data["items"]} == {("draft", 1)} + assert any("FROM laboratories" in statement for statement in reads) + assert all("FOR SHARE" not in s and "FOR UPDATE" not in s for s in reads) + finally: + event.remove(database, "after_cursor_execute", change_after_count) + if mutation == "update": + assert http.get(paths(value)[1]).json()["data"]["status"] == "uploaded" + else: + data = http.get(paths(value)[0]).json()["data"] + assert data["total"] == len(data["items"]) == 3 + + +@pytest.mark.parametrize("index", [0, 1]) +def test_session_revoked_after_preflight_blocks_read_and_rate_limit_is_outside_transaction( + identity, database, index +): + tenant, service, app = identity + active = 0 + transaction = service.transaction + + @contextmanager + def tracked_transaction(): + nonlocal active + with transaction() as connection: + active += 1 + try: + yield connection + finally: + active -= 1 + + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + original_limit = service.limits.user + + def revoke(principal, *, write): + assert active == 0 and write is False + original_limit(principal, write=write) + with database.begin() as connection: + connection.execute( + text("UPDATE sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + + with ( + patch.object(service, "transaction", side_effect=tracked_transaction), + patch.object(service.limits, "user", side_effect=revoke), + ): + assert http.get(paths(value)[index]).status_code == 401 + + +def test_read_fallback_unauthenticated_and_revoked_role_boundaries(identity, database): + tenant, service, app = identity + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as reader: + session = login(admin, tenant).json() + value, lab, _, _ = inspection(admin, session) + for path in paths(value): + assert reader.get(path).status_code == 401 + user = create_user(admin, session) + assert ( + role_write( + admin, session, user["id"], command(role="viewer", lab=lab["id"]) + ).status_code + == 200 + ) + assert login(reader, tenant, username="target").status_code == 200 + with patch.object(service.limits.redis, "eval", side_effect=ConnectionError): + for path in paths(value): + assert reader.get(path).status_code == 200 + assert ( + role_write( + admin, session, user["id"], command(2, "viewer", lab["id"]), revoke=True + ).status_code + == 200 + ) + for path in paths(value): + assert reader.get(path).status_code == 401 + assert login(reader, tenant, username="target").status_code == 200 + for path in paths(value): + assert reader.get(path).status_code == 404 + assert count_rows(database, tenant, "inference_runs") == 0 diff --git a/tests/persistence/test_roles_mysql.py b/tests/persistence/test_roles_mysql.py new file mode 100644 index 0000000..76c00ce --- /dev/null +++ b/tests/persistence/test_roles_mysql.py @@ -0,0 +1,485 @@ +"""Role HTTP/transaction tests on the disposable MySQL and Redis instances.""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from tests.persistence.test_identity_mysql import ORIGIN, assert_schema, headers, login, user_body +from tests.persistence.test_security_mysql import create_tenant + + +def lab_fixture(database, tenant): + college, lab = str(uuid4()), str(uuid4()) + with database.begin() as connection: + connection.execute( + text( + "INSERT INTO colleges (id,tenant_id,code,name) " + "VALUES (:id,:tenant,:id,'Synthetic college')" + ), + {"id": college, "tenant": tenant["tenant_id"]}, + ) + connection.execute( + text( + "INSERT INTO laboratories (id,tenant_id,college_id,code,name) " + "VALUES (:id,:tenant,:college,:id,'Synthetic laboratory')" + ), + {"id": lab, "tenant": tenant["tenant_id"], "college": college}, + ) + return lab + + +def command(version=1, role="rule_expert", lab=None): + return { + "expected_version": version, + "role": role, + "scope_kind": "tenant" if lab is None else "laboratory", + "laboratory_id": lab, + } + + +def create_user(http, session, name="target"): + response = http.post("/api/v1/users", json=user_body(name), headers=headers(session)) + assert response.status_code == 201, response.text + return response.json()["data"] + + +def role_write(http, session, user, body, *, revoke=False, key=None): + suffix = "revoke-role" if revoke else "roles" + return http.post(f"/api/v1/users/{user}/{suffix}", json=body, headers=headers(session, key)) + + +def scalar(database, query, tenant, **params): + with database.begin() as connection: + return connection.scalar(text(query), {"tenant": tenant["tenant_id"], **params}) + + +@pytest.mark.parametrize( + "role", ["safety_admin", "rule_expert", "lab_manager", "inspector", "remediator", "viewer"] +) +def test_all_roles_grant_list_revoke_and_invalidate_all_sessions(identity, database, role): + tenant, _, app = identity + lab = None if role in {"safety_admin", "rule_expert"} else lab_fixture(database, tenant) + with ( + TestClient(app, base_url=ORIGIN) as admin, + TestClient(app, base_url=ORIGIN) as target, + TestClient(app, base_url=ORIGIN) as other_session, + ): + session = login(admin, tenant).json() + user = create_user(admin, session) + assert login(target, tenant, username="target").status_code == 200 + assert login(other_session, tenant, username="target").status_code == 200 + before = scalar( + database, + "SELECT session_epoch FROM users WHERE tenant_id=:tenant AND id=:user", + tenant, + user=user["id"], + ) + response = role_write(admin, session, user["id"], command(role=role, lab=lab)) + assert response.status_code == 200, response.text + assert_schema(response, "User") + assert response.json()["data"]["version"] == 2 + assert target.get("/api/v1/me").status_code == 401 + assert other_session.get("/api/v1/me").status_code == 401 + listed = admin.get(f"/api/v1/users/{user['id']}/roles") + assert listed.status_code == 200 + assert_schema(listed, "RoleAssignmentPage") + assignment = listed.json()["data"]["items"][0] + assert listed.json()["data"]["total"] == 1 + assert assignment["role"] == role and assignment["laboratory_id"] == lab + new_session = login(target, tenant, username="target") + assert new_session.status_code == 200 and new_session.json()["data"]["permissions"] + revoked = role_write(admin, session, user["id"], command(2, role, lab), revoke=True) + assert revoked.status_code == 200 and revoked.json()["data"]["version"] == 3 + assert target.get("/api/v1/me").status_code == 401 + assert admin.get(f"/api/v1/users/{user['id']}/roles").json()["data"]["total"] == 0 + assert login(target, tenant, username="target").json()["data"]["permissions"] == [] + assert ( + scalar( + database, + "SELECT session_epoch FROM users WHERE tenant_id=:tenant AND id=:user", + tenant, + user=user["id"], + ) + == before + 2 + ) + assert ( + scalar( + database, + "SELECT COUNT(*) FROM audit_events WHERE tenant_id=:tenant " + "AND action IN ('role.grant','role.revoke') AND resource_id=:id", + tenant, + id=assignment["id"], + ) + == 2 + ) + + +def test_role_idempotency_duplicate_and_stale_version(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = create_user(http, session) + key = str(uuid4()) + original = role_write(http, session, user["id"], command(), key=key) + replay = role_write(http, session, user["id"], command(), key=key) + assert original.status_code == replay.status_code == 200 + assert original.content == replay.content + different = role_write(http, session, user["id"], command(1, "safety_admin"), key=key) + assert different.json()["error"]["code"] == "IDEMPOTENCY_CONFLICT" + duplicate = role_write(http, session, user["id"], command(2)) + assert duplicate.json()["error"]["code"] == "STATE_CONFLICT" + stale = role_write(http, session, user["id"], command(), revoke=True) + assert stale.json()["error"]["code"] == "VERSION_CONFLICT" + revoke_key = str(uuid4()) + revoked = role_write(http, session, user["id"], command(2), revoke=True, key=revoke_key) + replay = role_write(http, session, user["id"], command(2), revoke=True, key=revoke_key) + assert ( + revoked.status_code == replay.status_code == 200 and revoked.content == replay.content + ) + absent = role_write(http, session, user["id"], command(3), revoke=True) + assert absent.status_code == 404 + assert ( + scalar( + database, + "SELECT COUNT(*) FROM audit_events " + "WHERE tenant_id=:tenant AND action LIKE 'role.%'", + tenant, + ) + == 2 + ) + + +@pytest.mark.parametrize( + "role,scope,has_lab", + [ + ("safety_admin", "laboratory", True), + ("rule_expert", "laboratory", True), + ("inspector", "tenant", False), + ("lab_manager", "tenant", False), + ("remediator", "tenant", False), + ("viewer", "tenant", False), + ("viewer", "laboratory", False), + ("safety_admin", "tenant", True), + ], +) +def test_illegal_role_scope_is_rejected_atomically(identity, database, role, scope, has_lab): + tenant, _, app = identity + lab = lab_fixture(database, tenant) if has_lab else None + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = create_user(http, session) + for revoke in (False, True): + body = {**command(role=role, lab=lab), "scope_kind": scope} + result = role_write(http, session, user["id"], body, revoke=revoke) + assert result.status_code == 422, result.text + assert http.get(f"/api/v1/users/{user['id']}").json()["data"]["version"] == 1 + assert ( + scalar(database, "SELECT COUNT(*) FROM api_idempotency WHERE tenant_id=:tenant", tenant) + == 1 + ) + + +def test_role_tenant_visibility_and_non_admin_access(identity, database): + tenant, _, app = identity + foreign = create_tenant(database, "roleforeign") + foreign_lab = lab_fixture(database, foreign) + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = create_user(http, session) + assert http.get(f"/api/v1/users/{foreign['admin_id']}/roles").status_code == 404 + assert ( + http.get(f"/api/v1/users/{user['id']}/roles?laboratory_id={foreign_lab}").status_code + == 404 + ) + for revoke in (False, True): + assert ( + role_write(http, session, foreign["admin_id"], command(), revoke=revoke).status_code + == 404 + ) + assert ( + role_write( + http, + session, + user["id"], + command(role="viewer", lab=foreign_lab), + revoke=revoke, + ).status_code + == 404 + ) + basic = login(http, tenant, username="target").json() + assert http.get(f"/api/v1/users/{user['id']}/roles").status_code == 403 + for revoke in (False, True): + assert role_write(http, basic, user["id"], command(), revoke=revoke).status_code == 403 + + +def test_role_pagination_filter_and_exact_scope_revoke(identity, database): + tenant, _, app = identity + labs = [lab_fixture(database, tenant) for _ in range(2)] + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = create_user(http, session) + for version, lab in enumerate(labs, 1): + assert ( + role_write(http, session, user["id"], command(version, "viewer", lab)).status_code + == 200 + ) + assert role_write(http, session, user["id"], command(3)).status_code == 200 + path = f"/api/v1/users/{user['id']}/roles" + full = http.get(path).json()["data"] + assert full["total"] == 3 + assert full["items"] == sorted( + full["items"], key=lambda x: (x["created_at"], x["id"]), reverse=True + ) + pages = [ + http.get(path + f"?page={page}&page_size=1").json()["data"] for page in range(1, 5) + ] + assert [p["items"][0] for p in pages[:3]] == full["items"] and pages[3]["items"] == [] + assert all(p["total"] == 3 for p in pages) + filtered = http.get(path + f"?laboratory_id={labs[0]}") + assert_schema(filtered, "RoleAssignmentPage") + assert filtered.json()["data"]["total"] == 1 + assert ( + role_write( + http, session, user["id"], command(4, "viewer", labs[0]), revoke=True + ).status_code + == 200 + ) + remaining = http.get(path).json()["data"]["items"] + assert {(a["role"], a["laboratory_id"]) for a in remaining} == { + ("viewer", labs[1]), + ("rule_expert", None), + } + + +def test_disable_and_archive_deny_grants_but_allow_security_cleanup(identity, database): + tenant, _, app = identity + lab = lab_fixture(database, tenant) + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = create_user(http, session) + assert role_write(http, session, user["id"], command(1, "viewer", lab)).status_code == 200 + disabled = http.post( + f"/api/v1/users/{user['id']}/disable", + json={"expected_version": 2, "reason": "test"}, + headers=headers(session), + ) + assert disabled.status_code == 200 + assert role_write(http, session, user["id"], command(3)).status_code == 409 + with database.begin() as connection: + connection.execute( + text( + "UPDATE laboratories SET status='archived' WHERE tenant_id=:tenant AND id=:lab" + ), + {"tenant": tenant["tenant_id"], "lab": lab}, + ) + other = create_user(http, session, "other") + assert role_write(http, session, other["id"], command(1, "viewer", lab)).status_code == 409 + revoked = role_write(http, session, user["id"], command(3, "viewer", lab), revoke=True) + assert revoked.status_code == 200 and revoked.json()["data"]["status"] == "disabled" + + +@pytest.mark.parametrize("revoke", [False, True]) +def test_role_audit_failure_rolls_back_role_version_epoch_sessions_and_idempotency( + identity, database, revoke +): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http, TestClient(app, base_url=ORIGIN) as target: + session = login(http, tenant).json() + user = create_user(http, session) + version = 1 + if revoke: + assert role_write(http, session, user["id"], command()).status_code == 200 + version = 2 + assert login(target, tenant, username="target").status_code == 200 + before = scalar( + database, + "SELECT session_epoch FROM users WHERE tenant_id=:tenant AND id=:user", + tenant, + user=user["id"], + ) + with patch( + "packages.persistence.roles.append_audit", + side_effect=RuntimeError("private audit failure"), + ): + result = role_write(http, session, user["id"], command(version), revoke=revoke) + assert result.status_code == 500 and "private" not in result.text + assert target.get("/api/v1/me").status_code == 200 + assert http.get(f"/api/v1/users/{user['id']}").json()["data"]["version"] == version + assert ( + scalar( + database, + "SELECT session_epoch FROM users WHERE tenant_id=:tenant AND id=:user", + tenant, + user=user["id"], + ) + == before + ) + assert ( + scalar(database, "SELECT COUNT(*) FROM api_idempotency WHERE tenant_id=:tenant", tenant) + == version + ) + assert http.get(f"/api/v1/users/{user['id']}/roles").json()["data"]["total"] == int(revoke) + + +@pytest.mark.parametrize("same_key", [True, False]) +def test_concurrent_role_grants_commit_once(identity, database, same_key): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + user = create_user(http, session) + cookie = http.cookies.get("labsafe_session") + count = 10 if same_key else 2 + gate, key = Barrier(count), str(uuid4()) + + def grant(_): + with TestClient(app, base_url=ORIGIN) as http: + gate.wait(timeout=10) + return http.post( + f"/api/v1/users/{user['id']}/roles", + json=command(), + headers={ + **headers(session, key if same_key else None), + "Cookie": f"labsafe_session={cookie}", + }, + ) + + with ThreadPoolExecutor(max_workers=count) as pool: + replies = list(pool.map(grant, range(count))) + successful = [r for r in replies if r.status_code == 200] + assert successful and len({r.content for r in successful}) == 1, [r.text for r in replies] + if same_key: + assert all(r.status_code in {200, 409} for r in replies) + else: + assert sorted(r.status_code for r in replies) == [200, 409] + assert ( + next(r for r in replies if r.status_code == 409).json()["error"]["code"] + == "VERSION_CONFLICT" + ) + assert ( + scalar( + database, + "SELECT COUNT(*) FROM user_roles WHERE tenant_id=:tenant AND user_id=:user", + tenant, + user=user["id"], + ) + == 1 + ) + assert ( + scalar( + database, + "SELECT COUNT(*) FROM audit_events WHERE tenant_id=:tenant AND action='role.grant'", + tenant, + ) + == 1 + ) + assert ( + scalar(database, "SELECT COUNT(*) FROM api_idempotency WHERE tenant_id=:tenant", tenant) + == 2 + ) + + +@pytest.mark.parametrize("self_revoke", [True, False]) +def test_two_admin_role_revocation_race_keeps_one_admin(identity, database, self_revoke): + tenant, service, app = identity + with TestClient(app, base_url=ORIGIN) as http: + first = login(http, tenant).json() + first_token = http.cookies.get("labsafe_session") + second_user = create_user(http, first, "admin2") + assert ( + role_write(http, first, second_user["id"], command(role="safety_admin")).status_code + == 200 + ) + second = login(http, tenant, username="admin2").json() + second_token = http.cookies.get("labsafe_session") + actors = [ + (tenant["admin_id"], 1, first, first_token), + (second_user["id"], 2, second, second_token), + ] + gate = Barrier(2) + original_limit = service.limits.user + + def synchronize(principal, *, write): + original_limit(principal, write=write) + if write: + gate.wait(timeout=10) + + def revoke(index): + _, _, session, token = actors[index] + user, version, _, _ = actors[index if self_revoke else 1 - index] + with TestClient(app, base_url=ORIGIN) as http: + return http.post( + f"/api/v1/users/{user}/revoke-role", + json=command(version, "safety_admin"), + headers={**headers(session), "Cookie": f"labsafe_session={token}"}, + ) + + with ( + patch.object(service.limits, "user", side_effect=synchronize), + ThreadPoolExecutor(max_workers=2) as pool, + ): + replies = list(pool.map(revoke, range(2))) + assert sorted(r.status_code for r in replies) == ([200, 409] if self_revoke else [200, 401]), [ + r.text for r in replies + ] + assert ( + scalar( + database, + "SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id=ur.user_id " + "AND u.tenant_id=ur.tenant_id JOIN roles r ON r.id=ur.role_id " + "WHERE ur.tenant_id=:tenant AND u.status='active' AND r.code='safety_admin'", + tenant, + ) + == 1 + ) + + +def test_self_grant_invalidates_old_session_and_blocks_cached_replay(identity): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + key = str(uuid4()) + response = role_write(http, session, tenant["admin_id"], command(), key=key) + assert response.status_code == 200 + assert role_write(http, session, tenant["admin_id"], command(), key=key).status_code == 401 + renewed = login(http, tenant).json() + replay = role_write(http, renewed, tenant["admin_id"], command(), key=key) + assert replay.status_code == 200 and replay.content == response.content + + +def test_role_capacity_rejects_grant_without_invalidating_existing_session(identity, database): + tenant, _, app = identity + with TestClient(app, base_url=ORIGIN) as http, TestClient(app, base_url=ORIGIN) as target: + session = login(http, tenant).json() + user = create_user(http, session) + labs = [lab_fixture(database, tenant) for _ in range(51)] + # Fifty inspector assignments project to exactly 200 distinct action/lab pairs. + with database.begin() as connection: + role = connection.scalar(text("SELECT id FROM roles WHERE code='inspector'")) + connection.execute( + text( + "INSERT INTO user_roles " + "(id,tenant_id,user_id,role_id,scope_kind,laboratory_id) " + "VALUES (:id,:tenant,:user,:role,'laboratory',:lab)" + ), + [ + { + "id": str(uuid4()), + "tenant": tenant["tenant_id"], + "user": user["id"], + "role": role, + "lab": lab, + } + for lab in labs[:50] + ], + ) + assert len(login(target, tenant, username="target").json()["data"]["permissions"]) == 200 + rejected = role_write(http, session, user["id"], command(1, "inspector", labs[-1])) + assert rejected.status_code == 409, rejected.text + assert target.get("/api/v1/me").status_code == 200 + assert http.get(f"/api/v1/users/{user['id']}/roles").json()["data"]["total"] == 50 + assert http.get(f"/api/v1/users/{user['id']}").json()["data"]["version"] == 1 diff --git a/tests/persistence/test_upload_completion_mysql.py b/tests/persistence/test_upload_completion_mysql.py new file mode 100644 index 0000000..7e99b15 --- /dev/null +++ b/tests/persistence/test_upload_completion_mysql.py @@ -0,0 +1,623 @@ +"""Real MySQL/Redis acceptance; object responses remain explicitly stubbed, not live S3.""" + +import json +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from datetime import timedelta +from pathlib import Path +from threading import Barrier +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from botocore.stub import Stubber +from fastapi.testclient import TestClient +from jsonschema import Draft202012Validator, FormatChecker +from redis.exceptions import ConnectionError +from sqlalchemy import event, text + +from packages.persistence.security import utc_now +from packages.storage.s3 import BUCKET, ObjectVersion +from tests.domain.test_uploads import upload_body +from tests.persistence import test_upload_grants_mysql as grants +from tests.persistence.factories import insert +from tests.persistence.test_foundations_mysql import count_rows, post +from tests.persistence.test_identity_mysql import ORIGIN, assert_schema, login +from tests.persistence.test_item_queries_mysql import inspection +from tests.persistence.test_roles_mysql import command, create_user, role_write +from tests.persistence.test_security_mysql import create_tenant + +upload_api = grants.upload_api +counts = grants.counts + + +@pytest.fixture +def completion_case(upload_api): + tenant, identity, app, storage = upload_api + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, lab, _, _ = inspection(http, session) + request = upload_body(value["item_ids"][0]) + granted = post(http, session, "/uploads", request) + assert granted.status_code == 201, granted.text + grant = granted.json()["data"] + yield SimpleNamespace( + tenant=tenant, + identity=identity, + app=app, + storage=storage, + http=http, + session=session, + inspection=value, + lab=lab, + request=request, + grant=grant, + body={"upload_id": grant["upload_id"], "sha256": request["sha256"]}, + ) + + +@contextmanager +def head(case, **overrides): + with Stubber(case.storage.internal) as stub: + stub.add_response( + "head_object", + { + "VersionId": "pinned-v1", + "ContentLength": 100, + "ContentType": "image/png", + **overrides, + }, + {"Bucket": BUCKET, "Key": case.grant["object_key"]}, + ) + yield + stub.assert_no_pending_responses() + + +def complete(case, *, key=None): + return post(case.http, case.session, "/uploads/complete", case.body, key=key) + + +def pinned(case): + return ObjectVersion(case.grant["object_key"], "pinned-v1", 100, "image/png") + + +def update(database, statement, **params): + with database.begin() as connection: + connection.execute(text(statement), params) + + +def rows(database, case, table): + with database.connect() as connection: + return [ + dict(row) + for row in connection.execute( + text(f"SELECT * FROM {table} WHERE tenant_id=:tenant"), + {"tenant": case.tenant["tenant_id"]}, + ).mappings() + ] + + +def decoded(value): + return json.loads(value) if isinstance(value, str) else value + + +def test_completion_registers_one_image_task_dispatch_and_exact_public_projection( + completion_case, database +): + case = completion_case + before = counts(database, case.tenant) + with head(case, Metadata={"sha256": "untrusted-head-metadata"}): + response = complete(case) + assert response.status_code == 202, response.text + assert_schema(response, "Image") + image = response.json()["data"] + assert image["status"] == "validating" and image["version"] == 1 + assert ( + image["owner_id"] == case.request["owner_id"] and image["owner_type"] == "inspection_item" + ) + assert image["analysis_sha256"] is image["width"] is image["height"] is None + after = counts(database, case.tenant) + assert {name: after[name] - before[name] for name in before} == { + "uploads": 0, + "asset_images": 1, + "task_runs": 1, + "outbox_events": 1, + "api_idempotency": 1, + "audit_events": 1, + } + stored = rows(database, case, "asset_images")[0] + assert stored["original_key"] == case.grant["object_key"] + assert stored["original_object_version"] == "pinned-v1" + assert stored["original_sha256"] == case.body["sha256"] + assert stored["analysis_key"] is stored["analysis_object_version"] is None + upload = rows(database, case, "uploads")[0] + assert upload["status"] == "validating" and upload["version"] == 2 + task = rows(database, case, "task_runs")[0] + assert task["logical_key"] == f"validate_image:{image['id']}" + assert ( + task["state"] == "ready" + and task["attempt"] == task["fencing_token"] == task["replay_generation"] == 0 + ) + assert task["dispatch_sequence"] == 1 and task["last_dispatched_at"] is None + assert task["lease_owner"] is task["lease_until"] is None + outbox = rows(database, case, "outbox_events")[0] + assert outbox["event_type"] == "TaskDispatch" and outbox["state"] == "pending" + assert outbox["aggregate_type"] == "task_run" and outbox["aggregate_id"] == task["id"] + message = decoded(outbox["payload"]) + spec = json.loads( + (Path(__file__).resolve().parents[2] / "contracts/task-message-v1.json").read_text() + ) + Draft202012Validator(spec, format_checker=FormatChecker()).validate(message) + assert message["task_id"] == task["id"] and message["resource_id"] == image["id"] + assert message["trace_id"] == response.json()["request_id"].replace("-", "") + assert ( + message["payload"] + == decoded(task["payload"]) + == {"upload_id": case.body["upload_id"], "image_id": image["id"]} + ) + before_get = counts(database, case.tenant) + with patch.object( + case.storage, "inspect_staging", side_effect=AssertionError("GET must not HEAD") + ): + queried = case.http.get(f"/api/v1/images/{image['id']}") + assert queried.status_code == 200 and queried.json()["data"] == image + assert queried.headers["cache-control"] == "no-store" + assert counts(database, case.tenant) == before_get + item = case.http.get(f"/api/v1/inspection-items/{case.request['owner_id']}").json()["data"] + assert item["status"] == "draft" and item["version"] == 1 and item["allowed_actions"] == [] + + +def test_same_key_replays_original_but_new_key_reads_existing_image_without_head( + completion_case, database +): + case, key = completion_case, str(uuid4()) + with head(case): + first = complete(case, key=key) + assert first.status_code == 202 + image_id = first.json()["data"]["id"] + # Synthetic later history: there is no worker in this batch. + update( + database, "UPDATE asset_images SET status='rejected',version=2 WHERE id=:id", id=image_id + ) + update( + database, + "UPDATE uploads SET status='rejected',expires_at=:expired WHERE id=:id", + id=case.body["upload_id"], + expired=utc_now() - timedelta(hours=1), + ) + update( + database, + "UPDATE inspection_items SET status='completed' WHERE id=:id", + id=case.request["owner_id"], + ) + before = counts(database, case.tenant) + with patch.object( + case.storage, "inspect_staging", side_effect=AssertionError("Never inspect latest again") + ): + replay = complete(case, key=key) + assert replay.status_code == 202 and replay.content == first.content + again = complete(case) + assert again.status_code == 202 and again.json()["data"]["id"] == image_id + assert again.json()["data"]["status"] == "rejected" + conflict = post( + case.http, case.session, "/uploads/complete", {**case.body, "sha256": "b" * 64}, key=key + ) + assert ( + conflict.status_code == 409 + and conflict.json()["error"]["code"] == "IDEMPOTENCY_CONFLICT" + ) + mismatch = post( + case.http, case.session, "/uploads/complete", {**case.body, "sha256": "b" * 64} + ) + assert mismatch.status_code == 422 + after = counts(database, case.tenant) + assert after == {**before, "api_idempotency": before["api_idempotency"] + 1} + assert rows(database, case, "asset_images")[0]["original_object_version"] == "pinned-v1" + + +@pytest.mark.parametrize( + "metadata,expected", + [ + ({"ContentLength": 99}, 422), + ({"ContentType": "image/jpeg"}, 422), + ({"ContentLength": 15728641}, 413), + ({"VersionId": "null"}, 503), + ], +) +def test_actual_head_metadata_failure_has_no_database_side_effects( + completion_case, database, metadata, expected +): + case = completion_case + before = counts(database, case.tenant) + with head(case, **metadata): + response = complete(case) + assert response.status_code == expected, response.text + assert counts(database, case.tenant) == before + assert rows(database, case, "uploads")[0]["status"] == "granted" + + +@pytest.mark.parametrize("code,expected", [("NoSuchKey", 404), ("AccessDenied", 503)]) +def test_head_errors_do_not_leave_a_persisted_pending_claim( + completion_case, database, code, expected +): + case = completion_case + before = counts(database, case.tenant) + with Stubber(case.storage.internal) as stub: + stub.add_client_error( + "head_object", + code, + "private provider message", + expected_params={"Bucket": BUCKET, "Key": case.grant["object_key"]}, + ) + response = complete(case) + assert response.status_code == expected and "private" not in response.text + assert counts(database, case.tenant) == before + + +@pytest.mark.parametrize("failure", ["hash", "expired", "closed", "missing"]) +def test_completion_rejects_before_head(completion_case, database, failure): + case = completion_case + if failure == "hash": + case.body["sha256"] = "b" * 64 + elif failure == "expired": + update( + database, + "UPDATE uploads SET expires_at=:expired WHERE id=:id", + expired=utc_now(), + id=case.body["upload_id"], + ) + elif failure == "closed": + update( + database, + "UPDATE inspections SET status='cancelled' WHERE id=:id", + id=case.inspection["id"], + ) + else: + case.body["upload_id"] = str(uuid4()) + before = counts(database, case.tenant) + with patch.object( + case.storage, "inspect_staging", side_effect=AssertionError("Unauthorized HEAD") + ): + response = complete(case) + assert ( + response.status_code + == {"hash": 422, "expired": 409, "closed": 409, "missing": 404}[failure] + ) + assert counts(database, case.tenant) == before + + +@pytest.mark.parametrize( + "change,expected", [("session", 401), ("owner", 409), ("expiry", 409), ("metadata", 409)] +) +def test_head_is_outside_transactions_and_commit_revalidates_every_mutable_input( + completion_case, database, change, expected +): + case, active = completion_case, 0 + transaction = case.identity.transaction + before = counts(database, case.tenant) + + @contextmanager + def tracked(): + nonlocal active + with transaction() as connection: + active += 1 + try: + yield connection + finally: + active -= 1 + + def inspect(tenant_id, upload_id): + assert active == 0 + assert counts(database, case.tenant) == before # Preparation claim rolled back. + if change == "session": + update( + database, + "UPDATE sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE tenant_id=:id", + id=tenant_id, + ) + elif change == "owner": + update( + database, + "UPDATE inspection_items SET status='queued' WHERE id=:id", + id=case.request["owner_id"], + ) + elif change == "expiry": + update( + database, + "UPDATE uploads SET expires_at=:expired WHERE id=:id", + id=upload_id, + expired=utc_now(), + ) + else: + update( + database, + "UPDATE uploads SET size_bytes=101,version=version+1 WHERE id=:id", + id=upload_id, + ) + return pinned(case) + + with ( + patch.object(case.identity, "transaction", side_effect=tracked), + patch.object(case.storage, "inspect_staging", side_effect=inspect), + ): + response = complete(case) + assert response.status_code == expected, response.text + assert counts(database, case.tenant) == before + + +@pytest.mark.parametrize( + "table", ["asset_images", "task_runs", "outbox_events", "audit_events", "api_idempotency"] +) +def test_any_commit_write_failure_rolls_back_image_task_dispatch_audit_and_response( + completion_case, database, table +): + case = completion_case + before = counts(database, case.tenant) + injected = [] + + def fail(connection, cursor, statement, params, context, many): + prefix = ( + "UPDATE api_idempotency SET state='completed'" + if table == "api_idempotency" + else f"INSERT INTO {table} " + ) + if statement.startswith(prefix): + injected.append(table) + raise RuntimeError("private injected commit failure") + + event.listen(database, "before_cursor_execute", fail) + try: + with head(case): + response = complete(case) + finally: + event.remove(database, "before_cursor_execute", fail) + assert response.status_code == 500 and "private" not in response.text + assert injected == [table] + assert counts(database, case.tenant) == before + assert rows(database, case, "uploads")[0]["status"] == "granted" + + +@pytest.mark.parametrize("same_key", [True, False]) +def test_four_session_completion_race_pins_one_version_and_creates_one_validation_intent( + completion_case, database, same_key +): + case = completion_case + clients = [TestClient(case.app, base_url=ORIGIN) for _ in range(4)] + sessions = [login(client, case.tenant).json() for client in clients] + key, barrier = str(uuid4()), Barrier(4) + before = counts(database, case.tenant) + + def inspect(tenant, upload): + index = barrier.wait(timeout=20) + return ObjectVersion(case.grant["object_key"], f"concurrent-v{index}", 100, "image/png") + + def run(index): + return post( + clients[index], + sessions[index], + "/uploads/complete", + case.body, + key=key if same_key else str(uuid4()), + ) + + try: + with ( + patch.object(case.storage, "inspect_staging", side_effect=inspect), + ThreadPoolExecutor(max_workers=4) as pool, + ): + responses = list(pool.map(run, range(4))) + assert [r.status_code for r in responses] == [202] * 4, [r.text for r in responses] + assert len({r.json()["data"]["id"] for r in responses}) == 1 + if same_key: + assert len({r.content for r in responses}) == 1 + after = counts(database, case.tenant) + assert {name: after[name] - before[name] for name in before} == { + "uploads": 0, + "asset_images": 1, + "task_runs": 1, + "outbox_events": 1, + "api_idempotency": 1 if same_key else 4, + "audit_events": 1, + } + assert rows(database, case, "asset_images")[0]["original_object_version"].startswith( + "concurrent-v" + ) + finally: + for client in clients: + client.close() + + +def test_upload_and_image_scope_are_checked_even_for_cached_completion(completion_case, database): + case, key = completion_case, str(uuid4()) + with head(case): + response = complete(case, key=key) + image_id = response.json()["data"]["id"] + foreign = create_tenant(database, "imageforeign") + with TestClient(case.app, base_url=ORIGIN) as http: + actor = login(http, foreign).json() + assert post(http, actor, "/uploads/complete", case.body).status_code == 404 + assert http.get(f"/api/v1/images/{image_id}").status_code == 404 + # Revoke the issuing user's role without relying only on cached session state. + update( + database, + "DELETE FROM user_roles WHERE tenant_id=:tenant AND user_id=:user", + tenant=case.tenant["tenant_id"], + user=case.tenant["admin_id"], + ) + with patch.object( + case.storage, "inspect_staging", side_effect=AssertionError("No unauthorized object access") + ): + assert complete(case, key=key).status_code == 404 + assert case.http.get(f"/api/v1/images/{image_id}").status_code == 404 + + +def test_other_lab_image_and_completion_are_hidden_before_any_object_access(completion_case): + case = completion_case + with head(case): + image = complete(case).json()["data"] + _, other_lab, _, _ = inspection(case.http, case.session) + user = create_user(case.http, case.session) + assert ( + role_write( + case.http, case.session, user["id"], command(role="inspector", lab=other_lab["id"]) + ).status_code + == 200 + ) + with TestClient(case.app, base_url=ORIGIN) as http: + actor = login(http, case.tenant, username="target").json() + with patch.object( + case.storage, "inspect_staging", side_effect=AssertionError("No cross-lab HEAD") + ): + assert post(http, actor, "/uploads/complete", case.body).status_code == 404 + assert http.get(f"/api/v1/images/{image['id']}").status_code == 404 + + +@pytest.mark.parametrize("role", ["viewer", "lab_manager", "inspector", "remediator"]) +def test_image_read_permission_is_not_capture_or_assignee_permission( + completion_case, database, role +): + case = completion_case + with head(case): + image = complete(case).json()["data"] + user = create_user(case.http, case.session) + assert ( + role_write( + case.http, case.session, user["id"], command(role=role, lab=case.lab["id"]) + ).status_code + == 200 + ) + with TestClient(case.app, base_url=ORIGIN) as http: + actor = login(http, case.tenant, username="target").json() + before = counts(database, case.tenant) + assert http.get(f"/api/v1/images/{image['id']}").json()["data"] == image + assert post(http, actor, "/uploads/complete", case.body).status_code == 403 + assert counts(database, case.tenant) == before + + +def test_image_reads_keep_bounded_fallback_but_completion_fails_closed(completion_case, database): + case = completion_case + with head(case): + image = complete(case).json()["data"] + before = counts(database, case.tenant) + with patch.object(case.identity.limits.redis, "eval", side_effect=ConnectionError("private")): + assert case.http.get(f"/api/v1/images/{image['id']}").status_code == 200 + assert complete(case).status_code == 503 + assert counts(database, case.tenant) == before + + def revoke(*args, **kwargs): + update( + database, + "UPDATE sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE tenant_id=:tenant", + tenant=case.tenant["tenant_id"], + ) + + with patch.object(case.identity.limits, "user", side_effect=revoke): + assert case.http.get(f"/api/v1/images/{image['id']}").status_code == 401 + assert counts(database, case.tenant) == before + + +@pytest.mark.parametrize("status", ["validating", "ready", "rejected", "deleted"]) +def test_image_status_projection_uses_persisted_metadata_not_synthetic_success( + completion_case, database, status +): + case = completion_case + with head(case): + image = complete(case).json()["data"] + # Explicit synthetic later state to test read projection, not worker/deletion completion. + update( + database, + "UPDATE asset_images SET status=:status WHERE id=:id", + status=status, + id=image["id"], + ) + response = case.http.get(f"/api/v1/images/{image['id']}") + assert response.status_code == 200 and response.json()["data"]["status"] == status + assert_schema(response, "Image") + if status == "deleted": + with patch.object( + case.storage, "inspect_staging", side_effect=AssertionError("No resurrection") + ): + assert complete(case).status_code == 404 + + +def test_remediation_completion_requires_current_assignee_even_with_completed_parent( + completion_case, database +): + case = completion_case + user = create_user(case.http, case.session) + assert ( + role_write( + case.http, case.session, user["id"], command(role="remediator", lab=case.lab["id"]) + ).status_code + == 200 + ) + with database.begin() as connection: + + def add(table, **values): + return insert(connection, table, tenant_id=case.tenant["tenant_id"], **values) + + # Legal FK synthetic history; remediation dispatch is not implemented in this batch. + dictionary = add("dictionary_versions") + model = add("model_versions", dictionary_version_id=dictionary) + rules = add("rule_bundles") + item_id = case.request["owner_id"] + run = add( + "inference_runs", + item_id=item_id, + laboratory_id=case.lab["id"], + model_bundle_id=model, + dictionary_version_id=dictionary, + rule_bundle_id=rules, + ) + fact = add("fact_revisions", item_id=item_id, run_id=run) + evaluation = add( + "rule_evaluations", + item_id=item_id, + run_id=run, + fact_revision_id=fact, + rule_bundle_id=rules, + ) + finding = add( + "findings", + item_id=item_id, + run_id=run, + fact_revision_id=fact, + rule_evaluation_id=evaluation, + status="dispatched", + ) + task = add( + "remediation_tasks", + finding_id=finding, + laboratory_id=case.lab["id"], + assignee_id=user["id"], + status="in_progress", + ) + connection.execute( + text("UPDATE inspections SET status='completed' WHERE id=:id"), + {"id": case.inspection["id"]}, + ) + with TestClient(case.app, base_url=ORIGIN) as http: + actor = login(http, case.tenant, username="target").json() + grant = post( + http, actor, "/uploads", upload_body(task, owner_type="remediation_task") + ).json()["data"] + body = {"upload_id": grant["upload_id"], "sha256": "a" * 64} + assert post(case.http, case.session, "/uploads/complete", body).status_code == 403 + with patch.object( + case.storage, + "inspect_staging", + return_value=ObjectVersion(grant["object_key"], "task-v1", 100, "image/png"), + ): + result = post(http, actor, "/uploads/complete", body) + assert result.status_code == 202, result.text + assert result.json()["data"]["owner_id"] == task + assert result.json()["data"]["owner_type"] == "remediation_task" + assert count_rows(database, case.tenant, "asset_images") == 1 + update( + database, + "UPDATE remediation_tasks SET assignee_id=:user WHERE id=:id", + user=case.tenant["admin_id"], + id=task, + ) + assert post(http, actor, "/uploads/complete", body).status_code == 403 diff --git a/tests/persistence/test_upload_grants_mysql.py b/tests/persistence/test_upload_grants_mysql.py new file mode 100644 index 0000000..c81deca --- /dev/null +++ b/tests/persistence/test_upload_grants_mysql.py @@ -0,0 +1,456 @@ +"""Real MySQL/Redis + local SDK signing, without a live MinIO server.""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from datetime import timedelta +from threading import Barrier +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from redis.exceptions import ConnectionError +from sqlalchemy import text + +from packages.application.uploads import UploadGrantApplication +from packages.persistence.security import utc_now +from packages.storage.s3 import S3Storage +from tests.business.test_s3_storage import settings +from tests.domain.test_uploads import upload_body +from tests.persistence.factories import insert +from tests.persistence.test_foundations_mysql import count_rows, created, inspection_body, post +from tests.persistence.test_identity_mysql import ORIGIN, assert_schema, login +from tests.persistence.test_item_queries_mysql import inspection +from tests.persistence.test_roles_mysql import command, create_user, role_write +from tests.persistence.test_security_mysql import create_tenant + + +@pytest.fixture +def upload_api(identity): + tenant, identity_service, app = identity + storage = S3Storage(settings()) + app.state.uploads = UploadGrantApplication(identity_service, storage) + with patch( + "botocore.httpsession.URLLib3Session.send", + side_effect=AssertionError("Unexpected S3 network I/O"), + ): + yield tenant, identity_service, app, storage + storage.close() + + +def counts(database, tenant): + return { + table: count_rows(database, tenant, table) + for table in ( + "uploads", + "asset_images", + "task_runs", + "outbox_events", + "api_idempotency", + "audit_events", + ) + } + + +def test_grant_persists_exact_contract_and_replay_is_byte_stable_without_workflow_side_effects( + upload_api, database +): + tenant, _, app, storage = upload_api + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, lab, _, _ = inspection(http, session) + body, key = upload_body(value["item_ids"][0]), str(uuid4()) + before = counts(database, tenant) + result = post(http, session, "/uploads", body, key=key) + assert result.status_code == 201, result.text + assert_schema(result, "UploadGrant") + grant = result.json()["data"] + with patch.object( + storage, "presign_put", side_effect=AssertionError("Replay must not re-sign") + ): + assert post(http, session, "/uploads", body, key=key).content == result.content + assert ( + post( + http, session, "/uploads", {**body, "filename": "different.png"}, key=key + ).status_code + == 409 + ) + assert grant["object_key"] == f"staging/{tenant['tenant_id']}/{grant['upload_id']}" + assert "filename" not in grant and grant["max_bytes"] == 15728640 + assert parse_qs(urlsplit(grant["put_url"]).query)["X-Amz-SignedHeaders"] == [ + "content-type;host" + ] + after = counts(database, tenant) + assert {k: after[k] - before[k] for k in before} == { + "uploads": 1, + "asset_images": 0, + "task_runs": 0, + "outbox_events": 0, + "api_idempotency": 1, + "audit_events": 1, + } + with database.begin() as connection: + row = ( + connection.execute( + text("SELECT * FROM uploads WHERE id=:id"), {"id": grant["upload_id"]} + ) + .mappings() + .one() + ) + assert row["laboratory_id"] == lab["id"] and row["requested_by"] == tenant["admin_id"] + assert ( + row["inspection_item_id"] == body["owner_id"] and row["remediation_task_id"] is None + ) + assert row["status"] == "granted" and row["version"] == 1 + assert ( + row["expected_sha256"] == body["sha256"] and row["size_bytes"] == body["size_bytes"] + ) + assert row["captured_at"].isoformat() == "2026-10-01T02:00:00.123000" + audit = connection.execute( + text("SELECT changes FROM audit_events WHERE resource_id=:id"), + {"id": grant["upload_id"]}, + ).scalar_one() + assert "Signature" not in str(audit) and "synthetic" not in str(audit) + item = http.get(f"/api/v1/inspection-items/{body['owner_id']}").json()["data"] + assert item["status"] == "draft" and item["version"] == 1 and item["allowed_actions"] == [] + + +@pytest.mark.parametrize( + "role", ["creator_inspector", "other_inspector", "viewer", "remediator", "rule_expert", "none"] +) +def test_grant_requires_creator_capture_and_scope(upload_api, database, role): + tenant, _, app, _ = upload_api + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as reader: + session = login(admin, tenant).json() + value, lab, room, template = inspection(admin, session) + user = create_user(admin, session) + actual_role = "inspector" if role.endswith("inspector") else role + if role != "none": + assert ( + role_write( + admin, + session, + user["id"], + command(role=actual_role, lab=None if role == "rule_expert" else lab["id"]), + ).status_code + == 200 + ) + actor = login(reader, tenant, username="target").json() + if role == "creator_inspector": + value = created( + reader, actor, "/inspections", inspection_body(lab, template, room), "Inspection" + ) + response = post(reader, actor, "/uploads", upload_body(value["item_ids"][0])) + expected = ( + 201 if role == "creator_inspector" else 404 if role in {"rule_expert", "none"} else 403 + ) + assert response.status_code == expected, response.text + assert count_rows(database, tenant, "uploads") == int(expected == 201) + + +@pytest.mark.parametrize( + "item_status,parent_status", + [ + ("queued", "in_progress"), + ("quality_checking", "in_progress"), + ("processing", "in_progress"), + ("completed", "in_progress"), + ("draft", "completed"), + ("draft", "cancelled"), + ], +) +def test_closed_or_busy_owner_rejects_grants_atomically( + upload_api, database, item_status, parent_status +): + tenant, _, app, _ = upload_api + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + with database.begin() as connection: + connection.execute( + text("UPDATE inspections SET status=:status WHERE id=:id"), + {"status": parent_status, "id": value["id"]}, + ) + connection.execute( + text("UPDATE inspection_items SET status=:status WHERE id=:id"), + {"status": item_status, "id": value["item_ids"][0]}, + ) + before = counts(database, tenant) + result = post(http, session, "/uploads", upload_body(value["item_ids"][0])) + assert result.status_code == 409, result.text + assert counts(database, tenant) == before + + +def test_foreign_tenant_and_foreign_lab_owner_are_hidden(upload_api, database): + tenant, _, app, _ = upload_api + foreign = create_tenant(database, "uploadforeign") + with TestClient(app, base_url=ORIGIN) as http: + actor = login(http, foreign).json() + foreign_inspection, _, _, _ = inspection(http, actor) + session = login(http, tenant).json() + for owner_id in (foreign_inspection["item_ids"][0], str(uuid4())): + assert post(http, session, "/uploads", upload_body(owner_id)).status_code == 404 + _, lab, _, _ = inspection(http, session) + other, _, _, _ = inspection(http, session) + user = create_user(http, session) + assert ( + role_write( + http, session, user["id"], command(role="inspector", lab=lab["id"]) + ).status_code + == 200 + ) + actor = login(http, tenant, username="target").json() + assert post(http, actor, "/uploads", upload_body(other["item_ids"][0])).status_code == 404 + assert count_rows(database, tenant, "uploads") == 0 + + +@pytest.mark.parametrize("failure", ["signer", "audit", "idempotency"]) +def test_grant_failures_roll_back_all_rows(upload_api, database, failure): + tenant, _, app, storage = upload_api + target = { + "signer": patch.object(storage, "presign_put", side_effect=RuntimeError("private")), + "audit": patch( + "packages.persistence.uploads.append_audit", side_effect=RuntimeError("private") + ), + "idempotency": patch( + "packages.application.uploads.complete_idempotency", side_effect=RuntimeError("private") + ), + }[failure] + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + before = counts(database, tenant) + with target: + result = post(http, session, "/uploads", upload_body(value["item_ids"][0])) + assert result.status_code == 500 and "private" not in result.text + assert counts(database, tenant) == before + + +def test_redis_failure_never_issues_or_persists_grant(upload_api, database): + tenant, service, app, storage = upload_api + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + before = counts(database, tenant) + with ( + patch.object(service.limits.redis, "eval", side_effect=ConnectionError("private")), + patch.object(storage, "presign_put") as sign, + ): + response = post(http, session, "/uploads", upload_body(value["item_ids"][0])) + assert response.status_code == 503 + sign.assert_not_called() + assert counts(database, tenant) == before + + +def test_db_grant_quota_is_not_just_redis_rate_and_expiry_frees_capacity(upload_api, database): + tenant, service, app, _ = upload_api + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + body = upload_body(value["item_ids"][0]) + for _ in range(10): + result = post(http, session, "/uploads", body) + assert result.status_code == 201, result.text + limited = post(http, session, "/uploads", body) + assert limited.status_code == 429 and limited.headers["retry-after"] + # Bypass the separately-tested minute counter to exercise DB quota state. + with patch.object(service.limits, "upload_grant"): + assert post(http, session, "/uploads", body).status_code == 429 + with database.begin() as connection: + connection.execute( + text("UPDATE uploads SET expires_at=:expired WHERE id=:id"), + { + "expired": utc_now() - timedelta(seconds=1), + "id": result.json()["data"]["upload_id"], + }, + ) + assert post(http, session, "/uploads", body).status_code == 201 + assert count_rows(database, tenant, "uploads") == 11 + + +@pytest.mark.parametrize("same_key", [True, False]) +def test_four_sessions_share_one_user_quota_without_shared_to_exclusive_upgrade( + upload_api, database, same_key +): + tenant, service, app, _ = upload_api + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + body = upload_body(value["item_ids"][0]) + for _ in range(9): + assert post(http, session, "/uploads", body).status_code == 201 + clients = [TestClient(app, base_url=ORIGIN) for _ in range(4)] + sessions = [login(client, tenant).json() for client in clients] + key, barrier = str(uuid4()), Barrier(4) + + def create(index): + barrier.wait(timeout=10) + return post( + clients[index], sessions[index], "/uploads", body, key=key if same_key else str(uuid4()) + ) + + try: + with ( + patch.object(service.limits, "upload_grant"), + ThreadPoolExecutor(max_workers=4) as pool, + ): + results = list(pool.map(create, range(4))) + assert sorted(r.status_code for r in results) == ( + [201] * 4 if same_key else [201, 429, 429, 429] + ), [r.text for r in results] + if same_key: + assert len({r.content for r in results}) == 1 + assert count_rows(database, tenant, "uploads") == 10 + finally: + for client in clients: + client.close() + + +def test_preflight_session_revocation_and_rate_limit_transaction_boundary(upload_api, database): + tenant, service, app, storage = upload_api + active, transaction = 0, service.transaction + + @contextmanager + def tracked(): + nonlocal active + with transaction() as connection: + active += 1 + try: + yield connection + finally: + active -= 1 + + def revoke(actor): + assert active == 0 + with database.begin() as connection: + connection.execute( + text("UPDATE sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE tenant_id=:tenant"), + {"tenant": tenant["tenant_id"]}, + ) + + with TestClient(app, base_url=ORIGIN) as http: + session = login(http, tenant).json() + value, _, _, _ = inspection(http, session) + before = counts(database, tenant) + with ( + patch.object(service, "transaction", side_effect=tracked), + patch.object(service.limits, "upload_grant", side_effect=revoke), + patch.object(storage, "presign_put") as sign, + ): + assert ( + post(http, session, "/uploads", upload_body(value["item_ids"][0])).status_code + == 401 + ) + sign.assert_not_called() + assert counts(database, tenant) == before + + +def test_revoked_role_blocks_replay_and_completed_owner_does_not_refresh_original_grant( + upload_api, database +): + tenant, _, app, _ = upload_api + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as http: + session = login(admin, tenant).json() + _, lab, room, template = inspection(admin, session) + user = create_user(admin, session) + assert ( + role_write( + admin, session, user["id"], command(role="inspector", lab=lab["id"]) + ).status_code + == 200 + ) + actor = login(http, tenant, username="target").json() + value = created( + http, actor, "/inspections", inspection_body(lab, template, room), "Inspection" + ) + body, key = upload_body(value["item_ids"][0]), str(uuid4()) + result = post(http, actor, "/uploads", body, key=key) + assert result.status_code == 201 + with database.begin() as connection: + connection.execute( + text("UPDATE inspection_items SET status='completed' WHERE id=:id"), + {"id": body["owner_id"]}, + ) + assert post(http, actor, "/uploads", body, key=key).content == result.content + assert post(http, actor, "/uploads", body).status_code == 409 + assert ( + role_write( + admin, session, user["id"], command(2, "inspector", lab["id"]), revoke=True + ).status_code + == 200 + ) + assert post(http, actor, "/uploads", body, key=key).status_code == 401 + actor = login(http, tenant, username="target").json() + assert post(http, actor, "/uploads", body, key=key).status_code == 404 + + +def test_remediation_grant_belongs_to_assignee_even_after_inspection_completes( + upload_api, database +): + tenant, _, app, _ = upload_api + with TestClient(app, base_url=ORIGIN) as admin, TestClient(app, base_url=ORIGIN) as http: + session = login(admin, tenant).json() + value, lab, _, _ = inspection(admin, session) + user = create_user(admin, session) + assert ( + role_write( + admin, session, user["id"], command(role="remediator", lab=lab["id"]) + ).status_code + == 200 + ) + with database.begin() as connection: + + def add(table, **values): + return insert(connection, table, tenant_id=tenant["tenant_id"], **values) + + # Synthetic legal history because the remediation command chain is not yet implemented. + dictionary = add("dictionary_versions") + model = add("model_versions", dictionary_version_id=dictionary) + rules = add("rule_bundles") + item_id = value["item_ids"][0] + run = add( + "inference_runs", + item_id=item_id, + laboratory_id=lab["id"], + model_bundle_id=model, + dictionary_version_id=dictionary, + rule_bundle_id=rules, + ) + fact = add("fact_revisions", item_id=item_id, run_id=run) + evaluation = add( + "rule_evaluations", + item_id=item_id, + run_id=run, + fact_revision_id=fact, + rule_bundle_id=rules, + ) + finding = add( + "findings", + item_id=item_id, + run_id=run, + fact_revision_id=fact, + rule_evaluation_id=evaluation, + status="dispatched", + ) + task = add( + "remediation_tasks", + finding_id=finding, + laboratory_id=lab["id"], + assignee_id=user["id"], + status="in_progress", + ) + connection.execute( + text("UPDATE inspections SET status='completed' WHERE id=:id"), {"id": value["id"]} + ) + body = upload_body(task, owner_type="remediation_task") + assert post(admin, session, "/uploads", body).status_code == 403 + actor = login(http, tenant, username="target").json() + result = post(http, actor, "/uploads", body) + assert result.status_code == 201, result.text + with database.begin() as connection: + row = connection.execute( + text("SELECT inspection_item_id,remediation_task_id FROM uploads WHERE id=:id"), + {"id": result.json()["data"]["upload_id"]}, + ).one() + assert row == (None, task) diff --git a/tests/security/test_authorization.py b/tests/security/test_authorization.py index a925d3b..4382aa7 100644 --- a/tests/security/test_authorization.py +++ b/tests/security/test_authorization.py @@ -1,6 +1,13 @@ import pytest -from packages.domain.security import Permission, Principal, ServiceError, authorize, require_version +from packages.domain.security import ( + Permission, + Principal, + ServiceError, + authorize, + permission_projection, + require_version, +) from packages.persistence.security import SESSION_ABSOLUTE, SESSION_IDLE, utc_now @@ -59,3 +66,13 @@ def test_time_contract_is_millisecond_utc_and_expiry_windows_are_fixed(): assert now.microsecond % 1000 == 0 assert SESSION_ABSOLUTE.total_seconds() == 8 * 3600 assert SESSION_IDLE.total_seconds() == 30 * 60 + + +def test_permission_projection_deduplicates_and_rejects_over_capacity(): + roles = tuple(("viewer", "laboratory", f"lab-{i:03}") for i in range(200)) + projected = permission_projection(principal(*roles, roles[0])) + assert len(projected) == 200 + assert projected == sorted(projected, key=lambda p: (p["action"], p["laboratory_id"])) + with pytest.raises(ServiceError) as failure: + permission_projection(principal(*roles, ("viewer", "laboratory", "lab-200"))) + assert failure.value.code == "STATE_CONFLICT" diff --git a/tests/security/test_rate_limit.py b/tests/security/test_rate_limit.py new file mode 100644 index 0000000..de0502e --- /dev/null +++ b/tests/security/test_rate_limit.py @@ -0,0 +1,67 @@ +from unittest.mock import Mock + +import pytest +from redis.exceptions import ConnectionError + +from packages.application.rate_limit import RateLimits +from packages.domain.security import Principal, ServiceError + + +def test_login_canonical_identity_and_no_plaintext_account_in_key(): + redis = Mock() + redis.eval.return_value = [1, 5] + limits = RateLimits(redis, clock=lambda: 899) + one = limits.login("tenant", "ADMIN", "1.2.3.4") + two = limits.login("tenant", "admin", "1.2.3.4") + assert one == two + assert "admin" not in one and "1.2.3.4" not in one + redis.eval.return_value = [6, 1] + with pytest.raises(ServiceError) as error: + limits.login("tenant", "admin", "1.2.3.4") + assert error.value.code == "RATE_LIMITED" and error.value.retry_after == 1 + + +def test_redis_outage_fails_closed_for_writes_and_login_but_bounds_reads(caplog): + redis = Mock() + redis.eval.side_effect = ConnectionError("private URL") + limits = RateLimits(redis, clock=lambda: 10) + principal = Principal("user", "tenant", "admin", ()) + for call in (lambda: limits.login("t", "u", "ip"), lambda: limits.user(principal, write=True)): + with pytest.raises(ServiceError) as error: + call() + assert error.value.status == 503 + for _ in range(30): + limits.user(principal, write=False) + with pytest.raises(ServiceError) as error: + limits.user(principal, write=False) + assert error.value.status == 429 + assert "conservative" in caplog.text and "private" not in caplog.text + + +@pytest.mark.parametrize("result", [False, ConnectionError("private URL")]) +def test_readiness_rejects_failed_redis_ping(result): + redis = Mock() + if isinstance(result, Exception): + redis.ping.side_effect = result + else: + redis.ping.return_value = result + with pytest.raises(ServiceError) as error: + RateLimits(redis).ready() + assert error.value.status == 503 and "private" not in str(error.value) + + +def test_upload_grant_rate_limit_is_separate_and_fails_closed(): + redis = Mock() + limits = RateLimits(redis, clock=lambda: 10) + actor = Principal("user", "tenant", "name", ()) + redis.eval.return_value = [10, 50] + limits.upload_grant(actor) + assert ":upload:" in redis.eval.call_args.args[2] + redis.eval.return_value = [11, 50] + with pytest.raises(ServiceError) as caught: + limits.upload_grant(actor) + assert caught.value.status == 429 and caught.value.retry_after == 50 + redis.eval.side_effect = ConnectionError("private") + with pytest.raises(ServiceError) as caught: + limits.upload_grant(actor) + assert caught.value.status == 503