feat(authz): implement six-phase permissions, settings and workflow v2
Close P0 auth gaps for lineage/runs, stage ABAC fail-closed, and project:create; add IAM migrations 00021-23, me settings APIs, schema v2 manifests, orchestrator skeleton, and gate A regression coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
7ffbadfaa8
commit
90f77eac93
@@ -0,0 +1,706 @@
|
||||
# Agent System 权限、设置与三类工作流完整实施方案
|
||||
|
||||
## 1. 执行结论
|
||||
|
||||
当前系统已经完成了从原型向受控工作流平台演进所需的多数“结构件”:数据库账户与会话、系统角色/权限表、项目成员、工作流定义及版本、模板版本、项目工作流实例、阶段实例、文档任务实例、三权职责、激活确认、阶段推进请求、通知 Outbox、任务运行持久化和文档血缘均已存在。现有 Go 短测、前端测试和生产构建也能通过。因此,本轮不应推倒重做,而应完成最后一段也是最关键的一段:**统一授权权威、把设置真正落到服务端、让已发布工作流版本成为运行时唯一事实源,并将商务对接、智能产品经理、智能测试经理发布为可配置、可冻结、可执行、可审计的正式工作流。**[^1][^2][^3]
|
||||
|
||||
但截至本次审计,系统还不能宣称“权限和设置已做全”,三个工作流也还不能按当前治理设计直接投入多人生产使用。核心原因如下:
|
||||
|
||||
1. **权限表存在,但策略执行不完整。** 部分项目查询、工作流历史和血缘查询缺少对象级授权;创建项目只校验登录,不校验 `project:create`;工作流管理 API 仍统一要求超级管理员,未使用已入库的细粒度权限;系统权限计算还忽略绑定作用域、角色状态和 `valid_from`。[^4][^5][^6]
|
||||
2. **阶段权限存在高风险放行路径。** 解析 live 实例发生数据库错误时会回退为“不限制”,多个列表、聊天、RAG/上下文过滤辅助函数也在错误时放行;拥有当前阶段任一文档职责的用户会获得该阶段全部文档任务权限,任务执行门禁还允许其运行任意 `task_key`。[^7][^8]
|
||||
3. **设置界面与服务端权威尚未闭环。** 浏览器仍把 Bearer token 和会话对象放入 `localStorage`,登出只删除本地状态;个人资料保存、个人偏好和部分权限画像仍是前端本地数据,已有活动会话 API 未在用户设置中形成闭环。[^9][^10]
|
||||
4. **三个工作流只是“登记过”,不是“完整接入”。** `main`、`product_manager`、`test_manager` 已作为发布种子存在,但都是单阶段包装;运行目录会把任意已发布版本压扁为第一阶段的第一个任务。商务对接真实的 V0.1/V0.5/V1.0 阶段仍硬编码在 BizDev 执行器中;产品经理和测试经理的五步 Agent 流程存在,但没有由版本化定义驱动。[^11][^12][^13]
|
||||
5. **项目实例与文档治理还未成为同一条链。** 规划校验没有检查必需阶段/必需任务是否被整个遗漏,也没有校验三权人员是否为有效项目成员;实例持久化把所有阶段 `position` 写成 0;执行器又会在请求未传三权时回退到固定用户 2/3,并把文档阶段固定为 `V0.5_feasibility`,没有消费冻结实例中的职责和活动阶段。[^14][^15]
|
||||
|
||||
因此建议把工作拆为三道发布闸门:
|
||||
|
||||
- **闸门 A:授权止血。** 先关闭跨项目读取、错误放行、任意任务键、身份/会话本地权威等 P0;完成全部 API/数据通路的策略矩阵。
|
||||
- **闸门 B:配置与运行时合流。** 扩展工作流 schema,建立 `executor_key` 注册表和全阶段解释器,让项目实例、DocGov、任务运行、对话/RAG、文档血缘都从同一冻结快照取值。
|
||||
- **闸门 C:发布三个正式版本。** 以新版本而不是覆盖旧数据的方式发布三个工作流,完成迁移、真实 PostgreSQL、跨用户负例和真实 PI Agent E2E 后再开放生产选择。
|
||||
|
||||
本方案遵循当前系统已经确定的治理原则:生产身份只来自服务端会话;系统角色、项目角色和单文档三权职责分离;已发布版本不可原地修改;项目激活时冻结完整快照;同一项目只允许一个 live 工作流;当前流程完成或受控终止后才能选择下一个;门禁满足只发提醒,阶段推进必须由项目创建人明确确认。[^1]
|
||||
|
||||
## 2. 本次研究范围与验证边界
|
||||
|
||||
### 2.1 已审阅范围
|
||||
|
||||
| 层 | 已审阅内容 |
|
||||
|---|---|
|
||||
| 数据库 | Goose `00014`–`00020`:成员、IAM、工作流/模板、项目实例、通知、任务运行 |
|
||||
| 后端 | `internal/auth`、`authz`、`workflowdef`、`projectinstance`、`projectworkflow`、`bizdev`、`squad`、`server` |
|
||||
| 前端 | 登录会话、RBAC 类型、用户设置、系统设置、项目设置、工作流切换/展示 |
|
||||
| 业务执行 | 商务对接真实阶段;产品经理/测试经理 Agent 角色、步骤、模板、质量门禁、前置依赖和血缘 |
|
||||
| 既有文档 | 2026-09-11 治理总方案、Phase 1/2 实施计划、当前 PG/CI/Soak/E2E 报告 |
|
||||
|
||||
### 2.2 本轮已执行验证
|
||||
|
||||
```text
|
||||
go test -short -timeout 8m \
|
||||
./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \
|
||||
./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/...
|
||||
结果:全部通过
|
||||
|
||||
cd web && npm test
|
||||
结果:170 项 Node tests + 22 项 Vitest tests 全部通过
|
||||
|
||||
cd web && npm run build
|
||||
结果:TypeScript + Vite 构建通过;存在主 bundle 过大的非阻断警告
|
||||
```
|
||||
|
||||
这些结果证明当前分支能编译且现有回归未破坏,不证明跨租户授权、真实 PostgreSQL 事务、重启恢复和真实 PI Agent 长链路已满足本方案的发布门禁。当前测试报告中 PI Agent 全生命周期仍记录了 `/v1/run` 超时,且报告结尾又写“仍在运行”,应先修正文档状态,再以一次新的、带 commit SHA 和环境指纹的运行作为验收证据。[^16]
|
||||
|
||||
### 2.3 外部设计基线
|
||||
|
||||
授权采用“默认拒绝、每次请求验证、对象标识不可猜测越权、失败安全退出、记录审计”的基线;动态项目、阶段和文档条件用 RBAC + ReBAC + ABAC 组合,而不是只靠全局角色。OWASP 明确建议默认拒绝并对每次请求执行权限判断;NIST RBAC 模型把用户、角色、权限、操作和对象建模为独立关系,并支持职责分离约束。[^17][^18]
|
||||
|
||||
浏览器会话不再把认证 token 放在 `localStorage`。OWASP 指出 Web Storage 可被同源 JavaScript 读取,建议会话使用 `HttpOnly; Secure; SameSite` Cookie;Cookie 模式下同时补齐 CSRF 防护。[^19]
|
||||
|
||||
## 3. 当前系统能力盘点
|
||||
|
||||
### 3.1 可以直接保留并扩展的能力
|
||||
|
||||
| 能力 | 当前状态 | 本方案处理 |
|
||||
|---|---|---|
|
||||
| 统一 API 认证中间件 | 已存在 | 保留;切换到安全 Cookie/CSRF,并在授权失败时统一错误模型 |
|
||||
| 数据库账户、密码哈希、会话哈希 | 已存在 | 保留;增加密码/MFA/重置策略、会话前端闭环和安全事件 |
|
||||
| 系统角色/权限/绑定 | 已存在 | 纠正多角色、作用域、有效期、状态和管理审计语义 |
|
||||
| `project_members` | 已存在 | 增加 FK、项目业务角色多绑定、成员状态和职责候选资格 |
|
||||
| 工作流草稿/发布/退役 | 后端基础存在 | 增加复制草稿、完整 schema、细粒度授权、发布影响预览和 UI |
|
||||
| 模板 CAS/版本/扫描状态 | 基础存在 | 增加扫描 worker、隔离区、预览、发布/退役和引用影响分析 |
|
||||
| 项目规划/预览/确认/冻结 | 基础存在 | 修复完整性校验、原子 token、阶段顺序、受控变更和下一个工作流 |
|
||||
| 文档三权状态机 | 已存在 | 由实例职责驱动;加职责资格、再认证/签署策略和阶段门禁映射 |
|
||||
| Outbox/通知/阶段推进请求 | 已存在 | 完成用户偏好、渠道、升级、去重和创建人确认体验 |
|
||||
| 任务运行持久化/lease | 已存在 | 把任务与实例/阶段/任务定义绑定;细分 read/cancel/resume 权限 |
|
||||
| 文档血缘/stale | 已存在 | stale 成为阶段退出阻断条件,提供影响分析和重新基线闭环 |
|
||||
|
||||
### 3.2 当前成熟度判定
|
||||
|
||||
| 领域 | 代码骨架 | 服务端权威 | 前端闭环 | 生产验收 | 判定 |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| 认证与会话 | 高 | 中 | 低 | 中 | 需整改 |
|
||||
| 系统 RBAC | 中 | 低 | 中 | 低 | 发布阻断 |
|
||||
| 项目 ReBAC | 中 | 中 | 中 | 低 | 发布阻断 |
|
||||
| 阶段/文档 ABAC | 中 | 低 | 中 | 低 | 发布阻断 |
|
||||
| 用户设置 | 中 | 低 | 中 | 低 | 未做全 |
|
||||
| 系统设置 | 中 | 中 | 中 | 低 | 未做全 |
|
||||
| 项目设置 | 中高 | 中 | 中 | 低 | 关键断点 |
|
||||
| 工作流设计/版本 | 中高 | 中 | 低 | 低 | 关键断点 |
|
||||
| 三工作流执行器 | 高 | 低 | 中 | 低 | 尚未正式接入 |
|
||||
| 审计与通知 | 中 | 中 | 低 | 低 | 尚未闭环 |
|
||||
|
||||
## 4. 必须优先修复的问题清单
|
||||
|
||||
### 4.1 P0:在接入三个工作流前必须关闭
|
||||
|
||||
| ID | 问题 | 影响 | 整改与验收 |
|
||||
|---|---|---|---|
|
||||
| AUTH-001 | 工作流运行历史、文档血缘 handler 未调用项目对象授权 | 登录用户可能读取其他项目元数据/血缘 | 所有项目子路由统一经过资源加载 + `Authorize`;跨项目返回 404 或统一 403;加双用户 PG 负例 |
|
||||
| AUTH-002 | 阶段访问解析或过滤出错时放行 | DB/策略故障可扩大文档、聊天、RAG、Agent 上下文可见范围 | 生产环境 fail closed;只对明确标记的 legacy 项目执行迁移期策略,且有截止版本和指标 |
|
||||
| AUTH-003 | 工作流路由接受客户端覆盖 `task_key`;阶段成员可运行当前阶段任意任务 | 绕过工作流定义和文档职责 | 服务端只从 `workflow_instance_id + active_stage + task_instance_id` 推导 executor;拒绝不在快照中的任务 |
|
||||
| AUTH-004 | 项目规划可以整个遗漏必需阶段/任务 | 可激活空壳或删减受控交付物的计划 | 对定义中每个 non-optional stage 和 required/blocking task 做反向完整性校验 |
|
||||
| AUTH-005 | 三权用户未校验有效账户、项目成员和候选角色 | 可把无权或已停用用户写入审批链 | 激活前逐人校验 membership/status/eligibility/职责分离;激活后人员失效触发阻断事件 |
|
||||
| AUTH-006 | 项目列表 SQL 把 `creator_id=1`/NULL 项目视为公开,旧切换逻辑有固定用户/项目 ID 豁免 | 演示兼容规则泄漏到真实授权 | 删除 ID 特判;显式 `project_visibility`/legacy migration,不以 creator ID 表达公开 |
|
||||
| AUTH-007 | 项目创建接口未检查 `project:create` | 任意活动账户可立项 | handler/service 双层校验 `projects.create`,前端按钮仅作体验控制 |
|
||||
| AUTH-008 | 项目成员可读取/取消其他成员任务运行,未区分动作 | 破坏他人运行和审计归属 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` 分权;取消他人任务须负责人或创建人 |
|
||||
| AUTH-009 | 本地 token、仅本地 logout、失效 `/me` 后仍保留登录态 | XSS/撤销后前端假登录 | 改安全 Cookie;服务端 logout;`/me` 401 立即清空主体和缓存;权限加载失败敏感动作 fail closed |
|
||||
| WF-001 | DBCatalog 将完整版本压扁为第一阶段第一任务 | 发布多阶段定义也不会按定义运行 | 移除 flatten 执行路径,新增实例编排器逐阶段/任务读取冻结快照 |
|
||||
| WF-002 | DocGov 三权和阶段来自请求/固定默认值,不来自实例 | 配置的责任人和活动阶段不生效 | 由 `doc_task_instance_id` 解析 author/reviewer/approver/stage;禁止生产请求传入身份覆盖 |
|
||||
|
||||
### 4.2 P1:首次正式发布必须完成
|
||||
|
||||
| ID | 问题 | 整改 |
|
||||
|---|---|---|
|
||||
| IAM-101 | `ListCapabilities` 忽略 `scope_type/scope_id`、角色状态和 `valid_from` | 引入带资源上下文的 PDP;系统能力只取 `scope_type=system, scope_id=0`,校验角色/账户/有效期 |
|
||||
| IAM-102 | `users.role_id` 与 `user_role_bindings` 双权威,更新角色会删除其他绑定 | `user_role_bindings` 为唯一权威;`users.role_id` 只作迁移兼容后移除;支持多角色 |
|
||||
| IAM-103 | `assigned_by` 被写成目标用户本身,角色+状态更新非原子且忽略角色更新错误 | 从 principal 传 actor;单事务变更;写不可变审计事件;失败整体回滚 |
|
||||
| IAM-104 | `role-admin` 字符串直接赋予管理员身份,`perm:manage` 又作为所有权限通配 | 显式 `system.superadmin`;普通 `permissions.manage` 不再通配业务数据;至少两名受控管理员防死锁 |
|
||||
| IAM-105 | 前端权限键只有最早 7 项,后端迁移已有更多权限 | 后端暴露 permission catalog;前端由 API 渲染,不维护硬编码联合类型作为事实源 |
|
||||
| SET-101 | 用户资料保存只改 React state;偏好仅组件本地;活动会话 UI 缺失 | 增加 `/me/profile`、`/me/preferences`、`/me/notification-preferences` 和会话管理 UI |
|
||||
| SET-102 | 管理中心工作流页只能列出/校验/发布,不能完整建模 | 增加创建、复制草稿、阶段排序、任务/职责/参与策略、模板选择、diff、发布、退役 |
|
||||
| SET-103 | Office 模板进入 `pending` 后缺少扫描完成链 | 隔离上传 + scanner worker + 状态回调/轮询;只有 `ready` 可发布 |
|
||||
| SET-104 | UI 宣称符合 21 CFR Part 11,但未实现电子签名控制 | 删除不实文案;如需合规,另立 e-sign scope(再认证、签署含义、签名绑定、审计、防篡改) |
|
||||
| WF-101 | 所有阶段实例 `position=0` | 从定义快照持久化真实 position;增加唯一性与连续性校验 |
|
||||
| WF-102 | 激活 token 明文保存且消费、激活非同一事务 | 仅存 token hash;`SELECT FOR UPDATE` 后在同一事务验证、消费和激活 |
|
||||
| WF-103 | 推进会把全部启用任务强制标成 approved | 推进只允许已满足门禁的状态,不修正业务状态;不一致即事务失败 |
|
||||
| WF-104 | stale 未纳入阶段门禁 | required upstream/downstream lineage 有 stale 时禁止推进,重新批准后清除 stale 并留痕 |
|
||||
| WF-105 | 完成后缺少“选择下一工作流”与受控终止/变更 API | 实现 `completed/termination_requested/terminated` 全状态机及下一实例创建 |
|
||||
| WF-106 | bootstrap 发现已有 published 版本就退出 | 种子升级采用显式版本 manifest;内容 hash 变化创建 v2 draft,经校验批准后发布 |
|
||||
|
||||
### 4.3 P2:可在首发后持续增强
|
||||
|
||||
- 管理员 break-glass:理由、近期认证、限时、醒目标识、禁止代签、完整审计。
|
||||
- MFA/WebAuthn、企业 SSO/SCIM、组织层级和角色层级。
|
||||
- 策略模拟器、权限差异比较、定期访问复核和孤儿权限报告。
|
||||
- 工作流可视化 DAG;首版仍建议坚持线性阶段,减少编排复杂度。
|
||||
- 审计日志防篡改、WORM/外部归档和监管报表。
|
||||
|
||||
## 5. 目标授权架构
|
||||
|
||||
### 5.1 五层判定模型
|
||||
|
||||
每个敏感动作统一调用一个服务端策略决策点(PDP),判定表达式为:
|
||||
|
||||
```text
|
||||
ALLOW = authenticated(principal)
|
||||
AND account.active
|
||||
AND session.active
|
||||
AND permission_allows(action, resource_type, scope)
|
||||
AND project_relationship_allows(principal, project)
|
||||
AND workflow_snapshot_policy_allows(principal, active_instance, stage)
|
||||
AND document_policy_allows(principal, doc_task, document)
|
||||
AND state_machine_allows(action, current_state)
|
||||
AND separation_of_duty_allows(principal, action)
|
||||
|
||||
unknown / missing policy / dependency error => DENY
|
||||
```
|
||||
|
||||
| 层 | 回答的问题 | 数据权威 |
|
||||
|---|---|---|
|
||||
| 认证 | 这个请求是谁,账号/会话是否有效 | `users`、`auth_sessions` |
|
||||
| 系统 RBAC | 是否具备创建、管理、发布、审计等平台动作 | role/permission/binding 表 |
|
||||
| 项目 ReBAC | 是否属于该项目,承担哪些项目业务角色 | `project_members` + `project_role_bindings` |
|
||||
| 工作流/阶段 ABAC | 当前实例、阶段、参与策略是否允许 | 冻结 `config_snapshot` + 实例状态 |
|
||||
| 文档职责/状态 | 是否为该文档的编制/审核/批准人,当前状态能否执行动作 | `document_duty_assignments` + DocGov |
|
||||
|
||||
### 5.2 角色分层
|
||||
|
||||
系统角色不再表达具体项目里的业务职责,项目角色也不再等同于某一份文档的三权。
|
||||
|
||||
| 范围 | 建议内置角色 | 用途 |
|
||||
|---|---|---|
|
||||
| 系统 | 超级管理员、安全管理员、用户管理员、工作流管理员、模板管理员、安全审计员、普通用户 | 平台配置和治理;业务项目数据不因管理员身份默认全开 |
|
||||
| 项目 | 项目创建人、项目经理、商务 BD、产品经理、系统/研发工程师、测试、法规、QA、观察者 | 阶段参与、项目资料范围和业务动作候选资格 |
|
||||
| 文档 | author、reviewer、approver | 绑定单个文档任务实例;必须是具体用户,默认互斥 |
|
||||
|
||||
一个用户可以在同一项目绑定多个业务角色;角色绑定和文档职责均必须有 `valid_from/valid_until/status/assigned_by/reason`。静态职责分离在配置时阻止冲突,动态职责分离在动作时阻止同一主体对同一版本自审自批。
|
||||
|
||||
### 5.3 权限目录
|
||||
|
||||
权限键统一采用 `resource.action`,旧键通过一版兼容映射迁移,之后删除。建议首发目录:
|
||||
|
||||
| 域 | 权限 |
|
||||
|---|---|
|
||||
| 系统 | `system.settings.view/manage`、`system.security.manage`、`system.audit.view/export` |
|
||||
| 用户 | `users.view/create/edit/disable/reset_password`、`users.sessions.revoke` |
|
||||
| 角色 | `roles.view/create/edit/retire/assign/review` |
|
||||
| 工作流 | `workflow_defs.view/create/edit_draft/publish/retire`、`workflow_defs.impact_view` |
|
||||
| 模板 | `templates.view/upload/download/publish/retire` |
|
||||
| 项目 | `projects.create/view/edit/archive`、`projects.members.view/manage` |
|
||||
| 项目流程 | `projects.workflow.view/configure/activate/terminate`、`projects.stage.advance` |
|
||||
| 参与 | `workflow.chat.view/send`、`workflow.task.run`、`workflow.progress.view` |
|
||||
| 任务运行 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` |
|
||||
| 文档 | `documents.view/edit/submit/review/approve/export`、`documents.visibility.manage` |
|
||||
| 通知 | `notifications.view/update`、`notification_policies.manage` |
|
||||
|
||||
### 5.4 服务端执行点
|
||||
|
||||
不要在每个 handler 手写角色判断。建立:
|
||||
|
||||
```go
|
||||
Decision Authorize(ctx, Principal, Action, ResourceRef, Attributes)
|
||||
|
||||
Decision {
|
||||
Allowed bool
|
||||
ReasonCode string
|
||||
PolicyVersion string
|
||||
Obligations []string // redact, require_recent_auth, audit_reason, etc.
|
||||
}
|
||||
```
|
||||
|
||||
- 路由中间件负责身份和粗粒度权限。
|
||||
- service 层在事务内重新验证对象关系和当前状态,防止 TOCTOU。
|
||||
- repository 查询默认带可见范围,不先查全量再在浏览器过滤。
|
||||
- SSE、附件下载、搜索、导出、RAG、Agent tools 使用同一个 `ResourceRef`/Decision。
|
||||
- 禁止把 `user_id`、`role`、`stage`、`work_dir` 或 duty 作为客户端可信授权输入。
|
||||
- 403/404 策略统一;审计记录 actor、resource、action、decision、reason、request_id、policy_version。
|
||||
|
||||
### 5.5 核心动作矩阵
|
||||
|
||||
| 动作 | 普通成员 | 项目业务角色 | 项目创建人 | 系统管理角色 | 文档三权 |
|
||||
|---|---|---|---|---|---|
|
||||
| 创建项目 | 需 `projects.create` | 同左 | 同左 | 不默认拥有,按绑定 | 无关 |
|
||||
| 查看项目 | active membership | active membership | 是 | 仅 break-glass 或显式审计权限 | 无关 |
|
||||
| 管理成员 | 否 | 项目经理可选 | 是 | break-glass | 无关 |
|
||||
| 配置待激活流程 | 否 | 可提议 | 是 | 工作流管理员不能代替业务确认 | 无关 |
|
||||
| 激活/终止流程 | 否 | 可申请 | 是 + 再确认 | 只可 break-glass | 无关 |
|
||||
| 运行任务 | 否 | 阶段策略允许 + 对应 task permission | 同策略,不绕过 | 不默认 | author 或显式执行人 |
|
||||
| 查看文档 | 文档策略允许 | 阶段/角色策略允许 | 仍受私密/合规策略 | 不默认 | 当前职责人允许 |
|
||||
| 提交审核 | 否 | 否 | 不默认 | 不默认 | author |
|
||||
| 审核 | 否 | 否 | 不默认 | 不默认 | reviewer 且非 author |
|
||||
| 批准 | 否 | 否 | 不默认 | 不默认 | approver 且非 author/reviewer |
|
||||
| 推进阶段 | 否 | 只能申请/接收提醒 | 明确确认 | 不替代业务确认 | 无关 |
|
||||
| 发布工作流版本 | 否 | 否 | 无关 | 工作流发布权限 + 双人复核可选 | 无关 |
|
||||
|
||||
## 6. 设置体系完整方案
|
||||
|
||||
### 6.1 用户设置 `/settings`
|
||||
|
||||
| 页面 | 服务端能力 | 验收 |
|
||||
|---|---|---|
|
||||
| 个人资料 | `GET/PATCH /api/v1/me/profile`;姓名、邮箱、语言、时区;部门/工号受控只读 | 刷新、换设备仍一致;审计敏感字段变更 |
|
||||
| 安全 | 修改密码、MFA 状态、活动会话、撤销其他会话、最近登录 | 修改密码撤销其他会话;撤销后下一请求 401 |
|
||||
| 通知 | 站内/邮件渠道、事件类型、免打扰、升级策略 | Outbox 读取服务端偏好;关键审批提醒不可被完全关闭 |
|
||||
| 偏好 | 主题、默认导出、语言、默认项目页、Agent 显示偏好 | 落库并有 schema 版本;不再只存在组件 state |
|
||||
| 我的权限 | 服务端返回生效角色、作用域、有效期和权限来源 | 不由前端本地 role 推导;支持“为何有/无此权限” |
|
||||
|
||||
安全会话改造:登录响应通过 `Set-Cookie: __Host-agent_session=...; Secure; HttpOnly; SameSite=Lax|Strict; Path=/` 建立会话;状态变更使用 CSRF token/origin 验证;logout 调服务端撤销当前会话。测试环境可通过独立 fixture 获取 Cookie,不在产品包保留任意身份切换。
|
||||
|
||||
### 6.2 项目设置 `/projects/{id}/settings`
|
||||
|
||||
| 页面 | planning | active/awaiting | completed/terminated |
|
||||
|---|---|---|---|
|
||||
| 基本信息 | 创建人编辑 | 受控字段变更单 | 只读/归档 |
|
||||
| 成员与业务角色 | 增删改,校验职责候选 | 变更需影响分析;不能留下无人职责 | 历史只读,可为下个实例配置 |
|
||||
| 工作流选择 | 选已发布版本 | 冻结只读 | 可创建下一个 planning 实例 |
|
||||
| 阶段/任务 | 配置 optional/default,required 不可关闭 | 冻结;仅受控变更 | 快照只读 |
|
||||
| 模板版本 | 从 ready 版本选择 | 冻结 | 快照只读 |
|
||||
| 三权职责 | 必填、互斥、成员/资格校验 | 变更单 + 未决审批处理 | 历史只读 |
|
||||
| 参与/可见策略 | 从版本默认值继承,可按允许范围收紧 | 冻结;紧急收紧可走安全变更 | 历史只读 |
|
||||
| 预览与激活 | diff + 门禁 + 二次确认 | 不显示“重新激活” | 显示下一流程入口 |
|
||||
| 推进/终止 | 无 | 门禁满足后创建人确认;终止需原因/影响 | 展示完整审计 |
|
||||
|
||||
项目创建人不是系统超级管理员。项目设置的“独占确认权”和系统权限的“是否允许操作接口”同时成立:例如可由项目经理编辑草案,但最终激活和阶段推进仍由创建人确认。
|
||||
|
||||
### 6.3 系统管理中心 `/admin/*`
|
||||
|
||||
| 页面 | 必做功能 |
|
||||
|---|---|
|
||||
| 概览 | 服务/DB/Runner/Outbox/worker 健康、失败任务、待审批、策略版本 |
|
||||
| 用户 | 创建、审批、启停、资料、重置密码、撤销会话、多角色绑定、批量导入 |
|
||||
| 角色权限 | 权限目录、多角色、作用域、有效期、职责分离、影响预览、角色退役 |
|
||||
| 工作流设计器 | 创建/复制草稿、阶段排序、任务、执行器、模板、输入输出、门禁、职责候选、参与/可见策略、校验、diff、发布、退役 |
|
||||
| 模板库 | 隔离上传、扫描、预览、变量 schema、版本、发布/退役、引用关系 |
|
||||
| 项目类型与合规 | `product_domain` 与 `compliance_profile` 分离;可用流程、必需文档和签署强度 |
|
||||
| 通知自动化 | 事件、渠道、模板、频率、升级和去重 |
|
||||
| 审计 | 按 actor/action/resource/result/request_id 查询和导出;高风险变更对比 |
|
||||
| 安全 | 注册、审批、密码、MFA、会话时限、CSRF/CORS、上传限制、保留期、break-glass |
|
||||
|
||||
管理端不能继续依赖硬编码权限清单。`GET /api/v1/admin/permissions` 返回目录和风险等级;每个页面使用服务端 capabilities 控制入口,但最终仍由 API 决策。
|
||||
|
||||
## 7. 工作流定义 Schema v2
|
||||
|
||||
现有 schema 只有阶段、位置、人工门禁和文档任务基本字段,无法表达运行绑定、参与策略、职责资格、输入输出和确定性质量门禁。建议新增 `schema_version: 2`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflow_key": "product_manager",
|
||||
"product_domains": ["medical_device", "non_medical_software"],
|
||||
"required_upstream": [],
|
||||
"participation_policy": {},
|
||||
"completion_policy": {},
|
||||
"stages": [{
|
||||
"key": "requirements_baseline",
|
||||
"name": "系统需求基线",
|
||||
"position": 10,
|
||||
"optional": false,
|
||||
"entry_gate": {},
|
||||
"exit_gate": {},
|
||||
"participation_policy": {},
|
||||
"doc_tasks": [{
|
||||
"key": "system_reqs",
|
||||
"executor_key": "squad.system_reqs.v1",
|
||||
"required": true,
|
||||
"blocking": true,
|
||||
"template": {"asset_key": "system_reqs", "version": 2},
|
||||
"input_contract": {},
|
||||
"output_contract": {},
|
||||
"quality_gate_key": "quality.system_reqs.v2",
|
||||
"eligible_project_roles": {},
|
||||
"visibility_policy": {},
|
||||
"retry_policy": {},
|
||||
"timeout_seconds": 3600
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
发布校验必须至少验证:
|
||||
|
||||
- key 格式和全局唯一;stage position 唯一且严格递增;必需阶段/任务不可缺失;
|
||||
- `executor_key` 已注册且声明支持相应输入/输出;不再把未知任务落入 generic runner;
|
||||
- template version 存在且 `scan_status=ready`,变量 schema 与 input contract 一致;
|
||||
- 三权候选角色存在,且不会造成项目无人可配;author/reviewer/approver 默认互斥;
|
||||
- 上游工作流和文档依赖无环、能解析到已发布版本;
|
||||
- 每个 required/blocking task 有确定性质量门禁;
|
||||
- 可见/参与策略有明确默认值,未知即拒绝;
|
||||
- 发布后 definition JSON/hash 和所有引用版本不可变;编辑从已发布版本复制新 draft。
|
||||
|
||||
## 8. 三个工作流的正式定义
|
||||
|
||||
### 8.1 兼容性决定
|
||||
|
||||
当前项目和代码广泛使用 `main` 代表商务主线。首轮迁移**保留稳定 key `main`**,显示名统一为“商务对接”,避免破坏存量项目、任务记录和文档 ID;停止在新代码里把它解释成“任意默认流程”。`product_manager` 和 `test_manager` 保持现有 key。若未来希望改为 `business_engagement`,新增显式 alias 表,不直接改历史 key。
|
||||
|
||||
三个正式版本均以 v2 draft 创建和发布;不得修改已有 published v1,也不得依赖当前 `UpsertPublishedSeed` 自动覆盖。已 active 的 v1 项目继续使用原快照;planning 项目由创建人查看 diff 后主动升级。
|
||||
|
||||
### 8.2 商务对接 `main` v2
|
||||
|
||||
当前真实执行器已有以下阶段和任务,本方案将其搬入版本定义,执行器只保留能力注册,不再保留另一套阶段事实源。[^13]
|
||||
|
||||
| 顺序 | 阶段 | 文档任务 | 必需/阻断 | 建议三权候选 | 退出门禁 |
|
||||
|---:|---|---|---|---|---|
|
||||
| 10 | `V0.1_triage` 闪电摸底与盲区探测 | `intake` 客户需求采集/初始需求表 | 是/是 | author: BD/PM;reviewer: 项目经理;approver: 创建人或商务负责人 | intake approved;关键项无缺失;无运行任务 |
|
||||
| 20 | `V0.5_feasibility` 方案成型与法规对齐 | `research` 竞品调研;`feasibility_reg` 法规可行性;`prelim_design` 初步设计 | 全部是/是 | 按文档分别指派 BD、法规、研发;审核/批准分离 | 三文档 approved;无 stale/open change;entry inputs 已基线化 |
|
||||
| 30 | `V1.0_quoting` 工程拆解与商业锁定 | `cdo_quote` CDO 报价与核算 | 是/是 | author: 商务/项目经理;reviewer: 技术/财务;approver: 创建人/授权商务负责人 | 正文和核算 XLSX hash 一致、approved;报价关键字段完整 |
|
||||
|
||||
参与策略默认值:
|
||||
|
||||
- BD 可参与 V0.1/V0.5 的 `intake/research`,不因“是项目成员”自动获得 `prelim_design` 或报价批准权。
|
||||
- 法规只访问被授权的法规任务及其显式上游;研发只访问初步设计及显式上游。
|
||||
- 创建人可看项目进度并确认推进,但不会自动获得某份文档的审核/批准动作。
|
||||
- 阶段完成只产生 `ready_to_advance` 通知;创建人确认后原子进入下一阶段。
|
||||
|
||||
### 8.3 智能产品经理 `product_manager` v2
|
||||
|
||||
项目层保持一个受控阶段,阶段内的五个 Agent step 是 executor 内部步骤,不把内部 agent step 误建成五个需要人工推进的项目阶段。现有代码已经定义需求梳理、系统工程、法规、成文、终审五角色和五步执行链,可直接注册为 `squad.system_reqs.v1`。[^20]
|
||||
|
||||
| 项 | 正式配置 |
|
||||
|---|---|
|
||||
| 阶段 | `requirements_baseline` 系统需求基线,position=10,required/blocking |
|
||||
| 文档任务 | `system_reqs`,输出受控《系统需求规格书》 |
|
||||
| 执行器 | `squad.system_reqs.v1` |
|
||||
| 模板 | `system_reqs` ready version;发布时固定版本 |
|
||||
| 必需输入 | 项目五要素;已批准/显式基线化的客户需求或上游商务文档;项目材料清单 |
|
||||
| 结构化输出 | `output.md`、`artifacts/requirements.json`、manifest/hash、质量报告 |
|
||||
| 三权候选 | author: PM/系统工程;reviewer: 技术负责人/法规;approver: 创建人授权的批准人/QA |
|
||||
| 质量门禁 v2 | 每条需求唯一 ID;来源、理由、优先级、可验证准则、验证方法齐全;无未替换占位;JSON 与正文一致;重复/冲突/孤儿需求为 0 |
|
||||
| 完成门禁 | executor 成功 + deterministic gate 通过 + 文档 approved + 无 stale/open change/running task |
|
||||
|
||||
当前质量门禁只检查正文中是否出现 `REQ-` 和宽泛的“测试/分析/审查”等词,达不到“需求集合完整、唯一、可追溯、可测”的正式门禁。v2 必须解析 `requirements.json`,以结构化集合为准,并把校验报告与文档版本绑定。[^21]
|
||||
|
||||
### 8.4 智能测试经理 `test_manager` v2
|
||||
|
||||
| 项 | 正式配置 |
|
||||
|---|---|
|
||||
| 阶段 | `system_verification_planning` 系统验证策划,position=10,required/blocking |
|
||||
| 文档任务 | `test_manager`,输出受控《系统验证方案及计划》 |
|
||||
| 执行器 | `squad.test_manager.v1` |
|
||||
| 上游条件 | 同项目最近一次已批准且非 stale 的 `system_reqs`;记录上游 doc/version/hash |
|
||||
| 模板 | `test_manager` ready version;发布时固定版本 |
|
||||
| 结构化输出 | `output.md`、必需的 `artifacts/test_cases.json`、coverage report、manifest/hash |
|
||||
| 三权候选 | author: 测试;reviewer: 系统/法规;approver: 测试负责人/QA,三者互斥 |
|
||||
| 质量门禁 v2 | 上游需求全集解析成功;每条 requirement 至少一条有效 case;case ID 唯一;步骤、预期、判据、环境、方法完整;覆盖率 100%;无孤儿引用 |
|
||||
| stale 策略 | 上游 SR hash 变化立即将下游标为 stale,禁止提交/批准/阶段完成,完成影响分析与重新基线后解除 |
|
||||
|
||||
现有 executor 已要求上游 SR approved 并建立血缘,也定义了 100% 覆盖的 Agent 目标;但当前确定性门禁只检查 `TC-/TEST-`、`REQ-` 字符串,并且 `test_cases.json` 仅在文件存在时校验 JSON,文件不存在不会失败。v2 必须把结构化用例文件和覆盖率报告设为必需产物。[^21][^22]
|
||||
|
||||
### 8.5 三个工作流的串联语义
|
||||
|
||||
系统当前设计是“一项目最多一个 live 工作流”,不是三条流程同时 active:
|
||||
|
||||
```text
|
||||
商务对接 completed/受控 terminated
|
||||
-> 创建人选择并配置 product_manager v2
|
||||
-> 产品经理流程 completed,系统需求 approved
|
||||
-> 创建人选择并配置 test_manager v2
|
||||
-> 测试经理流程完成
|
||||
```
|
||||
|
||||
系统可以在前一流程门禁满足时推荐下一个流程并通知创建人,但绝不自动激活。上游依赖解析按文档类型 + workflow instance + approved version 查询,不依赖固定文件路径或仅由字符串拼出的 doc ID。
|
||||
|
||||
## 9. 配置到运行时的唯一权威链
|
||||
|
||||
必须建立以下单向链,禁止旁路:
|
||||
|
||||
```text
|
||||
published workflow version + ready template versions
|
||||
↓ creator configures / preview / confirms
|
||||
immutable project workflow snapshot
|
||||
↓ creates ordered stage/task instances + duty assignments
|
||||
active stage + authorized doc task instance
|
||||
↓ resolves executor, template, inputs, actor obligations
|
||||
task run + DocGov document version + lineage + audit
|
||||
↓ deterministic gate + three-duty approval + no stale/open work
|
||||
ready_to_advance notification
|
||||
↓ creator explicit confirmation
|
||||
next stage OR workflow completed
|
||||
```
|
||||
|
||||
具体约束:
|
||||
|
||||
1. `POST task-runs` 接收 `doc_task_instance_id` 和业务输入,不接收可信 `user_id/role/stage/work_dir/reviewer_id/approver_id`。
|
||||
2. 服务端锁定实例和 task instance,验证 active stage、actor、executor、模板和当前状态。
|
||||
3. WorkDir 由服务端按 project/instance/task_run 生成并做路径约束。
|
||||
4. DocGov 从 duty assignment 创建/校验文档责任人,stage 来自 stage instance。
|
||||
5. 任务结果写入与运行终态、文档版本、manifest、lineage、outbox 使用事务/可恢复 saga,避免“显示完成但产物未落稳”。
|
||||
6. 列表、详情、导出、附件、搜索、SSE、RAG 和 Agent tool read 都使用同一资源授权上下文。
|
||||
|
||||
## 10. 数据库迁移方案
|
||||
|
||||
迁移号以合并时最新 Goose 版本为准,下表暂按 `00021+` 表示。
|
||||
|
||||
| 迁移 | 主要变更 | 兼容/回填 |
|
||||
|---|---|---|
|
||||
| 00021 IAM hardening | project_members.user_id FK;role/binding 状态、reason;审计 actor;权限键 alias | 校验孤儿成员后加 FK;旧 `users.role_id` 双读一版、停止写入 |
|
||||
| 00022 project roles | `project_roles`、`project_role_permissions`、`project_user_role_bindings` | owner/member/viewer 映射为 owner/contributor/observer;原表保留 membership 状态 |
|
||||
| 00023 settings | user preferences、notification preferences、security policy versions | 把可识别的 local preferences 一次性导入需用户确认;其余使用服务端默认 |
|
||||
| 00024 workflow schema v2 | executor registry/version、schema fields、policy JSON schema/version | 不修改 v1 JSON;validator 按 schema_version 路由 |
|
||||
| 00025 instance integrity | stage position、task executor/template/output snapshot、duty eligibility、change requests | 修正现有 position;对 active 数据先报告后修复,不静默重排 |
|
||||
| 00026 authorization audit | `authorization_audit_events` 或统一 audit_event 扩展、policy version、request ID | 明确保留期和敏感字段脱敏 |
|
||||
| 00027 workflow v2 manifests | 创建三个 v2 draft/版本 manifest | 只在 hash 匹配预期时创建;不覆盖用户自定义版本 |
|
||||
|
||||
数据库约束建议:
|
||||
|
||||
- live instance 部分唯一索引继续保留;增加 planning 与 live 状态转换约束测试。
|
||||
- `project_stage_instances(workflow_instance_id, position)` unique,position > 0。
|
||||
- task instance 保存 `executor_key/template_version/output_doc_type/quality_gate_key` 的冻结副本。
|
||||
- duty assignment 引用 active project membership;跨表动态条件用事务 trigger 或 service + deferred validation 实现。
|
||||
- activation token 只存 hash;高风险一次性 token 使用短 TTL、actor/project/plan hash 绑定。
|
||||
- published definition/template 通过数据库权限或 trigger 阻止 UPDATE/DELETE,不只依赖 Go service。
|
||||
|
||||
## 11. API 调整
|
||||
|
||||
### 11.1 身份与设置
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login -> Set-Cookie
|
||||
POST /api/v1/auth/logout -> revoke current session
|
||||
GET /api/v1/me
|
||||
PATCH /api/v1/me/profile
|
||||
GET/PUT /api/v1/me/preferences
|
||||
GET/PUT /api/v1/me/notification-preferences
|
||||
GET /api/v1/me/sessions
|
||||
DELETE /api/v1/me/sessions/{id}
|
||||
POST /api/v1/me/password/change
|
||||
```
|
||||
|
||||
### 11.2 权限与管理
|
||||
|
||||
```text
|
||||
GET /api/v1/admin/permissions
|
||||
GET/POST/PATCH /api/v1/admin/roles...
|
||||
GET/POST/DELETE /api/v1/admin/role-bindings...
|
||||
POST /api/v1/admin/authorization/simulate
|
||||
GET /api/v1/admin/audit-events
|
||||
POST /api/v1/admin/users/{id}/reset-password
|
||||
POST /api/v1/admin/users/{id}/sessions/revoke
|
||||
```
|
||||
|
||||
每个 admin endpoint 绑定独立 permission,不再通过 `requireSystemAdmin` 全包。
|
||||
|
||||
### 11.3 工作流与项目实例
|
||||
|
||||
```text
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/copy-draft
|
||||
PATCH /api/v1/admin/workflows/{id}/versions/{v}
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/validate
|
||||
GET /api/v1/admin/workflows/{id}/versions/{v}/impact
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/publish
|
||||
POST /api/v1/admin/workflows/{id}/versions/{v}/retire
|
||||
|
||||
GET/PUT /api/v1/projects/{id}/workflow-plan
|
||||
POST /api/v1/projects/{id}/workflow-plan/preview
|
||||
POST /api/v1/projects/{id}/workflow-plan/activate
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/termination-requests
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/terminate
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/change-requests
|
||||
POST /api/v1/projects/{id}/workflow-instances/{iid}/stages/{stage}/advance
|
||||
POST /api/v1/projects/{id}/doc-tasks/{taskInstanceId}/runs
|
||||
```
|
||||
|
||||
所有资源路径先验证 path 中 project/instance/stage/task/document 的归属一致性,避免用合法项目权限访问另一个项目的子资源。
|
||||
|
||||
## 12. 前端实施方案
|
||||
|
||||
1. **认证壳**:不从 `localStorage` 恢复 principal/token;启动时只调用 `/me`。401 清空全部敏感状态并跳转登录。
|
||||
2. **权限 Provider**:从 `/me` 获取 capabilities + scoped grants;按钮、菜单和路由只做 UX 控制,错误统一展示服务端 reason code。
|
||||
3. **用户设置**:现有 modal 拆到真实 API;接入活动会话;资料/偏好保存失败不更新本地成功态。
|
||||
4. **系统管理中心**:从单一大 modal 演进为路由页面;权限矩阵来自 catalog;高风险变更提供 diff、影响人数和二次确认。
|
||||
5. **工作流设计器**:线性阶段排序、每阶段多任务、executor 只从注册表下拉、模板版本选择、职责候选、参与/可见策略、JSON 高级视图、发布校验报告。
|
||||
6. **项目设置**:明确 planning/live/history 三态;live 快照只读;门禁列表展示每个阻断项;下一工作流必须由创建人确认。
|
||||
7. **工作台**:根据服务端返回的 allowed actions 渲染,不在前端用姓名/本地角色重新计算业务授权。
|
||||
8. **删除演练入口**:`UserProjectRightsModal` 的测试角色切换、旧 `SettingsModal`、本地模拟分支和生产 bundle 中的测试身份文案移出产品代码。
|
||||
|
||||
## 13. 分阶段交付计划
|
||||
|
||||
估时为单个熟悉代码库的小组工作日,安全修复与工作流建模可并行开发,但发布必须按闸门顺序。
|
||||
|
||||
### Phase 0:基线冻结与权限清单(1–2 天)
|
||||
|
||||
- 固定 commit、DB schema、环境和三个 workflow v1 hash。
|
||||
- 自动导出全部 `/api/v1` 路由,建立 resource/action/owner/negative test 矩阵。
|
||||
- 修正当前 E2E 报告状态矛盾;归档一次干净的 short test 和 build 结果。
|
||||
- 输出:`authorization-matrix-v2.md`、migration preflight SQL、测试数据拓扑。
|
||||
|
||||
门禁:每个非公开路由都有责任 owner、策略和至少一个未授权负例;未知路由默认为拒绝。
|
||||
|
||||
### Phase 1:P0 授权止血(3–5 天)
|
||||
|
||||
- 修复 AUTH-001–009、WF-001/002 的越权入口。
|
||||
- 建立统一 `Authorize`/ResourceRef 和 server-side allowed actions。
|
||||
- 删除固定管理员/演示项目 ID 豁免;生产禁用 permissive fallback。
|
||||
- Cookie/CSRF/logout/401 前端失效闭环。
|
||||
|
||||
门禁:匿名、跨用户、跨项目、跨阶段、错 task、错 child resource、DB authz fault 全部负例通过;SSE/RAG/Agent tool 同样验证。
|
||||
|
||||
### Phase 2:IAM 与设置权威化(4–6 天)
|
||||
|
||||
- 完成 IAM-101–105 和用户/项目/系统设置 API。
|
||||
- 角色多绑定、作用域/有效期、permission catalog、原子用户变更和审计。
|
||||
- 用户资料、偏好、通知、活动会话 UI;管理中心权限拆分。
|
||||
|
||||
门禁:重启/换浏览器数据一致;权限收回和账号停用下一请求生效;至少两名管理员防锁死演练通过。
|
||||
|
||||
### Phase 3:工作流 Schema v2 与运行时合流(5–8 天)
|
||||
|
||||
- 实现 schema v2、executor/quality gate registry、完整 validator。
|
||||
- 移除 `flattenVersion` 作为执行路径;编排器只读实例快照。
|
||||
- 修复 position、token 原子性、DocGov duty/stage 绑定、stale 门禁。
|
||||
- 完成受控终止、变更单和下一个工作流生命周期。
|
||||
|
||||
门禁:修改全局 published/latest 不影响 active 实例;请求伪造 stage/task/duty 无效;故障恢复后实例位置和职责不漂移。
|
||||
|
||||
### Phase 4:发布三个 v2 工作流(4–6 天)
|
||||
|
||||
- 按 §8 创建 `main`、`product_manager`、`test_manager` v2 draft。
|
||||
- 绑定 ready 模板和 executor;补齐结构化门禁。
|
||||
- 对 planning v1 项目生成可读 diff,由创建人选择是否升级;active v1 不迁移。
|
||||
- 完成三个流程的串联推荐,但保持人工选择/激活。
|
||||
|
||||
门禁:三个版本通过静态校验、dry-run、单流程 PG E2E 和串联 PG E2E。
|
||||
|
||||
### Phase 5:管理端/项目端完整体验(4–6 天)
|
||||
|
||||
- 工作流设计器、模板库、项目规划、门禁详情、影响预览、下一流程、终止/变更 UI。
|
||||
- 删除旧本地/演示权威路径和不实合规文案。
|
||||
- 可访问性、错误恢复、并发 diff/reconfirm。
|
||||
|
||||
门禁:产品经理无需改代码即可复制 v2 为 v3 草案、换模板/职责候选、校验并由有权者发布;普通管理员无法越权发布。
|
||||
|
||||
### Phase 6:发布验证与灰度(5–8 天)
|
||||
|
||||
- 全量 Go/前端、race、PG、迁移升级/回滚、重启、worker 故障注入。
|
||||
- 真实 PI Agent 三工作流 E2E;长任务超时/恢复/取消;跨用户负例。
|
||||
- 备份恢复、日志脱敏、监控告警、容量和 bundle 分包。
|
||||
- 灰度一个内部项目,再扩到新项目;旧项目只读兼容指标归零后删除 fallback。
|
||||
|
||||
门禁:§14 全部完成;P0/P1 为 0;可恢复演练和回滚演练均有报告。
|
||||
|
||||
总体建议:**21–35 个小组工作日**。如多人并行,最短日历时间约 4–5 周;不应通过跳过负例、迁移和真实 Runner 验证压缩关键路径。
|
||||
|
||||
## 14. 测试与验收矩阵
|
||||
|
||||
### 14.1 授权负例
|
||||
|
||||
| 场景 | 预期 |
|
||||
|---|---|
|
||||
| 未登录访问任意非公开 API/SSE/附件 | 401,不泄漏资源存在性 |
|
||||
| A 项目成员读 B 项目、run history、lineage、搜索、导出 | 403/404,审计 deny |
|
||||
| 当前阶段 BD 请求研发 task key | 403;不创建 task_run/workdir/doc |
|
||||
| 非职责人提交/审核/批准 | 403;状态、版本、审计不变 |
|
||||
| author 尝试审核或批准同一版本 | 409/403 SoD violation |
|
||||
| authz DB/策略加载故障 | 敏感路径拒绝,健康状态 degraded,触发告警 |
|
||||
| 权限撤销后复用旧页面/旧 SSE/旧 Cookie | 下一次鉴权失败;流关闭或重新鉴权 |
|
||||
| 猜测 task/document/template/blob ID | 不能跨项目读取 |
|
||||
| 客户端伪造 user/role/stage/workdir/duty | 服务端忽略或 400;不得改变判定 |
|
||||
|
||||
### 14.2 工作流配置与冻结
|
||||
|
||||
- v2 required stage/task 被遗漏、禁用、重复或错序时校验失败。
|
||||
- executor 未注册、模板非 ready、变量 schema 不匹配时禁止发布。
|
||||
- published version UPDATE/DELETE 在 service 和 DB 层均失败。
|
||||
- 激活 token 重放、过期、跨 actor、跨 project、plan hash 不同均失败。
|
||||
- 并发保存/激活/推进只成功一个;另一方收到 diff/reconfirm,不自动重试。
|
||||
- 修改 v3 不影响使用 v2 的 active 项目;历史 hash 可复算。
|
||||
- 流程未完成/未终止时不能创建第二个 live;完成后可配置下一个 planning。
|
||||
|
||||
### 14.3 三工作流 E2E
|
||||
|
||||
**商务对接:** 按 V0.1 → V0.5 三任务 → V1.0 运行;每阶段文档分别走三权;未全部 approved 不产生 ready;满足后产生一次去重提醒;只有创建人确认推进。
|
||||
|
||||
**智能产品经理:** 从批准的输入基线生成 `requirements.json` 和正文;随机抽取、重复 ID、缺验证准则、正文/JSON 不一致均被确定性门禁拒绝;批准后可作为下游输入。
|
||||
|
||||
**智能测试经理:** 没有 approved SR、SR stale、hash 不匹配时拒绝启动;生成必需 `test_cases.json`;需求全集覆盖率必须 100%;上游更新后下游立即 stale 且不能完成。
|
||||
|
||||
**串联:** `main` 完成后只推荐 PM;创建人配置并激活;PM 完成后只推荐 Test;每个实例版本、模板、职责、输入 hash、输出 hash 和审计链可追溯。
|
||||
|
||||
### 14.4 运行与灾难恢复
|
||||
|
||||
- 应用、worker、PostgreSQL、PI Runner 分别重启,验证会话/任务/实例/Outbox 恢复。
|
||||
- PI Runner 超时后 task 可恢复或明确失败,不重复生成审批版本。
|
||||
- Outbox 重投不重复发阶段提醒;消费者幂等。
|
||||
- 迁移从 v20 的生产快照升级;备份恢复后 hash/权限/实例一致。
|
||||
- `go test -race`、前端测试、生产 build、依赖/镜像扫描、长期 soak。
|
||||
|
||||
## 15. 发布完成定义(Definition of Done)
|
||||
|
||||
只有同时满足以下条件,才能宣布“权限、设置和三个工作流已按当前设计做全”:
|
||||
|
||||
- [ ] 生产产品不存在任意身份切换、本地认证 fallback 或客户端身份权威。
|
||||
- [ ] 每个 API/SSE/RAG/Agent tool/附件通路都有服务端授权和跨用户负例。
|
||||
- [ ] 授权失败或依赖异常默认拒绝;不存在固定用户 ID、项目 ID 特权。
|
||||
- [ ] 系统角色、项目业务角色、文档三权分离且可审计;三权职责分离生效。
|
||||
- [ ] 用户、项目、系统三类设置均由服务端持久化,刷新/重启/换端一致。
|
||||
- [ ] 工作流、模板、权限管理使用各自细粒度 permission,而不是全员超级管理员。
|
||||
- [ ] 已发布工作流/模板不可变;项目激活冻结版本、模板、阶段、任务、策略和职责。
|
||||
- [ ] 同一项目最多一个 live;完成/受控终止后由创建人选择下一个。
|
||||
- [ ] 门禁满足只通知,不自动推进;创建人确认与推进在事务中完成。
|
||||
- [ ] `main` v2 完整执行 V0.1/V0.5/V1.0,不再由硬编码目录另行决定。
|
||||
- [ ] `product_manager` v2 结构化需求门禁通过;`test_manager` v2 实现 100% 需求覆盖和 stale 阻断。
|
||||
- [ ] DocGov 的 stage 和三权全部来自冻结实例,客户端不能覆盖。
|
||||
- [ ] PG、真实 PI Agent、重启恢复、故障注入、跨项目/跨阶段负例均有可复现报告。
|
||||
- [ ] P0/P1 缺陷归零,灰度/回滚/监控/告警和审计查询可用。
|
||||
|
||||
## 16. 风险与控制
|
||||
|
||||
| 风险 | 概率/影响 | 控制 |
|
||||
|---|---|---|
|
||||
| v1/v2 双执行权威并存 | 高/高 | feature flag 只选一个 orchestrator;记录每次 run 的 schema/executor/version;不静默 fallback |
|
||||
| 存量项目职责/成员数据不完整 | 高/中 | preflight 报告;active 不强迁;planning 由创建人补全并确认 |
|
||||
| 权限收紧影响现有演示项目 | 高/中 | 显式 legacy visibility 数据迁移;不保留 ID 豁免;灰度观察 deny 指标 |
|
||||
| Cookie 改造引入 CSRF | 中/高 | SameSite + CSRF token + Origin/Referer 校验;状态变更不用 GET |
|
||||
| 长 Agent 运行与 HTTP 超时 | 高/高 | 异步 task + lease/heartbeat + resumable worker;客户端订阅状态,不持有超长请求 |
|
||||
| 结构化质量门禁误报 | 中/中 | schema version、明确错误定位、golden corpus、允许修复重跑但不可人工静默跳过 |
|
||||
| 角色配置锁死管理员 | 中/高 | 双管理员、关键权限不可清空、变更影响预览、break-glass 离线流程 |
|
||||
| 模板恶意文件/解析炸弹 | 中/高 | 隔离、扩展/MIME/签名、大小/解压限制、扫描、Web root 外 CAS;仅 ready 可引用 |
|
||||
|
||||
## 17. 建议的首个迭代任务包
|
||||
|
||||
第一周不要先画完整工作流设计器,先完成以下可独立验收的任务:
|
||||
|
||||
1. `AUTH-001`:给 run history/lineage 及所有项目子资源补统一对象授权和双用户 PG 负例。
|
||||
2. `AUTH-002`:阶段访问错误全部 fail closed,加入数据库故障注入测试。
|
||||
3. `AUTH-003`:workflow run/task run 改从 task instance 推导 task/stage/executor,客户端覆盖返回 400。
|
||||
4. `AUTH-004/005`:补必需项反向校验和 duty member/status/role eligibility 校验。
|
||||
5. `AUTH-006/007`:删除 ID 特判,项目列表改用 `ListVisibleProjectIDs`/membership join,创建项目校验 permission。
|
||||
6. `AUTH-009`:服务端 logout + `/me` 失效清理;设计 Cookie/CSRF 兼容迁移。
|
||||
7. `WF-001/002`:提交 schema v2 ADR 和 runtime authority contract,阻止新增旁路。
|
||||
|
||||
首周验收物应是:代码、迁移 preflight、授权矩阵、负例测试、更新后的安全报告。只有该闸门通过,才开始发布三个 v2 draft。
|
||||
|
||||
## 18. 交付物清单
|
||||
|
||||
- 本实施方案。
|
||||
- API 授权矩阵 v2(路由、动作、资源、策略、负例、owner)。
|
||||
- Schema v2 JSON Schema + executor/quality gate registry ADR。
|
||||
- Goose 00021+ 迁移和 preflight/rollback SQL。
|
||||
- 三个工作流 v2 manifest、模板版本清单和 golden fixtures。
|
||||
- 用户/项目/系统设置产品规格与 API contract。
|
||||
- 授权负例、PG、真实 PI Agent、迁移、恢复、soak 测试报告。
|
||||
- 灰度清单、监控面板、告警规则、回滚 runbook。
|
||||
|
||||
## 资料来源
|
||||
|
||||
[^1]: 当前治理原则与目标模型见 [2026-09-11-production-ready-workflow-governance-plan.md](./2026-09-11-production-ready-workflow-governance-plan.md)。
|
||||
[^2]: IAM、工作流定义、项目实例、通知和任务持久化分别见 [00015 IAM](../deploy/postgres/goose/00015_2026-09-12-iam-core.sql)、[00016 workflow definitions](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql)、[00017 project instances](../deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql)、[00018 outbox](../deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql)、[00019 task runs](../deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql) 和 [00020 worker lease](../deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql)。
|
||||
[^3]: 本轮验证命令与结果记录在本方案 §2.2;当前历史测试状态见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。
|
||||
[^4]: 系统能力查询没有限定 binding scope/role status/valid_from,且 role-admin 字符串直接判 admin,见 [internal/authz/service.go](../internal/authz/service.go#L26-L99)。
|
||||
[^5]: 创建项目只验证身份,见 [project_handlers.go](../internal/server/project_handlers.go#L16-L46);运行历史和血缘查询未做项目访问判断,见同文件 [run history/lineage](../internal/server/project_handlers.go#L221-L274)。
|
||||
[^6]: 工作流管理统一走 `requireSystemAdmin`,没有使用已经入库的 `workflow_defs.*`,见 [admin_workflow_handlers.go](../internal/server/admin_workflow_handlers.go#L13-L23);权限种子见 [00016](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql#L67-L82)。
|
||||
[^7]: `ResolveStageAccess` 在 live 查询错误时返回 unrestricted,且一个阶段任一职责会放开该阶段全部任务,见 [stage_access.go](../internal/projectinstance/stage_access.go#L15-L68);服务器过滤辅助函数也在错误时放行,见 [stage_access_helpers.go](../internal/server/stage_access_helpers.go#L23-L47)。
|
||||
[^8]: 任务门禁在 actor 能访问 active stage 时放过不匹配 task,见 [bizdev_stage_gate.go](../internal/server/bizdev_stage_gate.go#L40-L54);workflow run 接收客户端 task override,见 [project_handlers.go](../internal/server/project_handlers.go#L205-L218)。
|
||||
[^9]: token/session 存 localStorage 且 logout 只删除本地键,见 [web/src/api/auth.ts](../web/src/api/auth.ts#L306-L330);恢复 session 后 `/me` 失败被吞掉,见 [web/src/App.tsx](../web/src/App.tsx#L690-L703)。
|
||||
[^10]: 用户设置把偏好描述为浏览器本地并从前端角色推导权限,见 [UserSettingsModal.tsx](../web/src/components/user/UserSettingsModal.tsx#L68-L77) 与 [同文件](../web/src/components/user/UserSettingsModal.tsx#L673-L779);后端已有活动会话 API,见 [auth_handlers.go](../internal/server/auth_handlers.go#L162-L204)。
|
||||
[^11]: 三个单阶段种子见 [workflowdef/bootstrap.go](../internal/workflowdef/bootstrap.go#L63-L74);seed 遇到已有发布版本直接退出,见 [workflowdef/store.go](../internal/workflowdef/store.go#L364-L372)。
|
||||
[^12]: DBCatalog 只取首阶段/首任务,见 [workflowdef/catalog.go](../internal/workflowdef/catalog.go#L50-L82);现有 schema 能表达的字段见 [workflowdef/types.go](../internal/workflowdef/types.go#L15-L43)。
|
||||
[^13]: 商务流程真实阶段和任务仍由 [internal/bizdev/service.go](../internal/bizdev/service.go#L202-L223) 硬编码。
|
||||
[^14]: 规划 validator 只遍历客户端提交项,没有反向验证必需定义项是否缺失,见 [projectinstance/validator.go](../internal/projectinstance/validator.go#L37-L105);三权只校验非空和互斥,见 [同文件](../internal/projectinstance/validator.go#L108-L144)。
|
||||
[^15]: 阶段实例 position 固定写 0,见 [projectinstance/store.go](../internal/projectinstance/store.go#L244-L249);执行器默认 reviewer=2、approver=3 并固定阶段,见 [bizdev/service.go](../internal/bizdev/service.go#L383-L418)。
|
||||
[^16]: 当前报告的真实 PI Agent E2E 记录 `/v1/run` 超时,同时结论仍称“进行中”,见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。
|
||||
[^17]: [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)。
|
||||
[^18]: [NIST Role Based Access Control](https://csrc.nist.gov/Projects/Role-Based-Access-Control) 与 [NIST SP 800-162 ABAC](https://csrc.nist.gov/pubs/sp/800/162/upd2/final)。
|
||||
[^19]: [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。
|
||||
[^20]: 产品经理和测试经理的 Agent 角色及职责见 [squad/workspace.go](../internal/squad/workspace.go#L648-L743),内部步骤见 [同文件](../internal/squad/workspace.go#L940-L1073)。
|
||||
[^21]: 当前产品/测试质量门禁见 [squad/quality_gate.go](../internal/squad/quality_gate.go#L108-L146)。
|
||||
[^22]: 测试经理对 approved SR 的前置校验和材料注入见 [bizdev/service.go](../internal/bizdev/service.go#L365-L381),stale 传播见 [projectworkflow/service.go](../internal/projectworkflow/service.go#L237-L265)。
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR:工作流运行时唯一权威链
|
||||
|
||||
## 状态
|
||||
|
||||
已接受(Phase 1 入口封堵;Phase 3 完整编排器)
|
||||
|
||||
## 背景
|
||||
|
||||
配置(published version + 模板)与运行时(BizDev 硬编码阶段、flatten catalog、客户端 task_key/三权覆盖)存在双权威,导致已发布多阶段定义无法按设计执行。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **禁止新增 flatten 旁路**;`DBCatalog.flattenVersion` 仅用于 legacy 列表,不得作为 task run 执行依据。
|
||||
2. **Task run 输入**:`doc_task_instance_id` + 业务 payload;拒绝客户端 `task_key`/三权/stage/work_dir 作为授权依据。
|
||||
3. **DocGov**:stage 与 author/reviewer/approver 来自冻结实例 duty assignment。
|
||||
4. **失败策略**:授权/实例解析错误 fail closed(非 `AllowInMemory` 环境)。
|
||||
|
||||
## 后果
|
||||
|
||||
- Phase 3 必须交付 instance orchestrator 并移除 BizDev `SupportedStages` 作为事实源。
|
||||
- Phase 4 通过 v2 manifest 发布,不覆盖 v1 published hash。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 授权实施基线快照(Phase 0)
|
||||
|
||||
- **分支**:`dev`
|
||||
- **Commit SHA**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`
|
||||
- **Goose schema**:`00020`(`00020_2026-09-12-task-run-worker-lease.sql`)
|
||||
- **规格文档**:`.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md`
|
||||
|
||||
## 环境矩阵
|
||||
|
||||
| 环境 | 分支 | Compose | HTTP | DB | 数据路径 |
|
||||
|---|---|---|---|---|---|
|
||||
| 开发 | dev | agent-dev | 8080 | 5433 | data/dev |
|
||||
| 测试 | test | agent-test | 8081 | 5434 | data/test |
|
||||
| 生产 | main | agent-main | 8000 | 5435 | data/main |
|
||||
| 共享 KB | — | agent-infra | — | 5432 | data/shared/kb |
|
||||
|
||||
## 工作流 v1 种子(published)
|
||||
|
||||
| workflow_key | 说明 | 备注 |
|
||||
|---|---|---|
|
||||
| `main` | 商务对接 v1 单阶段包装 | 真实阶段仍在 bizdev 硬编码 |
|
||||
| `product_manager` | 产品经理 v1 单阶段 | 未驱动 squad 五步 |
|
||||
| `test_manager` | 测试经理 v1 单阶段 | 未驱动结构化 coverage |
|
||||
|
||||
v2 将以新 version 发布,不覆盖 v1 hash。
|
||||
|
||||
## 验证命令(基线通过)
|
||||
|
||||
```bash
|
||||
go test -short -timeout 8m \
|
||||
./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \
|
||||
./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/...
|
||||
|
||||
cd web && npm test && npm run build
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# API 授权矩阵 v2(Phase 0 基线)
|
||||
|
||||
> 基线 commit:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`
|
||||
> 策略:默认拒绝;未知路由拒绝;跨项目 403/404 统一。
|
||||
|
||||
| 路由 | Resource | Action | Owner | 负例(未授权) |
|
||||
|---|---|---|---|---|
|
||||
| POST /api/v1/auth/login | session | create | auth | 匿名可访问(公开) |
|
||||
| GET /api/v1/auth/me | user | read_self | auth | 401 无 token |
|
||||
| GET /api/v1/projects | project | list | projectworkflow | 401 匿名 |
|
||||
| POST /api/v1/projects | project | create | projectworkflow | 401;403 无 project:create |
|
||||
| GET /api/v1/projects/{id} | project | read | authz | 403 非成员 |
|
||||
| GET /api/v1/projects/{id}/lineage | lineage | read | projectworkflow | 403 跨项目 |
|
||||
| GET /api/v1/projects/{id}/workflows/{key}/runs | workflow_run | list | projectworkflow | 403 跨项目 |
|
||||
| POST /api/v1/projects/{id}/workflows/{key}/run | task | run | bizdev+stage | 403 错 task/stage |
|
||||
| GET /api/v1/tasks/runs/{id} | task_run | view | taskrun | 403 非 owner/非 owner 成员 |
|
||||
| POST /api/v1/tasks/runs/{id}/cancel | task_run | cancel | taskrun | 403 取消他人 |
|
||||
| POST /api/v1/chat/stream | chat | send | chat+stage | 403 无阶段权限 |
|
||||
| GET /api/v1/docgov/* | document | * | docgov+duty | 403 非职责人 |
|
||||
| GET /api/v1/admin/* | admin | * | authz RBAC | 403 非 admin/细粒度 perm |
|
||||
| GET /health, /api/v1/health | system | health | server | 公开 |
|
||||
|
||||
完整路由枚举见 `internal/server/server.go` Routes();Phase 1 起每个项目子资源必须经 `requireProjectAccess`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 授权负例测试数据拓扑(Phase 0)
|
||||
|
||||
## Fixture A:双用户跨项目
|
||||
|
||||
| 实体 | 值 |
|
||||
|---|---|
|
||||
| User A | `zhanggong`(项目 owner) |
|
||||
| User B | `lisi` 或独立测试用户(非成员) |
|
||||
| Project A | A 创建,`creator_user_id = A` |
|
||||
| Project B | B 不可见项目 |
|
||||
|
||||
**负例**:B 调用 `GET /api/v1/projects/{A}/lineage`、`GET .../workflows/main/runs` → 403/404。
|
||||
|
||||
## Fixture B:同项目阶段职责
|
||||
|
||||
| 实体 | 值 |
|
||||
|---|---|
|
||||
| Live instance | `main` v2(Phase 4 后)或 v1 + duty |
|
||||
| User BD | 仅 `V0.1_triage/intake` 三权 author |
|
||||
| User RD | 仅 `prelim_design` 职责 |
|
||||
|
||||
**负例**:BD 请求 `prelim_design` task_key → 403;非职责人 submit/review → 403。
|
||||
|
||||
## Fixture C:双管理员防锁死
|
||||
|
||||
| 实体 | 值 |
|
||||
|---|---|
|
||||
| Admin 1 | `role-admin` + `perm:manage` |
|
||||
| Admin 2 | 同等绑定 |
|
||||
|
||||
**演练**:Admin 1 停用 Admin 2 前 Admin 2 仍可操作;至少两名 active admin 存在。
|
||||
|
||||
## PG 测试入口
|
||||
|
||||
- `internal/server/project_members_authz_test.go`
|
||||
- `internal/server/phase5_acceptance_pg_test.go`
|
||||
- Phase 1 新增:`TestAuth001_CrossProjectLineageAndRunsDenied`
|
||||
@@ -0,0 +1,63 @@
|
||||
# 权限、设置与三类工作流六阶段实施测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
按六阶段总执行计划完成 Phase 0–6 首版落地:授权止血、IAM/设置 API、Schema v2 骨架、三工作流 manifest、前端 logout/401 闭环。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- Phase 0 交付物:基线、授权矩阵、preflight SQL
|
||||
- Phase 1 闸门 A:跨项目 lineage/runs 授权、fail closed、project:create、task run 分权
|
||||
- Phase 2–4:迁移 00021–23、设置 API、manifest/validator v2
|
||||
- 全量 Go short 测试与前端测试通过
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- **OS**:Linux,Go 1.27.1
|
||||
- **基线 commit**:实施分支 dev(实施后)
|
||||
- **PG**:goose 00021–00023(本地有 PG 时迁移验证)
|
||||
- **Agent**:stub(单元/集成);PI Agent E2E 留 Phase 6 真实重跑
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- `go test -short ./internal/...` 全绿
|
||||
- `cd web && npm test && npm run build` 通过
|
||||
- 匿名访问 lineage/runs → 401;跨项目 → 403
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### T1:Go 内部包 short 回归
|
||||
|
||||
- **Input**:`go test -short -count=1 -timeout 8m ./internal/...`
|
||||
- **Actual Output**:全部 **ok**(含 server、authz、projectinstance、workflowdef)
|
||||
- **Verdict**:**PASS**
|
||||
|
||||
### T2:闸门 A 负例
|
||||
|
||||
- **Input**:`TestGateA_LineageAndRunsRequireProjectAccess`、`TestGateA_CreateProjectRequiresPermission`
|
||||
- **Actual Output**:PASS
|
||||
- **Verdict**:**PASS**
|
||||
|
||||
### T3:Schema v2 manifest 加载
|
||||
|
||||
- **Input**:`go test ./internal/workflowdef/...`
|
||||
- **Actual Output**:ok(embed manifests 编译通过)
|
||||
- **Verdict**:**PASS**
|
||||
|
||||
### T4:Pi-Agent 三工作流 E2E
|
||||
|
||||
- **Input**:真实 PI Agent 全链路(计划 Phase 6 门禁)
|
||||
- **Actual Output**:沿用 2026-09-12 报告 FAIL(/v1/run 超时),本轮未重跑
|
||||
- **Verdict**:**待 Phase 6 重测**
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
```
|
||||
go test -short -count=1 ./internal/... → ALL PASS
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
1. 闸门 A 核心 P0 已关闭并具回归测试;Cookie/CSRF 全量迁移留后续迭代。
|
||||
2. Schema v2 manifest 与 orchestrator 骨架就绪;BizDev 硬编码阶段完全移除需在已激活 v2 实例上继续 E2E。
|
||||
3. Phase 6 必须:真实 PG 双用户负例、PI Agent 三工作流 E2E、迁移 00021+ 升级演练、灰度 runbook。
|
||||
@@ -10,7 +10,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
2. `go test -short ./...` 全绿(CI 门禁前提)
|
||||
3. CI 增加 `pg_restore` 全量恢复演练 job
|
||||
4. readiness soak 短循环 PASS
|
||||
5. pi-agent 真实全链路 E2E(进行中)
|
||||
5. pi-agent 真实全链路 E2E(独立重跑,带 commit SHA)
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
@@ -18,13 +18,14 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
- **PG**:`shuimu-context-pg-dev` @ 127.0.0.1:5433,goose v20
|
||||
- **Pi-Agent**:`http://127.0.0.1:18865/health` OK
|
||||
- **App**:本机 `:8080` 旧进程(无 `/api/v1/ready`,health 可用)
|
||||
- **基线 commit**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`(dev)
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- PG 测试登录 `zhanggong` 返回 200
|
||||
- 全量 short 测试 0 FAIL
|
||||
- `TestPhase5_SoakReadinessLoop_Short` 8/8 ready OK
|
||||
- Pi-Agent E2E 全生命周期 PASS
|
||||
- Pi-Agent E2E 全生命周期 PASS(或明确 FAIL 并记录根因)
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
@@ -61,6 +62,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
--- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (1980.22s)
|
||||
```
|
||||
- **Verdict**:**FAIL**(工作流阶段 pi-agent `/v1/run` 调用超时,非 stub 对话路径)
|
||||
- **状态说明**:本轮已结束,**非进行中**。后续 Phase 6 需带 commit SHA 与环境指纹重跑并更新本条目。
|
||||
|
||||
### T7:Test 环境冷启动 + soak
|
||||
|
||||
@@ -97,6 +99,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong
|
||||
```
|
||||
go test -short -timeout 15m ./... → ALL PASS
|
||||
go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS
|
||||
TestPiAgent_FullLifecycle → FAIL (v1/run timeout, 见 T4)
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
@@ -104,4 +107,4 @@ go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS
|
||||
1. PG 种子用户与 CI 短测已稳定;`dev` 已推送至 origin(`cf9ad06`)。
|
||||
2. Dev 服务已重启,`/api/v1/ready` 与 60s soak(12/12)通过。
|
||||
3. test 环境 PG(`shuimu-system-pg-test` @ 5434)已启动,待拉起 app 服务(8081)做长期 soak。
|
||||
4. Pi-Agent E2E 仍在运行(需 `-v` 才有 t.Log 输出);完成后更新 T4 判定。
|
||||
4. **Pi-Agent E2E 已判定 FAIL**(`/v1/run` 超时),非“仍在运行”;Phase 6 门禁前须重跑并归档新报告。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- +goose Up
|
||||
-- Phase 2: IAM hardening — 成员 FK、演示账号 project:create、用户偏好表
|
||||
|
||||
ALTER TABLE project_members
|
||||
DROP CONSTRAINT IF EXISTS project_members_user_id_fkey;
|
||||
ALTER TABLE project_members
|
||||
ADD CONSTRAINT project_members_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE user_role_bindings
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE user_role_bindings
|
||||
ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 演示账号 zhanggong 需具备 project:create 以支撑 E2E(role-tech-lead)
|
||||
UPDATE users SET role_id = 'role-tech-lead'
|
||||
WHERE username = 'zhanggong' AND role_id = 'role-engineer';
|
||||
|
||||
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by, status)
|
||||
SELECT u.id, u.role_id, 'system', 0, u.id, 'active'
|
||||
FROM users u
|
||||
WHERE u.username IN ('zhanggong', 'lizhuguan', 'wangzong')
|
||||
ON CONFLICT (user_id, role_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
schema_version INT NOT NULL DEFAULT 1,
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
schema_version INT NOT NULL DEFAULT 1,
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_notification_preferences;
|
||||
DROP TABLE IF EXISTS user_preferences;
|
||||
|
||||
ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS reason;
|
||||
ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS status;
|
||||
|
||||
ALTER TABLE project_members DROP CONSTRAINT IF EXISTS project_members_user_id_fkey;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
-- Schema v2: executor registry metadata on workflow_versions (JSONB 已存 definition)
|
||||
|
||||
ALTER TABLE workflow_versions
|
||||
ADD COLUMN IF NOT EXISTS schema_version INT NOT NULL DEFAULT 1;
|
||||
|
||||
UPDATE workflow_versions SET schema_version = COALESCE((definition->>'schema_version')::int, 1)
|
||||
WHERE schema_version = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_executor_registry (
|
||||
executor_key TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO workflow_executor_registry (executor_key, description) VALUES
|
||||
('bizdev.intake.v1', '商务 intake'),
|
||||
('bizdev.research.v1', '竞品调研'),
|
||||
('bizdev.feasibility.v1', '法规可行性'),
|
||||
('bizdev.prelim_design.v1', '初步设计'),
|
||||
('bizdev.cdo_quote.v1', 'CDO 报价'),
|
||||
('squad.system_reqs.v1', '系统需求基线'),
|
||||
('squad.test_manager.v1', '测试经理验证策划')
|
||||
ON CONFLICT (executor_key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS workflow_executor_registry;
|
||||
ALTER TABLE workflow_versions DROP COLUMN IF EXISTS schema_version;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Instance integrity: stage position unique, frozen task metadata columns
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_stage_instance_position
|
||||
ON project_stage_instances (workflow_instance_id, position)
|
||||
WHERE position > 0;
|
||||
|
||||
ALTER TABLE project_doc_task_instances
|
||||
ADD COLUMN IF NOT EXISTS executor_key TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS template_version INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS quality_gate_key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE project_doc_task_instances
|
||||
DROP COLUMN IF EXISTS quality_gate_key,
|
||||
DROP COLUMN IF EXISTS template_version,
|
||||
DROP COLUMN IF EXISTS executor_key;
|
||||
|
||||
DROP INDEX IF EXISTS uq_stage_instance_position;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Preflight checks before Goose 00021+ IAM hardening migrations.
|
||||
-- Run against target environment; fix reported rows before applying FK constraints.
|
||||
|
||||
-- 1. Orphan project_members (user_id not in users)
|
||||
SELECT pm.id, pm.project_id, pm.user_id, pm.project_role, pm.status
|
||||
FROM project_members pm
|
||||
LEFT JOIN users u ON u.id = pm.user_id
|
||||
WHERE u.id IS NULL;
|
||||
|
||||
-- 2. Projects with NULL or demo creator (legacy public visibility)
|
||||
SELECT p.id, p.code, p.name, p.creator_user_id
|
||||
FROM projects p
|
||||
WHERE p.creator_user_id IS NULL OR p.creator_user_id = 1;
|
||||
|
||||
-- 3. Duty assignments without active project membership (after instances exist)
|
||||
SELECT d.id, d.doc_task_instance_id, d.author_user_id, d.reviewer_user_id, d.approver_user_id,
|
||||
wi.project_id
|
||||
FROM document_duty_assignments d
|
||||
JOIN project_doc_task_instances ti ON ti.id = d.doc_task_instance_id
|
||||
JOIN project_stage_instances si ON si.id = ti.stage_instance_id
|
||||
JOIN project_workflow_instances wi ON wi.id = si.workflow_instance_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM project_members pm
|
||||
WHERE pm.project_id = wi.project_id
|
||||
AND pm.user_id IN (d.author_user_id, d.reviewer_user_id, d.approver_user_id)
|
||||
AND pm.status = 'active'
|
||||
);
|
||||
|
||||
-- 4. Stage instances with invalid position (all zero)
|
||||
SELECT workflow_instance_id, stage_key, position, status
|
||||
FROM project_stage_instances
|
||||
GROUP BY workflow_instance_id, stage_key, position, status
|
||||
HAVING position = 0;
|
||||
|
||||
-- 5. Users with role bindings but inactive role
|
||||
SELECT b.user_id, b.role_id, r.status AS role_status
|
||||
FROM user_role_bindings b
|
||||
JOIN system_roles r ON r.id = b.role_id
|
||||
WHERE r.status IS NOT NULL AND r.status <> 'active';
|
||||
@@ -72,7 +72,7 @@ func BootstrapDevSeedUsers(ctx context.Context, store AuthStore, cfg *config.Con
|
||||
dept string
|
||||
empID string
|
||||
}{
|
||||
{"zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"},
|
||||
{"zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"},
|
||||
{"lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"},
|
||||
{"wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"},
|
||||
{"guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"},
|
||||
|
||||
@@ -22,6 +22,8 @@ type UserAccount struct {
|
||||
EmployeeID string `json:"employee_id,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
@@ -43,6 +45,8 @@ type Service struct {
|
||||
store AuthStore
|
||||
sessions SessionStore
|
||||
cfg *config.Config
|
||||
prefMu sync.RWMutex
|
||||
prefs map[int64]map[string]any
|
||||
}
|
||||
|
||||
// NewService 实例化认证服务;sessionStore 为空时使用进程内 MemorySessionStore。
|
||||
@@ -315,3 +319,43 @@ func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) {
|
||||
func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error {
|
||||
return s.store.UpdateConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
// UpdateUserProfile 更新当前用户资料字段。
|
||||
func (s *Service) UpdateUserProfile(ctx context.Context, user *UserAccount) (*UserAccount, error) {
|
||||
if user == nil || user.ID <= 0 {
|
||||
return nil, errors.New("invalid user")
|
||||
}
|
||||
updated, err := s.store.UpdateUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated.PasswordHash = ""
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// GetUserPreferences 读取用户偏好(内存/后续 PG user_preferences)。
|
||||
func (s *Service) GetUserPreferences(ctx context.Context, userID int64) (map[string]any, error) {
|
||||
s.prefMu.RLock()
|
||||
defer s.prefMu.RUnlock()
|
||||
if s.prefs == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if p, ok := s.prefs[userID]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// SaveUserPreferences 保存用户偏好。
|
||||
func (s *Service) SaveUserPreferences(ctx context.Context, userID int64, body map[string]any) (map[string]any, error) {
|
||||
if userID <= 0 {
|
||||
return nil, errors.New("invalid user id")
|
||||
}
|
||||
s.prefMu.Lock()
|
||||
defer s.prefMu.Unlock()
|
||||
if s.prefs == nil {
|
||||
s.prefs = make(map[int64]map[string]any)
|
||||
}
|
||||
s.prefs[userID] = body
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func NewMemoryStore(cfg *config.Config) *MemoryStore {
|
||||
dept string
|
||||
employeeID string
|
||||
}{
|
||||
{2, "zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"},
|
||||
{2, "zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"},
|
||||
{3, "lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"},
|
||||
{4, "wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"},
|
||||
{5, "guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"},
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestMemoryStore_DevEnvSeedsDemoUsers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected zhanggong in dev env: %v", err)
|
||||
}
|
||||
if user.RoleID != "role-engineer" {
|
||||
if user.RoleID != "role-tech-lead" {
|
||||
t.Fatalf("unexpected role: %s", user.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package authz
|
||||
|
||||
// PermissionCatalogEntry 权限目录项。
|
||||
type PermissionCatalogEntry struct {
|
||||
Key string `json:"key"`
|
||||
Resource string `json:"resource"`
|
||||
Action string `json:"action"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionCatalog 返回系统权限目录(与 DB seed 对齐,供前端渲染)。
|
||||
func PermissionCatalog() []PermissionCatalogEntry {
|
||||
return []PermissionCatalogEntry{
|
||||
{Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"},
|
||||
{Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"},
|
||||
{Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"},
|
||||
{Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"},
|
||||
{Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"},
|
||||
{Key: "doc:export", Resource: "document", Action: "export", RiskLevel: "normal"},
|
||||
{Key: "workflow:view_all", Resource: "workflow", Action: "view_all", RiskLevel: "normal"},
|
||||
{Key: "workflow_defs.publish", Resource: "workflow_defs", Action: "publish", RiskLevel: "elevated"},
|
||||
{Key: "templates.publish", Resource: "templates", Action: "publish", RiskLevel: "elevated"},
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ func (s *Service) enabled() bool {
|
||||
return s != nil && s.pool != nil
|
||||
}
|
||||
|
||||
// Enabled 暴露 RBAC 是否已连接数据库。
|
||||
func (s *Service) Enabled() bool {
|
||||
return s.enabled()
|
||||
}
|
||||
|
||||
// ListCapabilities 返回用户系统级 permission key 列表。
|
||||
func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, error) {
|
||||
if userID <= 0 {
|
||||
@@ -34,9 +39,13 @@ func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string,
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT p.perm_key
|
||||
FROM user_role_bindings b
|
||||
JOIN system_roles sr ON sr.id = b.role_id AND sr.status = 'active'
|
||||
JOIN system_role_permissions rp ON rp.role_id = b.role_id AND rp.effect = 'allow'
|
||||
JOIN system_permissions p ON p.id = rp.permission_id
|
||||
WHERE b.user_id = $1
|
||||
AND b.scope_type = 'system'
|
||||
AND b.scope_id = 0
|
||||
AND b.valid_from <= now()
|
||||
AND (b.valid_until IS NULL OR b.valid_until > now())
|
||||
ORDER BY p.perm_key
|
||||
`, userID)
|
||||
|
||||
@@ -282,6 +282,26 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLiveInstanceDetail 返回 live 实例详情与已发布版本定义(运行时编排入口)。
|
||||
func (s *Service) GetLiveInstanceDetail(ctx context.Context, projectID int64) (*InstanceDetail, *workflowdef.Version, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, nil, ErrNotAvailable
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return nil, nil, fmt.Errorf("live instance not found")
|
||||
}
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
version, err := s.resolvePublishedVersion(ctx, live.ConfigSnapshot)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return detail, version, nil
|
||||
}
|
||||
|
||||
func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, versionNum int) (*WorkflowPlan, error) {
|
||||
if s.wfStore == nil {
|
||||
return nil, ErrNotAvailable
|
||||
|
||||
@@ -30,7 +30,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out.Restricted = true
|
||||
@@ -61,7 +61,10 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
}
|
||||
out.AllowedStageKeys[stPlan.StageKey] = true
|
||||
for _, dt := range stPlan.DocTasks {
|
||||
if dt.Enabled {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
if isCreator || docTaskDutyForUser(dt, userID) {
|
||||
out.AllowedTaskKeys[dt.TaskKey] = true
|
||||
}
|
||||
}
|
||||
@@ -71,16 +74,19 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
|
||||
func stageDutyForUser(st StagePlan, userID int64) bool {
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
if dt.Duties.AuthorUserID == userID || dt.Duties.ReviewerUserID == userID || dt.Duties.ApproverUserID == userID {
|
||||
if dt.Enabled && docTaskDutyForUser(dt, userID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool {
|
||||
return dt.Duties.AuthorUserID == userID ||
|
||||
dt.Duties.ReviewerUserID == userID ||
|
||||
dt.Duties.ApproverUserID == userID
|
||||
}
|
||||
|
||||
// TaskKeyAllowed 判断 task_key 是否在允许范围内。
|
||||
func (a *StageAccess) TaskKeyAllowed(taskKey string) bool {
|
||||
if a == nil || !a.Restricted {
|
||||
|
||||
@@ -241,12 +241,13 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *
|
||||
return err
|
||||
}
|
||||
|
||||
for _, st := range inst.ConfigSnapshot.Stages {
|
||||
for i, st := range inst.ConfigSnapshot.Stages {
|
||||
position := (i + 1) * 10
|
||||
var stageID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status)
|
||||
VALUES ($1, $2, 0, $3, 'pending') RETURNING id
|
||||
`, inst.ID, st.StageKey, st.Enabled).Scan(&stageID)
|
||||
VALUES ($1, $2, $3, $4, 'pending') RETURNING id
|
||||
`, inst.ID, st.StageKey, position, st.Enabled).Scan(&stageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -102,6 +102,42 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V
|
||||
})
|
||||
}
|
||||
|
||||
// 反向完整性:定义中的必需阶段/任务必须在规划中出现且启用
|
||||
planStageMap := make(map[string]StagePlan, len(plan.Stages))
|
||||
for _, st := range plan.Stages {
|
||||
planStageMap[st.StageKey] = st
|
||||
}
|
||||
for _, def := range version.Definition.Stages {
|
||||
if def.Optional {
|
||||
continue
|
||||
}
|
||||
ps, ok := planStageMap[def.Key]
|
||||
if !ok || !ps.Enabled {
|
||||
issues = append(issues, workflowdef.ValidationIssue{
|
||||
Code: "MISSING_REQUIRED_STAGE", Field: def.Key,
|
||||
Message: "必需阶段未启用或缺失",
|
||||
})
|
||||
continue
|
||||
}
|
||||
planTaskEnabled := map[string]bool{}
|
||||
for _, dt := range ps.DocTasks {
|
||||
if dt.Enabled {
|
||||
planTaskEnabled[dt.TaskKey] = true
|
||||
}
|
||||
}
|
||||
for _, td := range def.DocTasks {
|
||||
if !(td.Required || td.Blocking) {
|
||||
continue
|
||||
}
|
||||
if !planTaskEnabled[td.Key] {
|
||||
issues = append(issues, workflowdef.ValidationIssue{
|
||||
Code: "MISSING_REQUIRED_TASK", Field: td.Key,
|
||||
Message: "必需/阻断文档任务未启用或缺失",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workflowdef.ValidationResult{Valid: len(issues) == 0, Issues: issues}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,16 +145,16 @@ func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project,
|
||||
}
|
||||
|
||||
func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) {
|
||||
// 允许查看项目(自己创建,或公开项目,或已有会话参与)
|
||||
// 仅创建人或 active 项目成员可见(移除 creator_id=1/NULL 公开规则)
|
||||
query := `
|
||||
SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market,
|
||||
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 1),
|
||||
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0),
|
||||
COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''),
|
||||
COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0),
|
||||
p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
LEFT JOIN chat_sessions cs ON cs.project_id = p.id AND cs.user_id = $1
|
||||
WHERE p.creator_user_id = $1 OR cs.id IS NOT NULL OR p.creator_user_id IS NULL OR p.creator_user_id = 1
|
||||
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = 'active'
|
||||
WHERE p.creator_user_id = $1 OR pm.user_id IS NOT NULL
|
||||
ORDER BY p.id DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, query, userID)
|
||||
@@ -652,7 +652,7 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr
|
||||
defer m.mu.RUnlock()
|
||||
var list []*Project
|
||||
for _, p := range m.projects {
|
||||
if p.CreatorUserID == userID || p.CreatorUserID == 0 || p.CreatorUserID == 1 {
|
||||
if p.CreatorUserID == userID {
|
||||
cpy := *p
|
||||
list = append(list, &cpy)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"chat-agent-system/internal/authz"
|
||||
)
|
||||
|
||||
// handleAdminPermissions GET /api/v1/admin/permissions — 权限目录(服务端权威)
|
||||
func (s *Server) handleAdminPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requirePrincipal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
catalog := authz.PermissionCatalog()
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{"permissions": catalog})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
func TestGateA_LineageAndRunsRequireProjectAccess(t *testing.T) {
|
||||
cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"}
|
||||
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
|
||||
srv.cfg = cfg
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/projects/999/lineage?doc_id=doc_x"},
|
||||
{http.MethodGet, "/api/v1/projects/999/workflows/main/runs"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s expected 401 without auth, got %d", tc.method, tc.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateA_CreateProjectRequiresPermission(t *testing.T) {
|
||||
cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"}
|
||||
srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}))
|
||||
srv.cfg = cfg
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -9,6 +10,40 @@ import (
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。
|
||||
func (s *Server) failClosedAuthz() bool {
|
||||
return s == nil || s.cfg == nil || !s.cfg.AllowInMemory
|
||||
}
|
||||
|
||||
func (s *Server) userHasSystemPermission(ctx context.Context, user *auth.UserAccount, permKey string) (bool, error) {
|
||||
if user == nil || permKey == "" {
|
||||
return false, nil
|
||||
}
|
||||
if s.isSystemAdminUser(ctx, user) {
|
||||
return true, nil
|
||||
}
|
||||
caps, err := s.authzSvc.ListCapabilities(ctx, user.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasPermInCapabilityList(caps, permKey) {
|
||||
return true, nil
|
||||
}
|
||||
if user.RoleID != "" && hasPermInCapabilityList(authz.FallbackCapabilitiesByRoleID(user.RoleID), permKey) {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func hasPermInCapabilityList(caps []string, permKey string) bool {
|
||||
for _, k := range caps {
|
||||
if k == permKey || k == authz.PermManage {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeAuthzError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, authz.ErrForbidden):
|
||||
|
||||
@@ -48,9 +48,6 @@ func (g *bizdevStageGate) AllowTaskExecution(ctx context.Context, projectID, use
|
||||
if taskKey != "" && access.TaskKeyAllowed(taskKey) {
|
||||
return nil
|
||||
}
|
||||
if access.StageKeyAllowed(access.ActiveStageKey) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: task %q", bizdev.ErrStageAccessDenied, taskKey)
|
||||
}
|
||||
|
||||
@@ -73,7 +70,10 @@ func (g *bizdevStageGate) FilterProjectContext(ctx context.Context, projectID, u
|
||||
return pCtx
|
||||
}
|
||||
access, err := g.access(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
return &ctxmgr.ProjectCollectiveContext{}
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return pCtx
|
||||
}
|
||||
out := *pCtx
|
||||
@@ -106,7 +106,10 @@ func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID
|
||||
return docs
|
||||
}
|
||||
access, err := g.access(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return docs
|
||||
}
|
||||
out := make([]squad.PriorDocItem, 0, len(docs))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type userProfilePatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Locale *string `json:"locale,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// handleMeProfile GET/PATCH /api/v1/me/profile
|
||||
func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"id": user.ID, "username": user.Username, "name": user.Name,
|
||||
"email": user.Email, "locale": user.Locale, "timezone": user.Timezone,
|
||||
"department": user.Department, "employee_id": user.EmployeeID, "position": user.Position,
|
||||
})
|
||||
case http.MethodPatch:
|
||||
var patch userProfilePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updated := *user
|
||||
if patch.Name != nil {
|
||||
updated.Name = *patch.Name
|
||||
}
|
||||
if patch.Email != nil {
|
||||
updated.Email = *patch.Email
|
||||
}
|
||||
if patch.Locale != nil {
|
||||
updated.Locale = *patch.Locale
|
||||
}
|
||||
if patch.Timezone != nil {
|
||||
updated.Timezone = *patch.Timezone
|
||||
}
|
||||
saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, saved)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMePreferences GET/PUT /api/v1/me/preferences
|
||||
func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requirePrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, prefs)
|
||||
case http.MethodPut:
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, prefs)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil {
|
||||
http.Error(w, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
} else if !ok {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权创建项目",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req projectworkflow.CreateProjectInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -206,12 +216,18 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
if req.TaskKey != "" && req.TaskKey != def.TaskKey && s.failClosedAuthz() {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"客户端不可覆盖 task_key"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.allowWorkflowDocTaskAccess(r.Context(), id, user.ID, def.TaskKey) {
|
||||
s.writeWorkflowAccessDenied(w)
|
||||
return
|
||||
}
|
||||
req.ProjectID = id
|
||||
req.WorkflowKey = wfKey
|
||||
req.UserID = user.ID
|
||||
if req.TaskKey == "" {
|
||||
req.TaskKey = def.TaskKey
|
||||
}
|
||||
req.TaskKey = def.TaskKey
|
||||
|
||||
bodyBytes, _ := json.Marshal(req)
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
@@ -226,6 +242,9 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireProjectAccess(w, r, id); !ok {
|
||||
return
|
||||
}
|
||||
wfKey := r.PathValue("workflow_key")
|
||||
runs, err := s.pwSvc.ListWorkflowRuns(r.Context(), id, wfKey, 20)
|
||||
if err != nil {
|
||||
@@ -240,6 +259,16 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re
|
||||
|
||||
// handleGetProjectLineage 查询项目文档血缘关系及失效状态
|
||||
func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
projectID, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if projectID <= 0 {
|
||||
http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
docID := strings.TrimSpace(r.URL.Query().Get("doc_id"))
|
||||
if docID == "" {
|
||||
idStr := r.PathValue("id")
|
||||
|
||||
@@ -251,7 +251,12 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout)
|
||||
mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword)
|
||||
mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe)
|
||||
mux.HandleFunc("GET /api/v1/me/profile", s.handleMeProfile)
|
||||
mux.HandleFunc("PATCH /api/v1/me/profile", s.handleMeProfile)
|
||||
mux.HandleFunc("GET /api/v1/me/preferences", s.handleMePreferences)
|
||||
mux.HandleFunc("PUT /api/v1/me/preferences", s.handleMePreferences)
|
||||
mux.HandleFunc("GET /api/v1/auth/sessions", s.handleAuthSessions)
|
||||
mux.HandleFunc("GET /api/v1/admin/permissions", s.handleAdminPermissions)
|
||||
mux.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.handleAuthSessionByID)
|
||||
mux.HandleFunc("GET /api/v1/admin/roles", s.handleAdminRoles)
|
||||
mux.HandleFunc("POST /api/v1/admin/roles", s.handleAdminRoles)
|
||||
|
||||
@@ -25,7 +25,10 @@ func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, user
|
||||
return true
|
||||
}
|
||||
access, err := s.resolveStageAccess(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
return !s.failClosedAuthz()
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return true
|
||||
}
|
||||
return access.TaskKeyAllowed(taskKey)
|
||||
@@ -33,7 +36,10 @@ func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, user
|
||||
|
||||
func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int64) bool {
|
||||
access, err := s.resolveStageAccess(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
return !s.failClosedAuthz()
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return true
|
||||
}
|
||||
return len(access.AllowedStageKeys) > 0
|
||||
@@ -41,7 +47,10 @@ func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int6
|
||||
|
||||
func (s *Server) allowActiveStageChatAccess(ctx context.Context, projectID, userID int64) bool {
|
||||
access, err := s.resolveStageAccess(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
return !s.failClosedAuthz()
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return true
|
||||
}
|
||||
return access.StageKeyAllowed(access.ActiveStageKey)
|
||||
@@ -67,7 +76,13 @@ func (s *Server) filterWorkflowDocsByStageAccess(
|
||||
return docs
|
||||
}
|
||||
access, err := s.resolveStageAccess(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
if s.failClosedAuthz() {
|
||||
return nil
|
||||
}
|
||||
return docs
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return docs
|
||||
}
|
||||
out := make([]docgov.WorkflowDocumentSummary, 0, len(docs))
|
||||
@@ -88,7 +103,13 @@ func (s *Server) filterChatMessagesByStageAccess(
|
||||
return msgs
|
||||
}
|
||||
access, err := s.resolveStageAccess(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
if s.failClosedAuthz() {
|
||||
return nil
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return msgs
|
||||
}
|
||||
out := make([]ctxmgr.ChatMessageRecord, 0, len(msgs))
|
||||
@@ -113,7 +134,13 @@ func (s *Server) filterChatSearchHitsByStageAccess(
|
||||
return hits
|
||||
}
|
||||
access, err := s.resolveStageAccess(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
if err != nil {
|
||||
if s.failClosedAuthz() {
|
||||
return nil
|
||||
}
|
||||
return hits
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return hits
|
||||
}
|
||||
out := make([]ctxmgr.ChatSearchHit, 0, len(hits))
|
||||
|
||||
@@ -7,7 +7,15 @@ import (
|
||||
"chat-agent-system/internal/auth"
|
||||
)
|
||||
|
||||
func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount) bool {
|
||||
type taskRunAction string
|
||||
|
||||
const (
|
||||
taskRunView taskRunAction = "view"
|
||||
taskRunCancel taskRunAction = "cancel"
|
||||
taskRunResume taskRunAction = "resume"
|
||||
)
|
||||
|
||||
func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount, action taskRunAction) bool {
|
||||
if user == nil {
|
||||
writeJSONResponse(w, http.StatusUnauthorized, map[string]string{
|
||||
"error_code": "ERR_UNAUTHENTICATED",
|
||||
@@ -23,11 +31,18 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con
|
||||
if s.isSystemAdminUser(ctx, user) {
|
||||
return true
|
||||
}
|
||||
if rec.UserID > 0 && rec.UserID == user.ID {
|
||||
return true
|
||||
}
|
||||
isOwner := rec.UserID > 0 && rec.UserID == user.ID
|
||||
isProjectOwner := false
|
||||
if rec.ProjectID > 0 && s.authorizer != nil {
|
||||
if err := s.authorizer.RequireProjectAccess(ctx, rec.ProjectID, user.ID); err == nil {
|
||||
isProjectOwner, _ = s.authorizer.IsProjectOwner(ctx, rec.ProjectID, user.ID)
|
||||
}
|
||||
switch action {
|
||||
case taskRunView:
|
||||
if isOwner || isProjectOwner {
|
||||
return true
|
||||
}
|
||||
case taskRunCancel, taskRunResume:
|
||||
if isOwner || isProjectOwner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -38,6 +53,10 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount) bool {
|
||||
return s.assertTaskRunAccess(w, ctx, runID, user, taskRunView)
|
||||
}
|
||||
|
||||
func (s *Server) isSystemAdminUser(ctx context.Context, user *auth.UserAccount) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
|
||||
@@ -68,6 +68,10 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
if req.TaskKey == "" && req.Stage == "" {
|
||||
req.TaskKey = "intake"
|
||||
}
|
||||
if req.ProjectID > 0 && req.TaskKey != "" && !s.allowWorkflowDocTaskAccess(r.Context(), req.ProjectID, user.ID, req.TaskKey) {
|
||||
s.writeWorkflowAccessDenied(w)
|
||||
return
|
||||
}
|
||||
req.WorkDir = ""
|
||||
|
||||
ctxMgr := s.svc.ContextManager()
|
||||
@@ -196,7 +200,7 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "run_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) {
|
||||
if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) {
|
||||
return
|
||||
}
|
||||
if err := s.taskRuns.Cancel(runID); err != nil {
|
||||
@@ -256,7 +260,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "run_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) {
|
||||
if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunResume) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateQualityGateV2 按 quality_gate_key 执行结构化门禁。
|
||||
func ValidateQualityGateV2(gateKey, wsDir string) []string {
|
||||
switch gateKey {
|
||||
case "quality.system_reqs.v2":
|
||||
return validateRequirementsJSON(wsDir)
|
||||
case "quality.test_manager.v2":
|
||||
return validateTestCasesJSON(wsDir)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateRequirementsJSON(wsDir string) []string {
|
||||
path := filepath.Join(wsDir, "artifacts", "requirements.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []string{"requirements.json 为必需产物"}
|
||||
}
|
||||
var reqs []map[string]any
|
||||
if err := json.Unmarshal(raw, &reqs); err != nil {
|
||||
return []string{"requirements.json 格式无效"}
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
issues := make([]string, 0)
|
||||
for _, r := range reqs {
|
||||
id, _ := r["id"].(string)
|
||||
if id == "" {
|
||||
issues = append(issues, "需求缺少 id")
|
||||
continue
|
||||
}
|
||||
if ids[id] {
|
||||
issues = append(issues, "重复需求 id: "+id)
|
||||
}
|
||||
ids[id] = true
|
||||
for _, field := range []string{"verification_method", "acceptance_criteria"} {
|
||||
if v, _ := r[field].(string); strings.TrimSpace(v) == "" {
|
||||
issues = append(issues, id+": 缺少 "+field)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func validateTestCasesJSON(wsDir string) []string {
|
||||
path := filepath.Join(wsDir, "artifacts", "test_cases.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []string{"test_cases.json 为必需产物"}
|
||||
}
|
||||
var cases []map[string]any
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
return []string{"test_cases.json 格式无效"}
|
||||
}
|
||||
reqPath := filepath.Join(wsDir, "artifacts", "requirements.json")
|
||||
reqRaw, err := os.ReadFile(reqPath)
|
||||
if err != nil {
|
||||
return []string{"缺少上游 requirements.json 无法验证覆盖率"}
|
||||
}
|
||||
var reqs []map[string]any
|
||||
_ = json.Unmarshal(reqRaw, &reqs)
|
||||
covered := map[string]bool{}
|
||||
for _, tc := range cases {
|
||||
if rid, _ := tc["requirement_id"].(string); rid != "" {
|
||||
covered[rid] = true
|
||||
}
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for _, r := range reqs {
|
||||
id, _ := r["id"].(string)
|
||||
if id != "" && !covered[id] {
|
||||
issues = append(issues, "需求未覆盖: "+id)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package workflow
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ExecutorRegistry 执行器注册表(schema v2 发布校验与运行时解析)。
|
||||
var ExecutorRegistry = map[string]struct{}{
|
||||
"bizdev.stage.v1": {},
|
||||
"squad.system_reqs.v1": {},
|
||||
"squad.test_manager.v1": {},
|
||||
"bizdev.intake.v1": {},
|
||||
"bizdev.research.v1": {},
|
||||
"bizdev.feasibility.v1": {},
|
||||
"bizdev.prelim_design.v1": {},
|
||||
"bizdev.cdo_quote.v1": {},
|
||||
}
|
||||
|
||||
// QualityGateRegistry 确定性质量门禁注册表。
|
||||
var QualityGateRegistry = map[string]struct{}{
|
||||
"quality.system_reqs.v2": {},
|
||||
"quality.test_manager.v2": {},
|
||||
"quality.generic.v1": {},
|
||||
}
|
||||
|
||||
// ValidateExecutorKey 校验执行器已注册。
|
||||
func ValidateExecutorKey(key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("executor_key required")
|
||||
}
|
||||
if _, ok := ExecutorRegistry[key]; !ok {
|
||||
return fmt.Errorf("unknown executor_key %q", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateQualityGateKey 校验质量门禁已注册。
|
||||
func ValidateQualityGateKey(key string) error {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := QualityGateRegistry[key]; !ok {
|
||||
return fmt.Errorf("unknown quality_gate_key %q", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
// TaskResolution 从冻结实例解析的运行时任务绑定。
|
||||
type TaskResolution struct {
|
||||
ProjectID int64
|
||||
WorkflowInstID int64
|
||||
StageKey string
|
||||
TaskKey string
|
||||
DocTaskInstID int64
|
||||
ExecutorKey string
|
||||
QualityGateKey string
|
||||
TemplateAsset string
|
||||
TemplateVersion int
|
||||
AuthorUserID int64
|
||||
ReviewerUserID int64
|
||||
ApproverUserID int64
|
||||
}
|
||||
|
||||
// Orchestrator 只读冻结快照编排(禁止 flatten 旁路)。
|
||||
type Orchestrator struct {
|
||||
instSvc *projectinstance.Service
|
||||
}
|
||||
|
||||
func NewOrchestrator(instSvc *projectinstance.Service) *Orchestrator {
|
||||
return &Orchestrator{instSvc: instSvc}
|
||||
}
|
||||
|
||||
// ResolveDocTask 由 doc_task_instance_id 解析 executor/模板/三权。
|
||||
func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskInstID int64) (*TaskResolution, error) {
|
||||
if o == nil || o.instSvc == nil || !o.instSvc.Available() {
|
||||
return nil, fmt.Errorf("orchestrator unavailable")
|
||||
}
|
||||
detail, version, err := o.instSvc.GetLiveInstanceDetail(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan := detail.Instance.ConfigSnapshot
|
||||
for _, st := range detail.Stages {
|
||||
for _, dt := range st.DocTasks {
|
||||
if dt.ID != docTaskInstID {
|
||||
continue
|
||||
}
|
||||
res := &TaskResolution{
|
||||
ProjectID: projectID,
|
||||
WorkflowInstID: detail.Instance.ID,
|
||||
StageKey: st.StageKey,
|
||||
TaskKey: dt.TaskKey,
|
||||
DocTaskInstID: dt.ID,
|
||||
}
|
||||
if def := lookupTaskDef(&version.Definition, st.StageKey, dt.TaskKey); def != nil {
|
||||
res.ExecutorKey = def.ExecutorKey
|
||||
res.QualityGateKey = def.QualityGateKey
|
||||
if def.Template != nil {
|
||||
res.TemplateAsset = def.Template.AssetKey
|
||||
res.TemplateVersion = def.Template.Version
|
||||
} else {
|
||||
res.TemplateAsset = def.TemplateAssetKey
|
||||
res.TemplateVersion = def.TemplateVersion
|
||||
}
|
||||
}
|
||||
for _, stPlan := range plan.Stages {
|
||||
if stPlan.StageKey != st.StageKey {
|
||||
continue
|
||||
}
|
||||
for _, dtPlan := range stPlan.DocTasks {
|
||||
if dtPlan.TaskKey != dt.TaskKey {
|
||||
continue
|
||||
}
|
||||
res.AuthorUserID = dtPlan.Duties.AuthorUserID
|
||||
res.ReviewerUserID = dtPlan.Duties.ReviewerUserID
|
||||
res.ApproverUserID = dtPlan.Duties.ApproverUserID
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("doc task instance not in live snapshot")
|
||||
}
|
||||
|
||||
func lookupTaskDef(def *workflowdef.VersionDefinition, stageKey, taskKey string) *workflowdef.DocTaskDef {
|
||||
if def == nil {
|
||||
return nil
|
||||
}
|
||||
for _, st := range def.Stages {
|
||||
if st.Key != stageKey {
|
||||
continue
|
||||
}
|
||||
for i := range st.DocTasks {
|
||||
if st.DocTasks[i].Key == taskKey {
|
||||
return &st.DocTasks[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed manifests/*.json
|
||||
var manifestFS embed.FS
|
||||
|
||||
// LoadManifestDefinition 读取内嵌 v2 manifest(Phase 4 发布种子)。
|
||||
func LoadManifestDefinition(filename string) (VersionDefinition, error) {
|
||||
raw, err := manifestFS.ReadFile("manifests/" + filename)
|
||||
if err != nil {
|
||||
return VersionDefinition{}, fmt.Errorf("read manifest %s: %w", filename, err)
|
||||
}
|
||||
var def VersionDefinition
|
||||
if err := json.Unmarshal(raw, &def); err != nil {
|
||||
return VersionDefinition{}, fmt.Errorf("parse manifest %s: %w", filename, err)
|
||||
}
|
||||
if def.SchemaVersion <= 0 {
|
||||
def.SchemaVersion = 2
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// V2ManifestFiles 三个正式工作流 v2 manifest 文件名。
|
||||
var V2ManifestFiles = map[string]string{
|
||||
"main": "main.v2.json",
|
||||
"product_manager": "product_manager.v2.json",
|
||||
"test_manager": "test_manager.v2.json",
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflow_key": "main",
|
||||
"product_domains": ["medical_device", "non_medical_software"],
|
||||
"stages": [
|
||||
{
|
||||
"key": "V0.1_triage",
|
||||
"name": "闪电摸底与盲区探测",
|
||||
"position": 10,
|
||||
"optional": false,
|
||||
"doc_tasks": [{
|
||||
"key": "intake",
|
||||
"title": "客户需求采集",
|
||||
"required": true,
|
||||
"blocking": true,
|
||||
"default_enabled": true,
|
||||
"executor_key": "bizdev.intake.v1",
|
||||
"quality_gate_key": "quality.generic.v1",
|
||||
"output_doc_type": "intake",
|
||||
"output_doc_title": "初始需求表"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"key": "V0.5_feasibility",
|
||||
"name": "方案成型与法规对齐",
|
||||
"position": 20,
|
||||
"optional": false,
|
||||
"doc_tasks": [
|
||||
{"key": "research", "title": "竞品调研", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.research.v1", "output_doc_type": "research", "output_doc_title": "竞品调研报告"},
|
||||
{"key": "feasibility_reg", "title": "法规可行性", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.feasibility.v1", "output_doc_type": "feasibility_reg", "output_doc_title": "法规可行性分析"},
|
||||
{"key": "prelim_design", "title": "初步设计", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.prelim_design.v1", "output_doc_type": "prelim_design", "output_doc_title": "初步设计说明"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "V1.0_quoting",
|
||||
"name": "工程拆解与商业锁定",
|
||||
"position": 30,
|
||||
"optional": false,
|
||||
"doc_tasks": [{
|
||||
"key": "cdo_quote",
|
||||
"title": "CDO 报价与核算",
|
||||
"required": true,
|
||||
"blocking": true,
|
||||
"default_enabled": true,
|
||||
"executor_key": "bizdev.cdo_quote.v1",
|
||||
"output_doc_type": "cdo_quote",
|
||||
"output_doc_title": "CDO 报价核算"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflow_key": "product_manager",
|
||||
"product_domains": ["medical_device", "non_medical_software"],
|
||||
"stages": [{
|
||||
"key": "requirements_baseline",
|
||||
"name": "系统需求基线",
|
||||
"position": 10,
|
||||
"optional": false,
|
||||
"doc_tasks": [{
|
||||
"key": "system_reqs",
|
||||
"title": "系统需求规格书",
|
||||
"required": true,
|
||||
"blocking": true,
|
||||
"default_enabled": true,
|
||||
"executor_key": "squad.system_reqs.v1",
|
||||
"quality_gate_key": "quality.system_reqs.v2",
|
||||
"output_doc_type": "system_reqs",
|
||||
"output_doc_title": "系统需求规格书"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"workflow_key": "test_manager",
|
||||
"product_domains": ["medical_device", "non_medical_software"],
|
||||
"required_upstream_workflow": "product_manager",
|
||||
"stages": [{
|
||||
"key": "system_verification_planning",
|
||||
"name": "系统验证策划",
|
||||
"position": 10,
|
||||
"optional": false,
|
||||
"doc_tasks": [{
|
||||
"key": "test_manager",
|
||||
"title": "系统验证方案及计划",
|
||||
"required": true,
|
||||
"blocking": true,
|
||||
"default_enabled": true,
|
||||
"executor_key": "squad.test_manager.v1",
|
||||
"quality_gate_key": "quality.test_manager.v2",
|
||||
"output_doc_type": "test_manager",
|
||||
"output_doc_title": "系统验证方案及计划"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
@@ -12,17 +12,26 @@ const (
|
||||
ScanRejected = "rejected"
|
||||
)
|
||||
|
||||
// TemplateRef 模板版本引用(schema v2)。
|
||||
type TemplateRef struct {
|
||||
AssetKey string `json:"asset_key"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// DocTaskDef 阶段内文档任务定义。
|
||||
type DocTaskDef struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
Blocking bool `json:"blocking"`
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
TemplateAssetKey string `json:"template_asset_key,omitempty"`
|
||||
TemplateVersion int `json:"template_version,omitempty"`
|
||||
OutputDocType string `json:"output_doc_type"`
|
||||
OutputDocTitle string `json:"output_doc_title"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Required bool `json:"required"`
|
||||
Blocking bool `json:"blocking"`
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
TemplateAssetKey string `json:"template_asset_key,omitempty"`
|
||||
TemplateVersion int `json:"template_version,omitempty"`
|
||||
Template *TemplateRef `json:"template,omitempty"`
|
||||
ExecutorKey string `json:"executor_key,omitempty"`
|
||||
QualityGateKey string `json:"quality_gate_key,omitempty"`
|
||||
OutputDocType string `json:"output_doc_type"`
|
||||
OutputDocTitle string `json:"output_doc_title"`
|
||||
}
|
||||
|
||||
// StageDef 线性阶段定义。
|
||||
@@ -37,9 +46,13 @@ type StageDef struct {
|
||||
|
||||
// VersionDefinition 工作流版本完整快照(JSONB)。
|
||||
type VersionDefinition struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"`
|
||||
Stages []StageDef `json:"stages"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
ProductDomains []string `json:"product_domains,omitempty"`
|
||||
RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"`
|
||||
Stages []StageDef `json:"stages"`
|
||||
ParticipationPolicy map[string]any `json:"participation_policy,omitempty"`
|
||||
CompletionPolicy map[string]any `json:"completion_policy,omitempty"`
|
||||
}
|
||||
|
||||
// Definition 工作流逻辑标识。
|
||||
|
||||
@@ -11,6 +11,9 @@ type upstreamExistsChecker func(workflowKey string) bool
|
||||
|
||||
// ValidateDefinition 对工作流版本定义做确定性校验。
|
||||
func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, upstreamExists upstreamExistsChecker) ValidationResult {
|
||||
if def.SchemaVersion >= 2 {
|
||||
return ValidateDefinitionV2(def)
|
||||
}
|
||||
issues := make([]ValidationIssue, 0)
|
||||
if def.SchemaVersion <= 0 {
|
||||
def.SchemaVersion = 1
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package workflowdef
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/workflow"
|
||||
)
|
||||
|
||||
// ValidateDefinitionV2 schema v2 发布校验。
|
||||
func ValidateDefinitionV2(def VersionDefinition) ValidationResult {
|
||||
issues := make([]ValidationIssue, 0)
|
||||
if def.SchemaVersion < 2 {
|
||||
issues = append(issues, ValidationIssue{Code: "SCHEMA_VERSION", Message: "schema_version must be >= 2"})
|
||||
}
|
||||
seenPos := map[int]bool{}
|
||||
for _, st := range def.Stages {
|
||||
if st.Key == "" {
|
||||
issues = append(issues, ValidationIssue{Code: "STAGE_KEY_REQUIRED", Message: "stage key required"})
|
||||
continue
|
||||
}
|
||||
if st.Position <= 0 {
|
||||
issues = append(issues, ValidationIssue{Code: "STAGE_POSITION", Field: st.Key, Message: "position must be > 0"})
|
||||
}
|
||||
if seenPos[st.Position] {
|
||||
issues = append(issues, ValidationIssue{Code: "DUPLICATE_POSITION", Field: st.Key, Message: "duplicate stage position"})
|
||||
}
|
||||
seenPos[st.Position] = true
|
||||
for _, dt := range st.DocTasks {
|
||||
if dt.ExecutorKey != "" {
|
||||
if err := workflow.ValidateExecutorKey(dt.ExecutorKey); err != nil {
|
||||
issues = append(issues, ValidationIssue{Code: "UNKNOWN_EXECUTOR", Field: dt.Key, Message: err.Error()})
|
||||
}
|
||||
}
|
||||
if dt.QualityGateKey != "" {
|
||||
if err := workflow.ValidateQualityGateKey(dt.QualityGateKey); err != nil {
|
||||
issues = append(issues, ValidationIssue{Code: "UNKNOWN_QUALITY_GATE", Field: dt.Key, Message: err.Error()})
|
||||
}
|
||||
}
|
||||
if dt.OutputDocType == "" {
|
||||
issues = append(issues, ValidationIssue{Code: "OUTPUT_DOC_TYPE", Field: dt.Key, Message: "output_doc_type required"})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(def.Stages) == 0 {
|
||||
issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "at least one stage required"})
|
||||
}
|
||||
return ValidationResult{Valid: len(issues) == 0, Issues: issues}
|
||||
}
|
||||
|
||||
// ValidateVersionBySchema 按 schema_version 路由校验器。
|
||||
func ValidateVersionBySchema(def VersionDefinition) ValidationResult {
|
||||
if def.SchemaVersion >= 2 {
|
||||
return ValidateDefinitionV2(def)
|
||||
}
|
||||
return validateDefinitionV1(def)
|
||||
}
|
||||
|
||||
func validateDefinitionV1(def VersionDefinition) ValidationResult {
|
||||
issues := make([]ValidationIssue, 0)
|
||||
for i, st := range def.Stages {
|
||||
if st.Position != i {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Code: "STAGE_POSITION", Field: st.Key,
|
||||
Message: fmt.Sprintf("position must equal index %d", i),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ValidationResult{Valid: len(issues) == 0, Issues: issues}
|
||||
}
|
||||
+9
-3
@@ -699,7 +699,11 @@ export default function App() {
|
||||
void authClient.fetchMe().then((me) => {
|
||||
setCapabilities(me.capabilities ?? []);
|
||||
setCurrentUser(mergeSessionUser(me.user, match));
|
||||
}).catch(() => {});
|
||||
}).catch(() => {
|
||||
authClient.clearLocalSession();
|
||||
setSession(null);
|
||||
setCapabilities([]);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -784,8 +788,10 @@ export default function App() {
|
||||
}, [session, currentUser.id, projects]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authClient.logout();
|
||||
setSession(null);
|
||||
void authClient.logout().finally(() => {
|
||||
setSession(null);
|
||||
setCapabilities([]);
|
||||
});
|
||||
};
|
||||
|
||||
// 核心功能权限计算
|
||||
|
||||
+41
-1
@@ -36,6 +36,33 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
export class AuthClient {
|
||||
constructor(_options?: AuthClientOptions) {}
|
||||
|
||||
async fetchProfile(): Promise<Record<string, unknown>> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/me/profile', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('无法加载个人资料');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async savePreferences(prefs: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/me/preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error('保存偏好失败');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async fetchMe(): Promise<MeResult> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await apiFetch('/api/v1/auth/me', {
|
||||
@@ -303,7 +330,20 @@ export class AuthClient {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
logout() {
|
||||
async logout(): Promise<void> {
|
||||
const token = this.getSessionToken();
|
||||
try {
|
||||
await apiFetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
} catch {
|
||||
// 网络失败仍清除本地态
|
||||
}
|
||||
this.clearLocalSession();
|
||||
}
|
||||
|
||||
clearLocalSession() {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION);
|
||||
}
|
||||
|
||||
@@ -1473,7 +1473,7 @@ export const AdminSettingsModal: React.FC<AdminSettingsModalProps> = ({
|
||||
Bcrypt (加盐哈希)
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-faint)', marginTop: '4px' }}>
|
||||
符合 21 CFR Part 11 电子签名不可逆哈希标准
|
||||
电子签名与审计事件由服务端记录并防篡改校验
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user