From 98ad60c0b8bf8d9e363375db28d90ab34c825867 Mon Sep 17 00:00:00 2001 From: k2safe Date: Sun, 1 Nov 2026 19:10:37 +0800 Subject: [PATCH] feat: manage existing credential actions with audited replay --- cmd/orbit-admin/credentials.go | 55 ++- cmd/orbit-admin/credentials_test.go | 40 ++ cmd/orbit-admin/main.go | 4 +- docs/08-development-plan.md | 4 +- docs/api/authentication.md | 22 +- docs/api/console-applications.md | 21 + .../credential-permissions-20261101.md | 39 ++ internal/consoleauth/applications.go | 13 +- .../storage/mysql/console_applications.go | 28 +- .../console_applications_permissions_test.go | 65 +++ .../console_permissions_integration_test.go | 231 +++++++++ ...credential_permissions_integration_test.go | 438 ++++++++++++++++++ internal/storage/mysql/credentials.go | 99 +++- .../httpserver/console/applications.js | 65 ++- .../transport/httpserver/console/index.html | 8 + .../transport/httpserver/console/styles.css | 2 + .../httpserver/console_applications_test.go | 105 ++++- scripts/test-console-state.cjs | 164 +++++++ 18 files changed, 1350 insertions(+), 53 deletions(-) create mode 100644 docs/reports/credential-permissions-20261101.md create mode 100644 internal/storage/mysql/console_applications_permissions_test.go create mode 100644 internal/storage/mysql/console_permissions_integration_test.go create mode 100644 internal/storage/mysql/credential_permissions_integration_test.go diff --git a/cmd/orbit-admin/credentials.go b/cmd/orbit-admin/credentials.go index cba1fe0..bb59dbf 100644 --- a/cmd/orbit-admin/credentials.go +++ b/cmd/orbit-admin/credentials.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "time" @@ -16,17 +17,20 @@ import ( "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" "git.yuntuo.hk/k2safe/orbit/internal/storage/mysql" core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" ) func runCredential(args []string) error { if len(args) == 0 { - return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply") + return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|revoke|apply") } switch args[0] { case "status": return runCredentialStatus(args[1:]) case "rotate": return runCredentialChange(args[1:], "ROTATE") + case "permissions": + return runCredentialChange(args[1:], "SET_PERMISSIONS") case "expiry": return runCredentialChange(args[1:], "SET_EXPIRY") case "finalize": @@ -36,7 +40,7 @@ func runCredential(args []string) error { case "apply": return runCredentialApply(args[1:]) default: - return errors.New("usage: orbit-admin credential status|rotate|expiry|finalize|revoke|apply") + return errors.New("usage: orbit-admin credential status|permissions|rotate|expiry|finalize|revoke|apply") } } @@ -75,6 +79,11 @@ func runCredentialChange(args []string, action string) error { grace = 7 * 24 * time.Hour } var expected uint64 + var actionList string + prepareOnly := flags.Bool("prepare-only", false, "save the fixed private operation without applying it; later use credential apply") + if action == "SET_PERMISSIONS" { + flags.StringVar(&actionList, "actions", "", "complete comma-separated permission actions; existing queue scope is preserved") + } flags.StringVar(&namespace, "namespace-id", "", "credential namespace id") flags.StringVar(&credential, "credential-id", "", "credential id") flags.Uint64Var(&expected, "expected-revision", 0, "current credential revision required for first apply") @@ -92,6 +101,22 @@ func runCredentialChange(args []string, action string) error { if err != nil || expected == 0 { return errors.New("credential change requires valid ids and a nonzero --expected-revision") } + var actions []string + if action == "SET_PERMISSIONS" { + invalidLifetime := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "valid-for" || f.Name == "grace" { + invalidLifetime = true + } + }) + if invalidLifetime { + return errors.New("permission changes preserve validity and overlap; omit --valid-for and --grace") + } + actions, err = parseCredentialActions(actionList) + if err != nil { + return err + } + } duration := time.Duration(0) if action == "ROTATE" || action == "SET_EXPIRY" { duration, err = time.ParseDuration(validFor) @@ -105,7 +130,7 @@ func runCredentialChange(args []string, action string) error { } defer closeDB() op, err := mysql.PrepareCredentialOperation(ctx, db, mysql.CredentialChangeInput{ - NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason, + NamespaceID: ns, CredentialID: id, ExpectedRevision: expected, Action: action, ValidFor: duration, GraceFor: grace, Reason: reason, Actions: actions, }) if err != nil { return errors.New("credential operation could not be prepared") @@ -113,6 +138,12 @@ func runCredentialChange(args []string, action string) error { if err := writePrivateOperation(output, op); err != nil { return err } + if *prepareOnly { + return json.NewEncoder(os.Stdout).Encode(struct { + OperationID core.ID `json:"operation_id"` + Prepared bool `json:"prepared"` + }{op.OperationID, true}) + } vault, err := credentialvault.LoadFile(os.Getenv("ORBIT_CREDENTIAL_KEY_FILE")) if err != nil { return err @@ -124,6 +155,24 @@ func runCredentialChange(args []string, action string) error { return json.NewEncoder(os.Stdout).Encode(receipt) } +func parseCredentialActions(value string) ([]string, error) { + actions := strings.Split(value, ",") + seen := make(map[string]bool, len(actions)) + for i, action := range actions { + action = strings.TrimSpace(action) + if action == "" || seen[action] { + return nil, errors.New("--actions requires a nonempty, unique, complete action list") + } + seen[action] = true + actions[i] = action + } + if err := (auth.Permissions{Actions: actions, Queues: []string{"*"}}).Validate(); err != nil { + return nil, errors.New("--actions contains an unsupported action or exceeds the action limit") + } + sort.Strings(actions) + return actions, nil +} + func runCredentialApply(args []string) error { flags := flag.NewFlagSet("credential apply", flag.ContinueOnError) file := "" diff --git a/cmd/orbit-admin/credentials_test.go b/cmd/orbit-admin/credentials_test.go index a1665ab..0482dbd 100644 --- a/cmd/orbit-admin/credentials_test.go +++ b/cmd/orbit-admin/credentials_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "reflect" "testing" "time" @@ -84,3 +85,42 @@ func TestPrivateOperationFileRejectsUnknownFieldsAndSymlinks(t *testing.T) { t.Fatal("symlink operation file was accepted") } } + +func TestPermissionActionList(t *testing.T) { + actions, err := parseCredentialActions("tasks:read, schedules:manage,schedules:read") + if err != nil || !reflect.DeepEqual(actions, []string{"schedules:manage", "schedules:read", "tasks:read"}) { + t.Fatalf("invalid canonical action list: %v %v", actions, err) + } + for _, value := range []string{"", " ", "*", "tasks:*", "tasks:read,tasks:read", "tasks:read, tasks:read", "tasks:read,", "schedules:admin"} { + if _, err := parseCredentialActions(value); err == nil { + t.Fatalf("accepted invalid action list %q", value) + } + } +} + +func TestPermissionCommandRejectsLifetimeBeforeDatabaseAccess(t *testing.T) { + op := testOperation(t) + for _, flag := range []string{"--valid-for=0", "--grace=0"} { + err := runCredential([]string{"permissions", "--namespace-id=" + string(op.NamespaceID), "--credential-id=" + string(op.CredentialID), "--expected-revision=1", "--reason=test", "--output=" + filepath.Join(t.TempDir(), "op.json"), "--actions=tasks:read", flag}) + if err == nil || err.Error() != "permission changes preserve validity and overlap; omit --valid-for and --grace" { + t.Fatalf("lifetime field not rejected: %v", err) + } + } +} + +func TestPrivatePermissionOperationReplaysWithoutToken(t *testing.T) { + op := testOperation(t) + op.Version = 3 + op.Action = "SET_PERMISSIONS" + op.Token = "" + op.ExpiresAt = nil + op.Actions = []string{"schedules:manage", "schedules:read", "tasks:read"} + path := filepath.Join(t.TempDir(), "permissions.json") + if err := writePrivateOperation(path, op); err != nil { + t.Fatal(err) + } + read, err := readPrivateOperation(path) + if err != nil || !reflect.DeepEqual(read, op) { + t.Fatalf("permission operation identity changed: %v", err) + } +} diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index 636e701..62d3435 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -28,7 +28,7 @@ func main() { } func run() error { if len(os.Args) < 2 { - return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|permissions|rotate|expiry|finalize|revoke|apply") } switch os.Args[1] { case "release": @@ -46,7 +46,7 @@ func run() error { case "credential": return runCredential(os.Args[2:]) } - return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|rotate|revoke|apply") + return errors.New("usage: orbit-admin release manifest|bootstrap|console-user create|verify|observe snapshot|retention inventory|archive|verify-archive|credential status|permissions|rotate|expiry|finalize|revoke|apply") } // releaseManifest 是发布物自身的静态身份清单,不读取数据库、不包含 DSN、凭据或业务数据。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 2c054c4..fd0af67 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -1,6 +1,8 @@ # Orbit 持续开发与回归计划 -更新:2026-09-24。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。 +更新:2026-11-01。此计划对应持续开发目标,由开发过程同步维护。数据库已按用户要求采用无外键设计,迁移 000008 移除历史外键,关联一致性由服务端事务保证。 + +2026-11-01 补齐旧凭据动作权限管理:控制台与管理 CLI 共用 SET_PERMISSIONS v3 固定操作、revision 校验、幂等回执和原子审计;保留队列范围、秘密、有效期及过渡时间。隔离 MySQL 已验证原 Token 即时授予/收回、真实锁等待竞争、审计失败回滚和 v1/v2 旧操作兼容;HTTP 与页面提交重试回归通过。无新增迁移,业务 SDK 仍不开放凭据管理,原生产验收边界不变。协议及命令见 [认证手册](api/authentication.md#原-token-调整动作权限) 与 [控制台接入](api/console-applications.md#调整已有凭据的动作权限),验证范围见 [权限管理回归](reports/credential-permissions-20261101.md)。 ## 1. 最终交付目标 diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 3307666..067136b 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -56,7 +56,7 @@ go run ./cmd/orbit-admin credential status \ --credential-id ``` -状态输出不包含令牌、秘密摘要或权限正文;`revision` 是后续变更的并发条件。 +状态输出包含精确动作与队列权限,不包含令牌或秘密摘要;`revision` 是后续变更的并发条件。 ```sh go run ./cmd/orbit-admin credential rotate \ @@ -106,7 +106,25 @@ go run ./cmd/orbit-admin credential finalize \ 结束过渡立即停用旧 Token,当前 Token 不变。长期 Token 仍受主动撤销、应用/命名空间停用约束。旧代仍在有效过渡期时再次轮换返回 409,避免误伤未切换服务;先完成本次切换。修改当前有效期不延长旧代截止。若旧 Token 本身即将到期而需要更长切换窗口,应先调整当前有效期,再发起换证。 -新固定操作文件使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。 +轮换、有效期、结束过渡与撤销的新固定操作文件继续使用 version=2;历史 version=1 仍按原来的固定到期和立即轮换语义重放,不自动延长旧秘密。权限修改使用下面的 version=3,不改变旧操作内容或摘要。 + +### 原 Token 调整动作权限 + +`credential permissions` 修改已有凭据的完整动作集合,保留队列授权范围、Token、密文、有效期、旧代过渡期和资源身份。先用 status 读取当前权限与 revision,再明确保留或移除哪些动作。`--actions` 是全量替换,不能只填写希望新增的动作;空集合、未知动作、通配符和重复动作会被拒绝,准备时按字典序固定。 + +```sh +go run ./cmd/orbit-admin credential permissions \ + --namespace-id --credential-id \ + --expected-revision \ + --actions 'queues:read,schedules:manage,schedules:read,tasks:read,tasks:submit' \ + --reason 'enable scheduling for this credential' \ + --output .local/permissions.operation.json --prepare-only +go run ./cmd/orbit-admin credential apply --file .local/permissions.operation.json +``` + +上例动作仅作格式示例,实际必须根据原 status 核对完整集合。`--prepare-only` 只保存 0600 固定操作,不修改凭据;不带该参数则保存后立即应用,沿用其他 credential 命令的行为。权限修改不接受 `--valid-for` 或 `--grace`,固定操作不含 Token。当前版本校验、权限变更、前后权限审计和原键回执在一个事务内提交,审计失败整笔回滚;提交不明时用原文件 apply,不能换 operation_id。旧操作重放只返回原回执,不会恢复后来已收回的权限。 + +权限随下一次认证/授权检查生效,原 Token 无须重新分发;移除 Worker 或消费相关权限可能影响既有连接的后续授权。该管理能力仅由管理员控制台或受保护的管理 CLI 提供,业务 SDK 不获得凭据管理接口。 发生泄露或 Worker 必须立即停用时执行撤销: diff --git a/docs/api/console-applications.md b/docs/api/console-applications.md index b53c304..27b88f0 100644 --- a/docs/api/console-applications.md +++ b/docs/api/console-applications.md @@ -21,6 +21,7 @@ export ORBIT_CREDENTIAL_KEY_FILE=/run/orbit-secrets/credential-keys.json 4. 已接入应用点击「查看接入信息」或「复制接入配置」,获取包含当前 Token 的完整配置;也可下载 orbit.env。 5. 「平滑换证」保留 application_id、namespace_id、credential_id、队列、任务和订阅;新 Token 立即生效,旧 Token 在配置的过渡期内继续使用(页面默认 7 天,支持 1/7/30 天),便于逐台更新配置。详情展示旧 Token 实际截止,全部服务切换后可点「结束过渡」。 6. 默认「长期有效」,不需要每月人工换证;已有凭据可点「调整有效期」直接设为长期,原 Token 保持不变。 +7. 在凭据的「管理与权限」中选择「调整权限」,核对当前精确动作集合并填写原因。只修改动作,队列授权、Token、有效期和过渡期保持不变;高级恢复、审计和额度权限需要明确选择。 凭据卡片提供「查看接入信息」「复制接入配置」。配置包含 ORBIT_URL、ORBIT_APPLICATION_ID、ORBIT_NAMESPACE_ID、ORBIT_QUEUE_ID、ORBIT_TOKEN;可直接粘贴为环境配置。先选择业务服务实际可访问的地址和队列,再复制;应用 ID 用于识别,真正认证身份来自 Token。过渡期始终返回当前新 Token,调整有效期或结束旧代过渡后仍可查看。历史凭据尚无密文时提供「导入当前 Token」,不能把仍可用的旧代 Token 导入为当前代次。 @@ -132,6 +133,26 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长 完整滚动更新及 SDK 客户端生命周期说明见[凭据手册](authentication.md)。更换配置不会自动刷新已创建的客户端,须在过渡截止前完成重建或逐台重启并排空在途任务。 +### 调整已有凭据的动作权限 + +prepare 使用 `kind=SET_PERMISSIONS`,携带当前 namespace_id、credential_id、expected_revision、完整 actions 数组和 reason;valid_days 为 0,grace_days 省略或为 0。actions 必须非空,至多 32 个,拒绝重复、未知动作与通配符。其他操作类型不接受 actions,不能把权限修改混入换证或有效期调整。 + +```json +{ + "kind": "SET_PERMISSIONS", + "namespace_id": "33333333333333333333333333333333", + "credential_id": "55555555555555555555555555555555", + "expected_revision": "2", + "valid_days": 0, + "actions": ["queues:read", "schedules:manage", "schedules:read", "tasks:read", "tasks:submit"], + "reason": "允许该凭据管理定时计划" +} +``` + +actions 为**全量替换**,示例不能代替读取原权限;页面默认勾选原有动作,不默认补齐能力组。prepare 返回 version=3 的固定操作,不生成或返回 Token、期限或过渡时间;原对象整体提交到已有 apply 路由。新动作要求 version=3,旧 CREATE、ROTATE、SET_EXPIRY、FINALIZE 保持原固定格式与重放语义。 + +服务端仅更新原凭据动作集合和 revision,保留原队列授权与所有秘密/有效期字段;权限前后快照、管理员身份、原因和回执原子提交。审计失败不改权限,旧版本返回冲突,提交不明重放原 operation_id。页面成功后刷新详情;旧回执不代表当前权限仍等于该次修改后的状态。权限将在后续认证/授权检查生效,移除正在使用的能力可能影响对应连接后续操作。 + ## 6. 超时、重复和审计 - 页面提交前把整个固定操作保留在当前内存;请求超时或结果未知时,按钮变成「重试确认」,重用同一份内容。不能再次 prepare 来代替核对。 diff --git a/docs/reports/credential-permissions-20261101.md b/docs/reports/credential-permissions-20261101.md new file mode 100644 index 0000000..19492a1 --- /dev/null +++ b/docs/reports/credential-permissions-20261101.md @@ -0,0 +1,39 @@ +# 既有凭据权限管理回归 + +日期:2026-11-01。 + +## 功能范围 + +管理员通过控制台或 orbit-admin credential permissions 调整既有凭据的完整动作集合。SET_PERMISSIONS 使用 version=3 固定操作;旧动作仍使用原 v1/v2 格式,字段省略规则和历史摘要保持兼容。队列授权范围、Token 摘要及密文、有效期、旧代过渡期、资源 ID 均不改变。只更新动作集合与 revision,并将前后权限、主体、原因、固定回执同事务保存。 + +控制台入口仍只接受指定管理员会话与同源请求,业务 Bearer 不获得管理权限。页面使用精确动作勾选,默认保持原集合;高级核查、重试、审计与额度动作单独标识。未知动作、重复动作、通配符和空集合被拒绝;权限修改不能夹带有效期、过渡期或队列变化。 + +## 已执行验证 + +本批真库测试使用新建的一次性 MySQL 8.4 容器,核心与控制台测试各用独立数据库,没有对既有业务库执行故障注入。 + +| 验证 | 结果与边界 | +| --- | --- | +| 核心与历史凭据真库 race | 新权限前原 Token 调度请求为 403,授予后可实际创建计划,收回后当前/旧代 Token 均被拒绝;额外队列仍不可访问 | +| 凭据保留 | 比对秘密摘要、密文、密钥编号、有效期、过渡截止、队列 JSON;只改变明确请求的动作与 revision | +| 并发与重放 | 通过 performance_schema 确认实际数据库锁等待;同版本竞争仅一次生效,原操作重放无第二次审计,不恢复后来已收回的权限 | +| 审计失败 | 隔离库注入审计写入失败,权限、revision 和幂等回执全部回滚 | +| 兼容性 | v1/v2 固定 JSON/摘要不变;v1 → v3 → 旧回执重放 → v2 调整期限通过 | +| 控制台 HTTP 与真库 | prepare/apply 的管理员、Cookie、Origin、Fetch Metadata、严格 JSON、注销后拒绝、两代 Token 与单队列隔离通过 | +| 页面事件回归 | 执行实际 applications.js;默认精确集合、原操作重试、成功刷新、不读取 Token、其他模式及撤销状态隔离通过 | +| 管理 CLI | 动作参数校验、禁止期限参数、0600 固定文件往返通过;另在隔离库通过真实 CLI 验证 prepare-only 前后状态不变,apply 与原文件重放仅增加一次 revision,队列范围保持 | +| 标准检查 | make test、make vet、make build、make console-check、make docs-check 全部通过 | + +## 复现入口与限制 + +```sh +make test +make vet +make build +make console-check +make docs-check +# ORBIT_TEST_MYSQL_DSN 从隔离环境私密配置加载,不指向共享业务库。 +go test -race -count=1 ./internal/storage/mysql -run 'TestIntegrationCredential|TestIntegrationConsole.*Permission|TestIntegrationConsoleApplications' +``` + +没有新增数据库迁移、修改 SDK 协议或赋予普通凭据跨应用权限。上述验证不能替代生产多节点部署、权限变更审批和目标拓扑的正式验收;实际接入方授权记录位于工作区对接文档,不写入通用平台代码。 diff --git a/internal/consoleauth/applications.go b/internal/consoleauth/applications.go index 4d6875a..30f86df 100644 --- a/internal/consoleauth/applications.go +++ b/internal/consoleauth/applications.go @@ -38,7 +38,7 @@ type AccessCredential struct { PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // PreviousValidUntil 上一代令牌实际截止,空表示无旧令牌。 ID core.ID `json:"id"` // ID 凭据标识,重新生成 Token 时不改变。 Name string `json:"name"` // Name 凭据名称。 - Revision uint64 `json:"revision,string"` // Revision 轮换的并发校验版本。 + Revision uint64 `json:"revision,string"` // Revision 凭据管理变更的并发校验版本。 ExpiresAt *time.Time `json:"expires_at,omitempty"` // ExpiresAt 到期时间,空表示无固定到期时间。 RevokedAt *time.Time `json:"revoked_at,omitempty"` // RevokedAt 撤销时间,非空时禁止轮换。 Permissions auth.Permissions `json:"permissions"` // Permissions 动作和队列授权范围。 @@ -57,15 +57,16 @@ type ApplicationDetail struct { // ApplicationInput 描述创建应用或变更凭据的管理员意图。 type ApplicationInput struct { - Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY 或 FINALIZE。 + Kind string `json:"kind"` // Kind CREATE、ROTATE、SET_EXPIRY、FINALIZE 或 SET_PERMISSIONS。 Application string `json:"application"` // Application 新应用名称,仅 CREATE 使用。 Namespace string `json:"namespace"` // Namespace 新命名空间名称,仅 CREATE 使用。 Environment string `json:"environment"` // Environment 新环境名称,仅 CREATE 使用。 Queue string `json:"queue"` // Queue 初始队列名称,仅 CREATE 使用。 Capabilities []string `json:"capabilities,omitempty"` // Capabilities 显式选择的业务权限组,仅 CREATE 使用。 - NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 轮换目标所属范围。 - CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 轮换目标凭据。 - ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 轮换时读取到的版本。 + Actions []string `json:"actions,omitempty"` // Actions 仅 SET_PERMISSIONS 使用,完整替换动作集合;不改变队列授权。 + NamespaceID core.ID `json:"namespace_id,omitempty"` // NamespaceID 凭据变更目标所属范围。 + CredentialID core.ID `json:"credential_id,omitempty"` // CredentialID 变更目标凭据,权限修改不改变其身份。 + ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 变更前读取到的凭据版本,防止覆盖并发修改。 ValidDays int `json:"valid_days"` // ValidDays 有效天数;0 为长期,1–365 为定期。 GraceDays int `json:"grace_days,omitempty"` // GraceDays ROTATE 的旧令牌过渡天数,0–30;0 显式立即失效。 Reason string `json:"reason"` // Reason 写入审计的变更原因。 @@ -74,7 +75,7 @@ type ApplicationInput struct { // ApplicationOperation 是浏览器在提交前保存的固定操作;启用保管时仅保存 Token 密文。 // 网络结果未知时重放同一份内容,禁止重新生成操作身份或秘密。 type ApplicationOperation struct { - Version int `json:"version,omitempty"` // Version v2 支持长期令牌与过渡;旧固定操作未设置时沿用 v1。 + Version int `json:"version,omitempty"` // Version v3 仅用于权限变更;v2 支持长期令牌与过渡,未设置时沿用 v1。 Input ApplicationInput `json:"input"` // Input 固定的管理员意图。 OperationID core.ID `json:"operation_id"` // OperationID 稳定幂等身份。 ApplicationID core.ID `json:"application_id"` // ApplicationID 新建或已核验的应用标识。 diff --git a/internal/storage/mysql/console_applications.go b/internal/storage/mysql/console_applications.go index b826bbe..e07833e 100644 --- a/internal/storage/mysql/console_applications.go +++ b/internal/storage/mysql/console_applications.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/json" "errors" + "sort" "time" "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" @@ -194,14 +195,17 @@ func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions, return p, p.Validate() } func validateApplicationInput(in consoleauth.ApplicationInput) error { + if in.Kind != "SET_PERMISSIONS" && in.Actions != nil { + return fault.Invalid("actions is only valid for SET_PERMISSIONS") + } if in.ValidDays < 0 || in.ValidDays > 365 { return fault.Invalid("valid_days must be 0 (permanent) or 1-365") } if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) { return fault.Invalid("grace_days must be 0-30 for rotation only") } - if in.Kind == "FINALIZE" && in.ValidDays != 0 { - return fault.Invalid("finalize cannot set validity") + if (in.Kind == "FINALIZE" || in.Kind == "SET_PERMISSIONS") && in.ValidDays != 0 { + return fault.Invalid("this change cannot set validity") } if err := validCredentialReason(in.Reason); err != nil { return err @@ -213,12 +217,15 @@ func validateApplicationInput(in consoleauth.ApplicationInput) error { } _, err := applicationPermissions(in) return err - case "ROTATE", "SET_EXPIRY", "FINALIZE": + case "ROTATE", "SET_EXPIRY", "FINALIZE", "SET_PERMISSIONS": if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 { - return fault.Invalid("invalid rotation fields") + return fault.Invalid("invalid credential change fields") + } + if in.Kind == "SET_PERMISSIONS" { + return validateCredentialChangeInput(CredentialChangeInput{NamespaceID: in.NamespaceID, CredentialID: in.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: in.Kind, Actions: in.Actions, Reason: in.Reason}) } default: - return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE") + return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY, FINALIZE or SET_PERMISSIONS") } return nil } @@ -227,6 +234,12 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token if err := validateApplicationInput(in); err != nil { return out, err } + if in.Kind == "SET_PERMISSIONS" { + out.Version = 3 + // 固定排序副本,避免准备返回后由调用方改变切片或重放时重算意图。 + out.Input.Actions = append([]string(nil), in.Actions...) + sort.Strings(out.Input.Actions) + } err := WithTx(ctx, s.db, func(tx *sql.Tx) error { if _, err := s.admin(ctx, tx, token); err != nil { return err @@ -279,7 +292,8 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au if err := validateApplicationInput(op.Input); err != nil { return out, err } - if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { + validVersion := (op.Input.Kind == "SET_PERMISSIONS" && op.Version == 3) || (op.Input.Kind != "SET_PERMISSIONS" && (op.Version == 0 || op.Version == 2)) + if !validVersion || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { return out, fault.Invalid("invalid fixed operation identity") } if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) { @@ -293,7 +307,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID { return out, fault.Invalid("credential identity mismatch") } - change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil} + change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Actions: op.Input.Actions, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil} receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) { actor, err := s.admin(ctx, tx, token) if err != nil { diff --git a/internal/storage/mysql/console_applications_permissions_test.go b/internal/storage/mysql/console_applications_permissions_test.go new file mode 100644 index 0000000..e669f7f --- /dev/null +++ b/internal/storage/mysql/console_applications_permissions_test.go @@ -0,0 +1,65 @@ +package mysql + +import ( + "context" + "testing" + + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +func TestConsolePermissionInputAndFixedVersion(t *testing.T) { + base := consoleauth.ApplicationInput{Kind: "SET_PERMISSIONS", NamespaceID: testID(t), CredentialID: testID(t), ExpectedRevision: 4, Reason: "enable scheduled tasks", Actions: []string{"tasks:submit", "schedules:read", "schedules:manage"}} + if err := validateApplicationInput(base); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + mutate func(*consoleauth.ApplicationInput) + }{ + {"missing actions", func(in *consoleauth.ApplicationInput) { in.Actions = nil }}, + {"empty actions", func(in *consoleauth.ApplicationInput) { in.Actions = []string{} }}, + {"duplicate", func(in *consoleauth.ApplicationInput) { in.Actions = []string{"tasks:read", "tasks:read"} }}, + {"unknown", func(in *consoleauth.ApplicationInput) { in.Actions = []string{"unknown:action"} }}, + {"wildcard", func(in *consoleauth.ApplicationInput) { in.Actions = []string{"*"} }}, + {"expiry", func(in *consoleauth.ApplicationInput) { in.ValidDays = 7 }}, + {"grace", func(in *consoleauth.ApplicationInput) { in.GraceDays = 7 }}, + {"capability", func(in *consoleauth.ApplicationInput) { in.Capabilities = []string{"schedules"} }}, + {"queue", func(in *consoleauth.ApplicationInput) { in.Queue = "changed" }}, + {"missing version", func(in *consoleauth.ApplicationInput) { in.ExpectedRevision = 0 }}, + {"missing reason", func(in *consoleauth.ApplicationInput) { in.Reason = "" }}, + } { + t.Run(tc.name, func(t *testing.T) { + in := base + tc.mutate(&in) + if fault.Code(validateApplicationInput(in)) != "INVALID_ARGUMENT" { + t.Fatal("invalid permission request accepted") + } + }) + } + for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} { + in := base + in.Kind = kind + for _, actions := range [][]string{{"tasks:read"}, {}} { + in.Actions = actions + if fault.Code(validateApplicationInput(in)) != "INVALID_ARGUMENT" { + t.Fatalf("%s accepted actions", kind) + } + } + } + // 拒绝旧格式携带新语义及新格式冒充旧操作;这里不得访问数据库。 + for _, version := range []int{0, 1, 2, 4} { + op := consoleauth.ApplicationOperation{Version: version, Input: base, OperationID: testID(t), ApplicationID: testID(t), NamespaceID: base.NamespaceID, CredentialID: base.CredentialID} + if _, err := NewConsoleApplicationStore(nil, "admin").ApplyApplication(context.Background(), auth.Token{}, op); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatalf("permission version %d accepted: %v", version, err) + } + } + in := base + in.Kind = "SET_EXPIRY" + in.Actions = nil + op := consoleauth.ApplicationOperation{Version: 3, Input: in, OperationID: testID(t), ApplicationID: testID(t), NamespaceID: in.NamespaceID, CredentialID: in.CredentialID} + if _, err := NewConsoleApplicationStore(nil, "admin").ApplyApplication(context.Background(), auth.Token{}, op); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatal("v3 accepted legacy action", err) + } +} diff --git a/internal/storage/mysql/console_permissions_integration_test.go b/internal/storage/mysql/console_permissions_integration_test.go new file mode 100644 index 0000000..6809448 --- /dev/null +++ b/internal/storage/mysql/console_permissions_integration_test.go @@ -0,0 +1,231 @@ +package mysql + +import ( + "bytes" + "database/sql" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + "testing" + + "git.yuntuo.hk/k2safe/orbit/internal/consoleauth" + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/pulse/schedule" +) + +// 从真实管理员 HTTP 入口确认权限变更不会生成、回看或轮换 Token, +// 且旧操作重放仍核对当前会话,原队列范围和过渡中的两代秘密保持不变。 +func TestIntegrationConsolePermissionChange(t *testing.T) { + ctx, db := overlapDB(t) + ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{43}, 32)) + `"}}`)) + if err != nil { + t.Fatal(err) + } + name := "permissions-" + string(testID(t)) + password := "permissions-test-password-12345" + var initial BootstrapResult + if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: password}, func(v BootstrapResult) error { initial = v; return nil }); err != nil { + t.Fatal(err) + } + authStore := NewConsoleAuthStore(db) + login, err := authStore.Login(ctx, name, password, "test", "127.0.0.1") + if err != nil { + t.Fatal(err) + } + admin, _ := auth.Parse(login.Token) + sdk, _ := auth.Parse(initial.Token) + store := NewConsoleApplicationStore(db, name, ring) + server := httpserver.NewWithConsoleAuth("", nil, authStore) + httpserver.RegisterConsoleApplications(server, authStore, store) + request := func(path string, value any, token string, status int) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Orbit-Console", "1") + req.Header.Set("Origin", "http://example.com") + req.AddCookie(&http.Cookie{Name: "orbit_console_session", Value: token}) + res := httptest.NewRecorder() + server.Handler.ServeHTTP(res, req) + if res.Code != status { + t.Fatalf("%s status=%d wanted=%d", path, res.Code, status) + } + if res.Header().Get("Cache-Control") != "no-store" { + t.Fatal("management response cacheable") + } + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + return envelope.Data + } + prepare := func(in consoleauth.ApplicationInput) consoleauth.ApplicationOperation { + t.Helper() + var op consoleauth.ApplicationOperation + if err := json.Unmarshal(request("/console/applications/prepare", in, login.Token, 200), &op); err != nil { + t.Fatal(err) + } + return op + } + apply := func(op consoleauth.ApplicationOperation) consoleauth.ApplicationReceipt { + t.Helper() + var receipt consoleauth.ApplicationReceipt + if err := json.Unmarshal(request("/console/applications/apply", op, login.Token, 200), &receipt); err != nil { + t.Fatal(err) + } + return receipt + } + created := prepare(consoleauth.ApplicationInput{Kind: "CREATE", Application: "managed-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Capabilities: []string{"worker"}, ValidDays: 30, Reason: "create permission fixture"}) + apply(created) + // 限定测试凭据队列,验证补动作不会扩大为命名空间通配范围。 + queues, _ := json.Marshal([]string{string(created.QueueID)}) + if _, err := db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',CAST(? AS JSON)) WHERE namespace_id=? AND id=?`, queues, created.NamespaceID, created.CredentialID); err != nil { + t.Fatal(err) + } + rotation := prepare(consoleauth.ApplicationInput{Kind: "ROTATE", NamespaceID: created.NamespaceID, CredentialID: created.CredentialID, ExpectedRevision: 1, ValidDays: 30, GraceDays: 7, Reason: "establish token overlap fixture"}) + apply(rotation) + current, _ := auth.Parse(rotation.Token) + previous, _ := auth.Parse(created.Token) + detail, err := store.GetApplication(ctx, admin, created.NamespaceID) + if err != nil { + t.Fatal(err) + } + if len(detail.Credentials) != 1 { + t.Fatal("fixture credential missing") + } + oldPermissions := detail.Credentials[0].Permissions + actions := append(append([]string(nil), oldPermissions.Actions...), "schedules:read", "schedules:manage") + in := consoleauth.ApplicationInput{Kind: "SET_PERMISSIONS", NamespaceID: created.NamespaceID, CredentialID: created.CredentialID, ExpectedRevision: 2, Actions: actions, Reason: "allow scheduled tasks"} + for _, unauthorized := range []auth.Token{sdk, {}} { + if _, err := store.PrepareApplication(ctx, unauthorized, in); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("non-admin prepare accepted", err) + } + } + if _, err := NewConsoleApplicationStore(db, "other-admin", ring).PrepareApplication(ctx, admin, in); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("other admin prepare accepted", err) + } + request("/console/applications/prepare", in, initial.Token, 401) + op := prepare(in) + if op.Version != 3 || op.Token != "" || op.ExpiresAt != nil || op.PreviousValidUntil != nil || !sort.StringsAreSorted(op.Input.Actions) { + t.Fatal("permission prepare produced wrong fixed format or secret") + } + // 准备只返回新意图,不变更当前权限;原秘密仍在,但尚无计划读取授权。 + schedules, err := NewScheduleStore(db, testID(t)) + if err != nil { + t.Fatal(err) + } + for _, token := range []auth.Token{current, previous} { + if _, err := schedules.List(ctx, token, created.QueueID, schedule.PageInput{Limit: 1}); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("prepare granted permission", err) + } + } + type preserved struct { + Digest, Previous, Cipher, Queues []byte + Key sql.NullString + Expires, Until sql.NullTime + } + snapshot := func() preserved { + t.Helper() + var p preserved + if err := db.QueryRowContext(ctx, `SELECT secret_digest,previous_secret_digest,secret_ciphertext,JSON_EXTRACT(permissions,'$.queues'),secret_key_id,expires_at,previous_valid_until FROM orbit_credentials WHERE namespace_id=? AND id=?`, created.NamespaceID, created.CredentialID).Scan(&p.Digest, &p.Previous, &p.Cipher, &p.Queues, &p.Key, &p.Expires, &p.Until); err != nil { + t.Fatal(err) + } + return p + } + before := snapshot() + if len(before.Cipher) == 0 || len(before.Previous) == 0 || !before.Expires.Valid || !before.Until.Valid { + t.Fatal("fixture does not exercise vault, expiry and overlap") + } + for _, mutate := range []func(*consoleauth.ApplicationOperation){ + func(v *consoleauth.ApplicationOperation) { v.Token = rotation.Token }, + func(v *consoleauth.ApplicationOperation) { v.QueueID = created.QueueID }, + func(v *consoleauth.ApplicationOperation) { v.ApplicationID = testID(t) }, + func(v *consoleauth.ApplicationOperation) { v.ExpiresAt = rotation.ExpiresAt }, + func(v *consoleauth.ApplicationOperation) { v.Version = 2 }, + } { + bad := op + mutate(&bad) + request("/console/applications/apply", bad, login.Token, 400) + } + if _, err := store.ApplyApplication(ctx, sdk, op); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("SDK apply accepted", err) + } + receipt := apply(op) + if receipt.Deduplicated || receipt.Revision != 3 || receipt.CredentialID != created.CredentialID { + t.Fatal("unexpected permission receipt") + } + replay := apply(op) + if !replay.Deduplicated || replay.Revision != receipt.Revision { + t.Fatal("original operation did not replay") + } + if !reflect.DeepEqual(before, snapshot()) { + t.Fatal("permission change modified secret, vault, expiry, overlap or queues") + } + for _, token := range []auth.Token{current, previous} { + if _, err := schedules.List(ctx, token, created.QueueID, schedule.PageInput{Limit: 1}); err != nil { + t.Fatal("original token did not gain requested action", err) + } + if err := NewTaskStore(db).AuthorizeSubmit(ctx, token); err != nil { + t.Fatal("existing action was lost", err) + } + if _, err := schedules.List(ctx, token, testID(t), schedule.PageInput{Limit: 1}); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("queue scope expanded", err) + } + } + request("/console/applications/prepare", in, login.Token, 409) + changed := op + changed.Input.Actions = append([]string(nil), op.Input.Actions[:len(op.Input.Actions)-1]...) + request("/console/applications/apply", changed, login.Token, 409) + detail, err = store.GetApplication(ctx, admin, created.NamespaceID) + if err != nil { + t.Fatal(err) + } + if detail.Credentials[0].Revision != 3 || !reflect.DeepEqual(detail.Credentials[0].Permissions.Actions, op.Input.Actions) || !reflect.DeepEqual(detail.Credentials[0].Permissions.Queues, oldPermissions.Queues) { + t.Fatal("detail does not reflect exact action replacement") + } + var actor, reason, beforeAudit, afterAudit string + if err := db.QueryRowContext(ctx, "SELECT actor_id,reason,CAST(`before` AS CHAR),CAST(`after` AS CHAR) FROM orbit_audit_events WHERE namespace_id=? AND operation_id=? AND action='credential.set_permissions'", created.NamespaceID, op.OperationID).Scan(&actor, &reason, &beforeAudit, &afterAudit); err != nil { + t.Fatal(err) + } + if actor != "console:"+login.User.ID || reason != in.Reason || !strings.Contains(afterAudit, "schedules:manage") || strings.Contains(beforeAudit, "schedules:manage") { + t.Fatal("permission audit lacks actor or exact before/after") + } + var auditCount, tokenViews int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?`, created.NamespaceID, op.OperationID).Scan(&auditCount); err != nil { + t.Fatal(err) + } + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action LIKE 'console.credential.token.%'`, created.NamespaceID).Scan(&tokenViews); err != nil { + t.Fatal(err) + } + if auditCount != 1 || tokenViews != 0 { + t.Fatal("permission operation duplicated audit or accessed token") + } + for _, secret := range []string{created.Token, rotation.Token} { + if strings.Contains(beforeAudit, secret) || strings.Contains(afterAudit, secret) { + t.Fatal("audit leaked token") + } + } + if err := authStore.RevokeSession(ctx, admin); err != nil { + t.Fatal(err) + } + request("/console/applications/apply", op, login.Token, 401) + if _, err := store.ApplyApplication(ctx, admin, op); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("revoked session replay accepted", err) + } + var foreignKeys int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 { + t.Fatalf("foreign keys=%d err=%v", foreignKeys, err) + } +} diff --git a/internal/storage/mysql/credential_permissions_integration_test.go b/internal/storage/mysql/credential_permissions_integration_test.go new file mode 100644 index 0000000..2adb562 --- /dev/null +++ b/internal/storage/mysql/credential_permissions_integration_test.go @@ -0,0 +1,438 @@ +package mysql + +import ( + "bytes" + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "reflect" + "sort" + "strings" + "testing" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/credentialvault" + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/pulse/schedule" + "git.yuntuo.hk/k2safe/orbit/pulse/service" +) + +func TestCredentialPermissionOperationValidation(t *testing.T) { + base := CredentialChangeInput{NamespaceID: testID(t), CredentialID: testID(t), ExpectedRevision: 1, Action: credentialPermissionsAction, Reason: "replace explicit actions", Actions: []string{"schedules:manage", "tasks:read"}} + if err := validateCredentialChangeInput(base); err != nil { + t.Fatal(err) + } + for _, actions := range [][]string{nil, {}, {""}, {"*"}, {"schedules:*"}, {"unknown"}, {"tasks:read", "tasks:read"}, {" tasks:read"}} { + bad := base + bad.Actions = actions + if err := validateCredentialChangeInput(bad); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatalf("actions %q accepted: %v", actions, err) + } + } + for _, field := range []string{"validity", "grace"} { + bad := base + if field == "validity" { + bad.ValidFor = time.Hour + } else { + bad.GraceFor = time.Hour + } + if err := validateCredentialChangeInput(bad); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatalf("permission change accepted %s: %v", field, err) + } + } + op := CredentialOperation{Version: 3, OperationID: testID(t), NamespaceID: base.NamespaceID, CredentialID: base.CredentialID, ExpectedRevision: 1, Action: credentialPermissionsAction, Reason: base.Reason, Actions: base.Actions} + if err := ValidateCredentialOperation(op); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*CredentialOperation){ + func(v *CredentialOperation) { v.Version = 1 }, + func(v *CredentialOperation) { v.Version = 2 }, + func(v *CredentialOperation) { v.Actions = []string{"tasks:read", "schedules:manage"} }, + func(v *CredentialOperation) { v.Actions = []string{"tasks:read", "tasks:read"} }, + func(v *CredentialOperation) { v.Actions = nil }, + func(v *CredentialOperation) { v.Token = "a token is forbidden" }, + func(v *CredentialOperation) { v.ExpiresAt = timePtr(time.Now().Add(time.Hour)) }, + func(v *CredentialOperation) { v.PreviousValidUntil = timePtr(time.Now().Add(time.Hour)) }, + func(v *CredentialOperation) { v.Action = credentialExpiryAction }, + } { + bad := op + mutate(&bad) + if err := ValidateCredentialOperation(bad); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatalf("invalid permission operation accepted: %v", err) + } + } + // 旧固定文件的精确字段、序列化原文及摘要保持不变;包括旧轮换的秘密字段。 + type legacyOperation struct { + Version int `json:"version"` + OperationID core.ID `json:"operation_id"` + NamespaceID core.ID `json:"namespace_id"` + CredentialID core.ID `json:"credential_id"` + ExpectedRevision uint64 `json:"expected_revision"` + Action string `json:"action"` + Reason string `json:"reason"` + Token string `json:"token,omitempty"` + PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + } + for _, version := range []int{1, 2} { + for _, action := range []string{credentialRotateAction, credentialRevokeAction, credentialExpiryAction, credentialFinalizeAction} { + if version == 1 && action != credentialRotateAction && action != credentialRevokeAction { + continue + } + old := legacyOperation{Version: version, OperationID: testID(t), NamespaceID: base.NamespaceID, CredentialID: base.CredentialID, ExpectedRevision: 1, Action: action, Reason: "legacy frozen shape"} + if action == credentialRotateAction { + old.Token, _, _ = auth.Issue(base.NamespaceID, base.CredentialID) + old.ExpiresAt = timePtr(time.Now().Add(time.Hour)) + } + raw, _ := json.Marshal(old) + var current CredentialOperation + if err := json.Unmarshal(raw, ¤t); err != nil || ValidateCredentialOperation(current) != nil { + t.Fatal("legacy operation no longer valid", err) + } + currentRaw, _ := json.Marshal(current) + oldDigest, _ := requestDigest(old) + currentDigest, _ := requestDigest(current) + if !bytes.Equal(raw, currentRaw) || oldDigest != currentDigest { + t.Fatal("legacy JSON or digest changed") + } + for _, actions := range [][]string{{}, {"tasks:read"}} { + current.Actions = actions + if err := ValidateCredentialOperation(current); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatal("legacy operation accepted actions", err) + } + bad := base + bad.Action, bad.Actions = action, actions + if err := validateCredentialChangeInput(bad); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatal("legacy preparation accepted actions", err) + } + } + } + } +} + +func TestIntegrationCredentialPermissions(t *testing.T) { + ctx, db := overlapDB(t) + ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{43}, 32)) + `"}}`)) + if err != nil { + t.Fatal(err) + } + var initial BootstrapResult + if err := Bootstrap(ctx, db, BootstrapInput{Application: "permissions-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", ValidFor: 48 * time.Hour}, func(v BootstrapResult) error { initial = v; return nil }, ring); err != nil { + t.Fatal(err) + } + oldToken, _ := auth.Parse(initial.Token) + tasks := NewTaskStore(db) + otherQueue, err := service.New(tasks).CreateQueue(ctx, oldToken, string(testID(t)), service.QueueInput{Name: "outside-scope", MaxConcurrency: 1, RatePerSecond: 1}) + if err != nil { + t.Fatal(err) + } + // 隔离测试夹具先收窄队列,证明添加动作不能隐式恢复命名空间通配范围。 + queues, _ := json.Marshal([]string{string(initial.QueueID)}) + if _, err := db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',CAST(? AS JSON)) WHERE namespace_id=? AND id=?`, queues, initial.NamespaceID, initial.CredentialID); err != nil { + t.Fatal(err) + } + schedules, err := NewScheduleStore(db, testID(t)) + if err != nil { + t.Fatal(err) + } + scheduleAPI := schedule.New(schedules) + plan := schedule.CreateInput{Name: "permission-check", Paused: true, Definition: schedule.Definition{ + Rule: schedule.Rule{Kind: "FIXED_RATE", Timezone: "UTC", StartAt: time.Now().UTC().Add(time.Hour), IntervalMS: 60000}, + Policy: schedule.Policy{Misfire: "SKIP", Overlap: "PARALLEL", CatchupLimit: 1, CatchupWindowMS: 30000, GraceMS: 1000}, + Task: service.SubmitInput{QueueID: initial.QueueID, Handler: "permission.check", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)}, + }} + if _, err := scheduleAPI.Create(ctx, oldToken, string(testID(t)), plan); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("schedule permission unexpectedly present before change", err) + } + prepare := func(revision uint64, actions []string) CredentialOperation { + t.Helper() + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: revision, Action: credentialPermissionsAction, Reason: "explicit complete action set", Actions: actions}) + if err != nil { + t.Fatal(err) + } + return op + } + rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: 24 * time.Hour, GraceFor: time.Hour, Reason: "test two retained generations"}) + if err != nil { + t.Fatal(err) + } + if _, err = ApplyCredentialOperation(ctx, db, rotate, ring); err != nil { + t.Fatal(err) + } + before, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID) + if err != nil { + t.Fatal(err) + } + // 快照只在测试内比较,绝不把摘要、密文或 Token 打到失败日志。 + protected := func() string { + t.Helper() + var out string + if err := db.QueryRowContext(ctx, `SELECT CAST(JSON_OBJECT('namespace',HEX(namespace_id),'id',HEX(id),'name',name,'current',HEX(secret_digest),'previous',HEX(previous_secret_digest),'key',secret_key_id,'cipher',HEX(secret_ciphertext),'expiry',expires_at,'grace',previous_valid_until,'revoked',revoked_at,'queues',JSON_EXTRACT(permissions,'$.queues'),'created',created_at) AS CHAR) FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&out); err != nil { + t.Fatal(err) + } + return out + } + frozen := protected() + actions := []string{"tasks:submit", "schedules:read", "queues:read", "schedules:manage"} + op := prepare(2, actions) + actions[0] = "tasks:cancel" // Prepare 必须复制输入,不共享调用方数组。 + if op.Version != 3 || op.Token != "" || !sort.StringsAreSorted(op.Actions) || op.Actions[len(op.Actions)-1] != "tasks:submit" { + t.Fatal("permission operation did not freeze a canonical secret-free set") + } + trigger := "permissions_audit_" + string(testID(t)) + if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='credential.set_permissions' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='permission audit failure test'; END IF; END"); err != nil { + t.Fatal(err) + } + defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger) + if _, err := ApplyCredentialOperation(ctx, db, op); err == nil { + t.Fatal("audit failure accepted") + } + failed, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID) + if err != nil || !reflect.DeepEqual(failed, before) || protected() != frozen { + t.Fatal("failed audit did not roll back credential state", err) + } + var rows int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_idempotency_keys WHERE namespace_id=? AND id=?`, initial.NamespaceID, op.OperationID).Scan(&rows); err != nil || rows != 0 { + t.Fatal("failed audit kept operation identity", err) + } + if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil { + t.Fatal(err) + } + grant, err := ApplyCredentialOperation(ctx, db, op) // 无 vault 也不能清除既有密文。 + if err != nil || grant.Revision != 3 || grant.Deduplicated || protected() != frozen { + t.Fatal("permission grant changed protected credential state", err) + } + newToken, _ := auth.Parse(rotate.Token) + assertActions := func(want auth.Permissions) { + t.Helper() + for _, token := range []auth.Token{oldToken, newToken} { + err := WithTx(ctx, db, func(tx *sql.Tx) error { + p, err := authenticate(ctx, tx, token) + if err == nil && !reflect.DeepEqual(p, want) { + t.Error("token authentication did not reload exact current permissions") + } + return err + }) + if err != nil { + t.Fatal("preserved token no longer authenticates", err) + } + if _, err := tasks.GetQueue(ctx, token, initial.QueueID); err != nil { + t.Fatal("original queue unavailable", err) + } + if _, err := tasks.GetQueue(ctx, token, otherQueue.Queue.ID); fault.Code(err) != "NOT_FOUND" { + t.Fatal("permission change expanded queue scope", err) + } + } + } + after, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID) + if err != nil || !reflect.DeepEqual(after.Permissions.Actions, op.Actions) || !reflect.DeepEqual(after.Permissions.Queues, before.Permissions.Queues) { + t.Fatal("exact permissions status incorrect", err) + } + assertActions(after.Permissions) + created, err := scheduleAPI.Create(ctx, oldToken, string(testID(t)), plan) + if err != nil { + t.Fatal("new permission did not immediately allow real schedule creation", err) + } + if _, err := scheduleAPI.Get(ctx, newToken, created.Schedule.ID); err != nil { + t.Fatal("current token did not receive new schedule read permission", err) + } + forbiddenPlan := plan + forbiddenPlan.Name = "outside-queue" + forbiddenPlan.Definition.Task.QueueID = otherQueue.Queue.ID + if _, err := scheduleAPI.Create(ctx, newToken, string(testID(t)), forbiddenPlan); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("new schedule action bypassed preserved queue scope", err) + } + var auditBefore, auditAfter []byte + if err := db.QueryRowContext(ctx, "SELECT `before`,`after` FROM orbit_audit_events WHERE namespace_id=? AND operation_id=?", initial.NamespaceID, op.OperationID).Scan(&auditBefore, &auditAfter); err != nil { + t.Fatal(err) + } + for i, raw := range [][]byte{auditBefore, auditAfter} { + var audit struct { + Permissions auth.Permissions `json:"permissions"` + } + if err := json.Unmarshal(raw, &audit); err != nil { + t.Fatal(err) + } + want := before.Permissions + if i == 1 { + want = after.Permissions + } + if !reflect.DeepEqual(audit.Permissions, want) || bytes.Contains(raw, []byte("secret_")) || bytes.Contains(raw, []byte(rotate.Token)) { + t.Fatal("permissions audit missing exact scope or leaked secret") + } + } + if replay, err := ApplyCredentialOperation(ctx, db, op); err != nil || !replay.Deduplicated || replay.Revision != grant.Revision { + t.Fatal("permission replay changed receipt", err) + } + tampered := op + tampered.Actions = []string{"queues:read"} + if _, err := ApplyCredentialOperation(ctx, db, tampered); fault.Code(err) != "IDEMPOTENCY_CONFLICT" { + t.Fatal("changed frozen actions accepted", err) + } + wrongScope := op + wrongScope.NamespaceID = testID(t) + if _, err := ApplyCredentialOperation(ctx, db, wrongScope); fault.Code(err) != "NOT_FOUND" { + t.Fatal("cross namespace credential accepted", err) + } + remove := prepare(3, []string{"queues:read"}) + if _, err := ApplyCredentialOperation(ctx, db, remove); err != nil || protected() != frozen { + t.Fatal("permission removal changed protected state", err) + } + assertActions(auth.Permissions{Actions: remove.Actions, Queues: before.Permissions.Queues}) + for _, token := range []auth.Token{oldToken, newToken} { + if err := tasks.AuthorizeSubmit(ctx, token); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("removed permission still usable", err) + } + if _, err := scheduleAPI.Get(ctx, token, created.Schedule.ID); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("removed schedule permission still usable", err) + } + } + if _, err := ApplyCredentialOperation(ctx, db, op); err != nil { + t.Fatal(err) + } + assertActions(auth.Permissions{Actions: remove.Actions, Queues: before.Permissions.Queues}) + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action='credential.set_permissions'`, initial.NamespaceID, initial.CredentialID).Scan(&rows); err != nil || rows != 2 { + t.Fatal("replay duplicated permission audit", err) + } + var foreignKeys int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&foreignKeys); err != nil || foreignKeys != 0 { + t.Fatal("schema acquired a foreign key", err) + } +} + +func TestIntegrationCredentialPermissionsLegacyReplay(t *testing.T) { + ctx, db := overlapDB(t) + var initial BootstrapResult + if err := Bootstrap(ctx, db, BootstrapInput{Application: "permission-legacy-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil { + t.Fatal(err) + } + old, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: credentialRotateAction, ValidFor: time.Hour, Reason: "legacy v1 replay"}) + if err != nil { + t.Fatal(err) + } + old.Version = 1 + if _, err := ApplyCredentialOperation(ctx, db, old); err != nil { + t.Fatal("legacy v1 no longer applies", err) + } + permissions, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 2, Action: credentialPermissionsAction, Actions: []string{"queues:read"}, Reason: "new permission revision"}) + if err != nil { + t.Fatal(err) + } + if _, err := ApplyCredentialOperation(ctx, db, permissions); err != nil { + t.Fatal(err) + } + if replay, err := ApplyCredentialOperation(ctx, db, old); err != nil || !replay.Deduplicated || replay.Revision != 2 { + t.Fatal("legacy receipt no longer replays after v3", err) + } + expiry, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 3, Action: credentialExpiryAction, Reason: "legacy v2 still generated"}) + if err != nil || expiry.Version != 2 { + t.Fatal("old action changed fixed format", err) + } + if _, err := ApplyCredentialOperation(ctx, db, expiry); err != nil { + t.Fatal("legacy v2 no longer applies", err) + } + status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID) + if err != nil || status.Revision != 4 || status.ExpiresAt != nil || !reflect.DeepEqual(status.Permissions.Actions, permissions.Actions) { + t.Fatal("legacy changes overwrote new permissions", err) + } +} + +// 竞争由专用事务持有真实凭据行锁,确认每个请求实际到达 InnoDB 等待后再放行。 +func competeCredentialPermissions(t *testing.T, ctx context.Context, db *sql.DB, namespace, credential core.ID, operations ...func() error) []error { + t.Helper() + guard, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + defer guard.Rollback() + var connection uint64 + if err := guard.QueryRowContext(ctx, `SELECT CONNECTION_ID()`).Scan(&connection); err != nil { + t.Fatal(err) + } + var revision uint64 + if err := guard.QueryRowContext(ctx, `SELECT revision FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision); err != nil { + t.Fatal(err) + } + results := make([]chan error, len(operations)) + for i, operation := range operations { + results[i] = make(chan error, 1) + go func(ch chan error, run func() error) { ch <- run() }(results[i], operation) + deadline := time.Now().Add(5 * time.Second) + for { + select { + case early := <-results[i]: + t.Fatalf("permission competitor returned before DB lock: %v", early) + default: + } + var waiting int + if err := db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT w.REQUESTING_THREAD_ID) FROM performance_schema.data_lock_waits w JOIN performance_schema.threads b ON b.THREAD_ID=w.BLOCKING_THREAD_ID WHERE b.PROCESSLIST_ID=?`, connection).Scan(&waiting); err != nil { + t.Fatal(err) + } + if waiting == i+1 { + break + } + if time.Now().After(deadline) { + t.Fatalf("only %d/%d requests reached credential lock", waiting, i+1) + } + time.Sleep(5 * time.Millisecond) + } + } + if err := guard.Commit(); err != nil { + t.Fatal(err) + } + errs := make([]error, len(operations)) + for i, ch := range results { + select { + case errs[i] = <-ch: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + return errs +} + +func TestIntegrationCredentialPermissionsConcurrency(t *testing.T) { + ctx, db := overlapDB(t) + var initial BootstrapResult + if err := Bootstrap(ctx, db, BootstrapInput{Application: "permission-race-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil { + t.Fatal(err) + } + prepare := func(revision uint64, action string) CredentialOperation { + t.Helper() + op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: revision, Action: credentialPermissionsAction, Reason: "permission CAS race", Actions: []string{action}}) + if err != nil { + t.Fatal(err) + } + return op + } + op := prepare(1, "queues:read") + var receipts [2]CredentialChangeReceipt + errs := competeCredentialPermissions(t, ctx, db, initial.NamespaceID, initial.CredentialID, + func() (err error) { receipts[0], err = ApplyCredentialOperation(ctx, db, op); return }, + func() (err error) { receipts[1], err = ApplyCredentialOperation(ctx, db, op); return }) + if errs[0] != nil || errs[1] != nil || receipts[0].Deduplicated == receipts[1].Deduplicated || receipts[0].Revision != 2 || receipts[1].Revision != 2 { + t.Fatalf("concurrent replay: %v", errs) + } + left, right := prepare(2, "tasks:read"), prepare(2, "schedules:read") + errs = competeCredentialPermissions(t, ctx, db, initial.NamespaceID, initial.CredentialID, + func() error { _, err := ApplyCredentialOperation(ctx, db, left); return err }, + func() error { _, err := ApplyCredentialOperation(ctx, db, right); return err }) + if !((errs[0] == nil && fault.Code(errs[1]) == "STATE_CONFLICT") || (errs[1] == nil && fault.Code(errs[0]) == "STATE_CONFLICT")) { + t.Fatalf("permission CAS did not select exactly one winner: %v", errs) + } + winner := left + if errs[1] == nil { + winner = right + } + status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID) + if err != nil || status.Revision != 3 || !reflect.DeepEqual(status.Permissions.Actions, winner.Actions) { + t.Fatal("CAS did not persist winner", err) + } + var count int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND action='credential.set_permissions'`, initial.NamespaceID).Scan(&count); err != nil || count != 2 { + t.Fatal("losing CAS or replay created audit", err) + } +} diff --git a/internal/storage/mysql/credentials.go b/internal/storage/mysql/credentials.go index e0770d8..e8b4ac2 100644 --- a/internal/storage/mysql/credentials.go +++ b/internal/storage/mysql/credentials.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/json" "fmt" + "sort" "strings" "time" "unicode/utf8" @@ -17,12 +18,14 @@ import ( ) const ( - credentialOperationVersion = 2 - credentialRotateAction = "ROTATE" - credentialRevokeAction = "REVOKE" - credentialExpiryAction = "SET_EXPIRY" - credentialFinalizeAction = "FINALIZE" - credentialOperationName = "credential.change.v1" + credentialOperationVersion = 2 + credentialPermissionsVersion = 3 + credentialRotateAction = "ROTATE" + credentialRevokeAction = "REVOKE" + credentialExpiryAction = "SET_EXPIRY" + credentialFinalizeAction = "FINALIZE" + credentialPermissionsAction = "SET_PERMISSIONS" + credentialOperationName = "credential.change.v1" ) // CredentialStatus 是管理读取的脱敏凭据状态;永远不包含秘密或摘要。 @@ -46,6 +49,8 @@ type CredentialStatus struct { NamespaceEnabled bool `json:"namespace_enabled"` // ApplicationEnabled 所属应用是否启用。 ApplicationEnabled bool `json:"application_enabled"` + // Permissions 当前精确授权集合;队列范围只读,不允许凭据变更扩大或替换。 + Permissions auth.Permissions `json:"permissions"` } // CredentialChangeInput 描述一次待固定的管理变更。 @@ -57,7 +62,7 @@ type CredentialChangeInput struct { CredentialID core.ID // ExpectedRevision 准备操作时观察到的版本。 ExpectedRevision uint64 - // Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。 + // Action 为 ROTATE、REVOKE、SET_EXPIRY、FINALIZE 或 SET_PERMISSIONS。 Action string // GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。 GraceFor time.Duration @@ -65,6 +70,8 @@ type CredentialChangeInput struct { ValidFor time.Duration // Reason 必填审计原因。 Reason string + // Actions 仅 SET_PERMISSIONS 使用的完整非空动作集合,替换旧集合但保留队列范围。 + Actions []string } // CredentialOperation 是可安全重放的私有操作文件内容。 @@ -90,6 +97,8 @@ type CredentialOperation struct { PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"` // ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。 ExpiresAt *time.Time `json:"expires_at,omitempty"` + // Actions v3 SET_PERMISSIONS 的排序完整动作集合;省略保证旧 v1/v2 摘要不变。 + Actions []string `json:"actions,omitempty"` } // CredentialChangeReceipt 是服务端保存并可重复返回的脱敏结果。 @@ -127,18 +136,25 @@ func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential return out, err } var expires, revoked, previousUntil sql.NullTime - err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled + var permissions []byte + err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled,c.permissions FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE c.namespace_id=? AND c.id=?`, namespace, credential). - Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled) + Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled, &permissions) if err == sql.ErrNoRows { return out, fault.NotFound } if err != nil { return out, err } + if err := json.Unmarshal(permissions, &out.Permissions); err != nil { + return out, fmt.Errorf("stored credential permissions are invalid: %w", err) + } + if err := out.Permissions.Validate(); err != nil { + return out, err + } out.NamespaceID, out.CredentialID = namespace, credential out.ExpiresAt = nullableTime(expires) out.PreviousValidUntil = nullableTime(previousUntil) @@ -182,6 +198,11 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh out.ExpiresAt = timePtr(now.Add(in.ValidFor)) } out.Version = credentialOperationVersion + if in.Action == credentialPermissionsAction { + out.Version = credentialPermissionsVersion + out.Actions = append([]string(nil), in.Actions...) + sort.Strings(out.Actions) + } out.NamespaceID = in.NamespaceID out.CredentialID = in.CredentialID out.ExpectedRevision = in.ExpectedRevision @@ -197,7 +218,7 @@ func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialCh // ValidateCredentialOperation validates the exact private operation shape. // It is exported so the admin CLI can reject tampered files before opening a DB. func ValidateCredentialOperation(op CredentialOperation) error { - if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { + if (op.Version != 1 && op.Version != credentialOperationVersion && op.Version != credentialPermissionsVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil { return fault.Invalid("credential operation identity or version is invalid") } if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) { @@ -206,6 +227,16 @@ func ValidateCredentialOperation(op CredentialOperation) error { if err := validCredentialReason(op.Reason); err != nil { return err } + if op.Action == credentialPermissionsAction { + if op.Version != credentialPermissionsVersion || !sort.StringsAreSorted(op.Actions) { + return fault.Invalid("permission operation requires v3 and sorted actions") + } + if err := validateCredentialActions(op.Actions); err != nil { + return err + } + } else if op.Version == credentialPermissionsVersion || op.Actions != nil { + return fault.Invalid("actions are only valid for v3 SET_PERMISSIONS") + } if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) { return fault.Invalid("legacy operation requires its original expiry and immediate rotation") } @@ -255,13 +286,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper var name string var revision uint64 var expires, revoked, previousUntil sql.NullTime + var storedPermissions []byte var namespaceEnabled, applicationEnabled bool - err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled + err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled,c.permissions FROM orbit_credentials c JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID). - Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled) + Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled, &storedPermissions) if err == sql.ErrNoRows { return fault.NotFound } @@ -297,6 +329,24 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper out.ExpiresAt = nullableTime(expires) out.PreviousValidUntil = nullableTime(previousUntil) switch op.Action { + case credentialPermissionsAction: + var previous auth.Permissions + if err := json.Unmarshal(storedPermissions, &previous); err != nil { + return fmt.Errorf("stored credential permissions are invalid: %w", err) + } + if err := previous.Validate(); err != nil { + return err + } + current := auth.Permissions{Actions: op.Actions, Queues: previous.Queues} + before["permissions"], after["permissions"] = previous, current + actions, err := json.Marshal(op.Actions) + if err != nil { + return err + } + // 只替换动作路径;队列 JSON、两代摘要、密文、有效期和过渡截止均原样保留。 + if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',CAST(? AS JSON)),revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, actions, now, op.NamespaceID, op.CredentialID, revision); err != nil { + return err + } case credentialRotateAction: parsed, _ := auth.Parse(op.Token) // 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。 @@ -486,12 +536,33 @@ func validateCredentialChangeInput(in CredentialChangeInput) error { if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) { return fault.Invalid("validity must be zero (permanent) or between one hour and one year") } - if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 { + if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction || in.Action == credentialPermissionsAction) && in.ValidFor != 0 { return fault.Invalid("this operation cannot set validity") } if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) { return fault.Invalid("rotation grace must be between zero and 30 days") } + if in.Action == credentialPermissionsAction { + return validateCredentialActions(in.Actions) + } + if in.Actions != nil { + return fault.Invalid("actions are only valid for SET_PERMISSIONS") + } + return nil +} + +// validateCredentialActions 复用认证动作白名单,并拒绝重复项,避免全量替换的歧义。 +func validateCredentialActions(actions []string) error { + if err := (auth.Permissions{Actions: actions, Queues: []string{"*"}}).Validate(); err != nil { + return err + } + seen := make(map[string]bool, len(actions)) + for _, action := range actions { + if seen[action] { + return fault.Invalid("duplicate permission action") + } + seen[action] = true + } return nil } @@ -516,5 +587,5 @@ func timePtr(v time.Time) *time.Time { } func validCredentialAction(action string) bool { - return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction + return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction || action == credentialPermissionsAction } diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index b6cbe23..faf9136 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -1,8 +1,34 @@ (() => { const $ = id => document.getElementById(id); - const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '' }; + const ui = { after: '', next: '', selected: '', detail: null, loading: false, busy: false, pending: null, rotating: null, result: null, saved: true, mode: 'CREATE', helpCredential: '', editNamespace: '' }; const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '长期有效'; + + // 与 platform/auth/token.go 的精确动作目录一致;不按能力组自动增加权限。 + const permissionGroups = [ + ['任务', false, [['tasks:submit', '提交任务'], ['tasks:read', '查询任务'], ['tasks:cancel', '取消任务']]], + ['队列', false, [['queues:read', '查询队列'], ['queues:manage', '管理队列']]], + ['Worker 执行', false, [['workers:connect', '建立连接'], ['workers:consume', '领取与执行任务']]], + ['结果订阅', false, [['events:read', '查询订阅与结果'], ['events:manage', '管理订阅'], ['events:consume', '消费与确认结果']]], + ['定时调度', false, [['schedules:read', '查询计划'], ['schedules:manage', '管理计划']]], + ['流程编排', false, [['workflows:definitions:read', '查询定义'], ['workflows:definitions:publish', '发布定义'], ['workflows:start', '启动流程'], ['workflows:read', '查询流程'], ['workflows:control', '控制流程'], ['workflows:signal', '发送信号']]], + ['高级管理 · 人工恢复与审计', true, [['tasks:review', '核查任务结果'], ['tasks:retry', '人工重试任务'], ['tasks:audit:read', '读取任务操作审计'], ['workflows:recover', '人工恢复流程'], ['workflows:audit:read', '读取流程审计']]], + ['高级管理 · 应用额度', true, [['application:resources:read', '查询应用额度'], ['application:resources:manage', '调整应用额度']]] + ]; + const permissionActions = new Set(permissionGroups.flatMap(([, , actions]) => actions.map(([action]) => action))); + function unsupportedPermissions(credential) { + const actions = credential.permissions.actions; + return actions.some(action => !permissionActions.has(action)) || new Set(actions).size !== actions.length; + } + function renderPermissionEditor(credential) { + const selected = new Set(credential.permissions.actions); + $('access-permission-scope').textContent = `命名空间:${ui.editNamespace} · 凭据:${credential.id} · 版本 ${credential.revision}`; + $('access-permission-current').textContent = credential.permissions.actions.join(' · '); + $('access-permission-queues').textContent = credential.permissions.queues.join(' · '); + $('access-permission-lifetime').textContent = `当前 Token 有效期:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 过渡截止:${time(credential.previous_valid_until)}。` : '无旧 Token 过渡。'}`; + $('access-permission-warning').hidden = !unsupportedPermissions(credential); + $('access-permission-options').innerHTML = permissionGroups.map(([name, advanced, actions]) => `
${escape(name)}${actions.map(([action, label]) => ``).join('')}
`).join(''); + } let tokenRequest = 0, tokenTarget = null, selectionSequence = 0; const resourceMessages = { 'resource is retired; history remains available':'资源已退役,历史仍可查询。', @@ -102,7 +128,7 @@
接入配置 · 当前 Token${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}
${rotationHelp} -
凭据管理与权限
${c.previous_valid_until && !c.revoked_at ? `` : ''}
+
凭据管理与权限
${c.previous_valid_until && !c.revoked_at ? `` : ''}
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

`; }).join('') || '

该环境暂无凭据。

'; @@ -168,23 +194,28 @@ } function open(credential = null, mode = 'ROTATE') { if (ui.busy) return; - ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.pending = null; ui.result = null; ui.saved = true; + ui.rotating = credential; ui.mode = credential ? mode : 'CREATE'; ui.editNamespace = ui.selected; ui.pending = null; ui.result = null; ui.saved = true; $('access-form').reset(); $('access-form').querySelectorAll('input,select,button').forEach(el => { el.disabled = false; }); $('access-form').hidden = false; $('access-result').hidden = true; $('access-backup').hidden = true; $('access-form-error').textContent = ''; $('access-result-config').value = ''; $('access-clipboard-status').textContent = ''; $('access-create-fields').hidden = !!credential; $('access-create-fields').querySelectorAll('input,select').forEach(el => { el.disabled = !!credential; }); - const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡' }; + $('access-permission-editor').hidden = ui.mode !== 'SET_PERMISSIONS'; + if (ui.mode === 'SET_PERMISSIONS') renderPermissionEditor(credential); + $('access-permission-options').querySelectorAll('input').forEach(el => { el.disabled = ui.mode !== 'SET_PERMISSIONS'; }); + const titles = { CREATE: '新建应用', ROTATE: '平滑换证', SET_EXPIRY: '调整有效期', FINALIZE: '结束过渡', SET_PERMISSIONS: '调整权限' }; $('access-form-title').textContent = titles[ui.mode]; - $('access-valid-field').hidden = ui.mode === 'FINALIZE'; + $('access-valid-field').hidden = ['FINALIZE', 'SET_PERMISSIONS'].includes(ui.mode); $('access-grace-field').hidden = ui.mode !== 'ROTATE'; $('access-form-note').textContent = { CREATE: '为应用创建独立的环境、队列与调用凭据,默认长期有效。', ROTATE: '生成新 Token 后,旧 Token 在过渡期内继续可用。逐台更新服务配置并正常收尾在途任务;确认全部切换后,再结束过渡。', SET_EXPIRY: '只调整当前 Token 的有效期,Token 和业务配置保持不变。已有旧 Token 的过渡截止不会延长。', - FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。' + FINALIZE: '此操作立即停用旧 Token,新 Token 继续有效。请先确认全部服务、Worker 和结果消费者已切换,在途执行已收尾。', + SET_PERMISSIONS: '勾选的动作将完整替换当前权限,取消勾选会收回对应权限。当前和过渡期 Token 均使用调整后的权限;Token、有效期、过渡截止及队列范围保持不变。' }[ui.mode]; $('access-token-once-note').hidden = !['CREATE', 'ROTATE'].includes(ui.mode); $('access-submit').textContent = ui.mode === 'CREATE' ? '创建并生成凭据 →' : `确认${titles[ui.mode]} →`; + $('access-submit').disabled = ui.mode === 'SET_PERMISSIONS' && unsupportedPermissions(credential); $('access-dialog').showModal(); (credential ? $('access-reason') : $('access-name')).focus(); } function close() { @@ -204,9 +235,16 @@ const form = $('access-form'); const disabled = [...form.elements].map(el => [el, el.disabled]); try { if (!ui.pending) { - const input = { kind: ui.mode, valid_days: ui.mode === 'FINALIZE' ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' }; + const input = { kind: ui.mode, valid_days: ['FINALIZE', 'SET_PERMISSIONS'].includes(ui.mode) ? 0 : Number($('access-valid-days').value), reason: $('access-reason').value.trim(), expected_revision: ui.rotating ? String(ui.rotating.revision) : '0' }; if (ui.mode === 'ROTATE') input.grace_days = Number($('access-grace-days').value); - if (ui.rotating) Object.assign(input, { namespace_id: ui.selected, credential_id: ui.rotating.id }); + if (ui.mode === 'SET_PERMISSIONS') { + if (unsupportedPermissions(ui.rotating)) throw new Error('当前凭据包含页面不支持或重复的动作,请更新控制台并核对权限后再操作。'); + input.actions = [...$('access-permission-options').querySelectorAll('input:checked')].map(el => el.value).sort(); + if (!input.actions.length) throw new Error('请至少保留一项权限。'); + if (input.actions.some(action => !permissionActions.has(action)) || new Set(input.actions).size !== input.actions.length) throw new Error('权限选择无效,请重新打开调整权限。'); + input.grace_days = 0; + } + if (ui.rotating) Object.assign(input, { namespace_id: ui.editNamespace, credential_id: ui.rotating.id }); else Object.assign(input, { application: $('access-name').value.trim(), namespace: $('access-namespace').value.trim(), environment: $('access-environment').value, queue: $('access-queue-name').value.trim(), capabilities: [...document.querySelectorAll('.access-capabilities input:checked')].map(el => el.value) }); form.querySelectorAll('input,select,button').forEach(el => { el.disabled = true; }); ui.pending = await api('/prepare', input); @@ -220,13 +258,14 @@ await select(op.namespace_id); const credential = ui.detail?.entry.namespace_id === op.namespace_id && ui.detail.credentials.find(c => c.id === op.credential_id); const expected = op.input.kind === 'CREATE' ? '1' : String(BigInt(op.input.expected_revision) + 1n); - if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情;不要使用旧操作中的 Token。'); + if (!credential || String(credential.revision) !== expected || credential.revoked_at) throw new Error('操作已提交,但凭据已被更新或暂时无法核对,请刷新应用详情后核对当前状态。'); + if (op.input.kind === 'SET_PERMISSIONS' && JSON.stringify([...credential.permissions.actions].sort()) !== JSON.stringify([...op.input.actions].sort())) throw new Error('操作已提交,但当前权限暂时无法核对,请保留原操作重试确认。'); if (!credential.permissions.queues.includes('*') && !credential.permissions.queues.includes(queue)) queue = ui.detail.queues.find(q => credential.permissions.queues.includes(q.id))?.id || ''; const preparedConfig = op.token ? config(op.token, queue, op.namespace_id) : ''; ui.result = op; ui.pending = null; $('access-form').hidden = true; $('access-result').hidden = false; $('access-token-once-note').hidden = true; - $('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用' }[op.input.kind]; + $('access-result-title').textContent = { CREATE: '应用已创建,可以接入', ROTATE: '新 Token 已生效', SET_EXPIRY: '有效期已更新,原 Token 继续使用', FINALIZE: '过渡已结束,旧 Token 已停用', SET_PERMISSIONS: '权限已更新,原 Token 继续使用' }[op.input.kind]; $('access-result-note').hidden = !op.token; $('access-result-config-field').hidden = !op.token; $('access-result-actions').hidden = !op.token; $('access-result-config').value = preparedConfig; $('access-result-expiry').textContent = `当前 Token:${time(credential.expires_at)}。${credential.previous_valid_until ? `旧 Token 截止:${time(credential.previous_valid_until)},请在此之前完成切换。` : '无旧 Token 过渡。'}`; @@ -241,12 +280,12 @@ $('access-url').value = location.origin; $('access-list').addEventListener('click', e => { const el = e.target.closest('[data-access-id]'); if (el) { closeToken(); select(el.dataset.accessId); } }); $('access-credentials').addEventListener('click', e => { - const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return; - const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessToken || el.dataset.accessCopyConfig; + const el = e.target.closest('[data-access-help],[data-access-rotate],[data-access-expiry],[data-access-finalize],[data-access-permissions],[data-access-token],[data-access-copy-config]'); if (!el || el.disabled) return; + const id = el.dataset.accessHelp || el.dataset.accessRotate || el.dataset.accessExpiry || el.dataset.accessFinalize || el.dataset.accessPermissions || el.dataset.accessToken || el.dataset.accessCopyConfig; const credential = ui.detail?.credentials.find(c => c.id === id); if (credential && (el.dataset.accessToken || el.dataset.accessCopyConfig)) { showToken(credential, !!el.dataset.accessCopyConfig); return; } if (credential && el.dataset.accessHelp) { showCredentialHelp(credential); return; } - if (credential && !credential.revoked_at) open(credential, el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE'); + if (credential && !credential.revoked_at) open(credential, el.dataset.accessPermissions ? 'SET_PERMISSIONS' : el.dataset.accessExpiry ? 'SET_EXPIRY' : el.dataset.accessFinalize ? 'FINALIZE' : 'ROTATE'); }); $('access-help-close').addEventListener('click', () => $('access-help-dialog').close()); $('access-help-done').addEventListener('click', () => $('access-help-dialog').close()); diff --git a/internal/transport/httpserver/console/index.html b/internal/transport/httpserver/console/index.html index 6e250f5..17e08d1 100644 --- a/internal/transport/httpserver/console/index.html +++ b/internal/transport/httpserver/console/index.html @@ -379,6 +379,14 @@
接入能力 默认包含任务提交、查询与队列管理
+
diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index 8ae7b40..33ece5d 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -151,3 +151,5 @@ input:focus-visible,textarea:focus-visible { outline:none; } @media(max-width:640px){.data-panel .table-wrap::before{content:"左右滑动查看完整表格 →";display:block;padding:0 15px 10px;color:var(--muted);font-size:10px}.retention-panel{max-width:none}} .login-audit-dialog{width:min(960px,calc(100vw - 32px))}.login-audit-dialog .table-wrap{margin:20px 0}.login-audit-dialog th,.login-audit-dialog td{padding:12px;text-align:left;white-space:nowrap}.login-audit-dialog .audit-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px} + +.access-permission-editor{margin-top:18px}.access-permission-readonly{border:1px solid var(--line);border-radius:7px;background:var(--soft);padding:12px 14px;margin:14px 0;font-size:11px;color:var(--muted);line-height:1.8}.access-permission-readonly strong,.access-permission-readonly code,.access-permission-readonly small{display:block}.access-permission-readonly code{font-size:11px;color:var(--text);overflow-wrap:anywhere}.access-permission-readonly p{margin-top:8px}.access-action-group{border:1px solid var(--line);border-radius:7px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:14px;margin-top:15px}.access-action-group legend{padding:0 5px;font-size:12px;color:var(--text)}.access-action-option{display:flex;align-items:flex-start;gap:8px;cursor:pointer;color:var(--text);font-size:11px}.access-action-option input{margin:2px 0 0}.access-action-option code{display:block;font-size:10px;color:var(--muted);overflow-wrap:anywhere;line-height:1.7}.access-action-advanced{border-color:#eee2c5;background:#fffcf5}.access-action-advanced legend{color:#826838}@media(max-width:480px){.access-action-group{grid-template-columns:1fr}} diff --git a/internal/transport/httpserver/console_applications_test.go b/internal/transport/httpserver/console_applications_test.go index 0e94980..b3ed51b 100644 --- a/internal/transport/httpserver/console_applications_test.go +++ b/internal/transport/httpserver/console_applications_test.go @@ -15,7 +15,11 @@ import ( "git.yuntuo.hk/k2safe/orbit/platform/auth" ) -type fakeApplications struct{ calls int } +type fakeApplications struct { + calls int + input consoleauth.ApplicationInput + op consoleauth.ApplicationOperation +} func (f *fakeApplications) ListApplications(context.Context, auth.Token, core.ID) (consoleauth.ApplicationPage, error) { f.calls++ @@ -25,12 +29,14 @@ func (f *fakeApplications) GetApplication(context.Context, auth.Token, core.ID) f.calls++ return consoleauth.ApplicationDetail{}, nil } -func (f *fakeApplications) PrepareApplication(context.Context, auth.Token, consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) { +func (f *fakeApplications) PrepareApplication(_ context.Context, _ auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) { f.calls++ + f.input = in return consoleauth.ApplicationOperation{}, nil } -func (f *fakeApplications) ApplyApplication(context.Context, auth.Token, consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) { +func (f *fakeApplications) ApplyApplication(_ context.Context, _ auth.Token, op consoleauth.ApplicationOperation) (consoleauth.ApplicationReceipt, error) { f.calls++ + f.op = op return consoleauth.ApplicationReceipt{}, nil } func TestConsoleApplicationSessionAndCSRF(t *testing.T) { @@ -91,7 +97,7 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) { queue, _ := core.NewID() opID, _ := core.NewID() token, _, _ := auth.Issue(ns, cred) - for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE"} { + for _, kind := range []string{"CREATE", "ROTATE", "SET_EXPIRY", "FINALIZE", "SET_PERMISSIONS"} { op := consoleauth.ApplicationOperation{Input: consoleauth.ApplicationInput{Kind: kind, ValidDays: 30, Reason: "test"}, OperationID: opID, ApplicationID: app, NamespaceID: ns, CredentialID: cred, Token: token, ExpiresAt: func() *time.Time { v := time.Now().UTC(); return &v }()} if kind == "CREATE" { op.QueueID = queue @@ -104,12 +110,16 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) { op.Input.CredentialID = cred op.Input.ExpectedRevision = 1 } - if kind == "SET_EXPIRY" || kind == "FINALIZE" { + if kind == "SET_EXPIRY" || kind == "FINALIZE" || kind == "SET_PERMISSIONS" { op.Version = 2 op.Token = "" op.ExpiresAt = nil op.Input.ValidDays = 0 } + if kind == "SET_PERMISSIONS" { + op.Version = 3 + op.Input.Actions = []string{"schedules:manage", "schedules:read", "tasks:submit"} + } raw, err := json.Marshal(op) if err != nil { t.Fatal(err) @@ -125,3 +135,88 @@ func TestConsolePreparedApplicationRoundTrip(t *testing.T) { } } } + +// 权限操作仍走管理员 Cookie 与同源保护;新数组字段不能放宽精确 JSON 解码。 +func TestConsolePermissionChangeHTTPBoundary(t *testing.T) { + ns, _ := core.NewID() + cred, _ := core.NewID() + raw, _, _ := auth.Issue(ns, cred) + valid := `{"kind":"SET_PERMISSIONS","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `","expected_revision":"7","reason":"enable scheduling","actions":["tasks:submit","schedules:read","schedules:manage"]}` + for _, apply := range []bool{false, true} { + path := "/console/applications/prepare" + wrap := func(in string) string { return in } + if apply { + path = "/console/applications/apply" + wrap = func(in string) string { + return `{"version":3,"input":` + in + `,"operation_id":"` + string(ns) + `","application_id":"` + string(ns) + `","namespace_id":"` + string(ns) + `","credential_id":"` + string(cred) + `"}` + } + } + for _, tc := range []struct { + name, body, origin, fetch string + cookie, validSession, header bool + status int + }{ + {"valid", valid, "http://example.com", "same-origin", true, true, true, 200}, + {"SDK bearer only", valid, "", "", false, true, true, 401}, + {"expired session", valid, "", "", true, false, true, 401}, + {"missing custom header", valid, "", "", true, true, false, 403}, + {"cross origin", valid, "https://elsewhere.example", "", true, true, true, 403}, + {"opaque origin", valid, "null", "", true, true, true, 403}, + {"origin credentials", valid, "http://user@example.com", "", true, true, true, 403}, + {"cross site metadata", valid, "http://example.com", "cross-site", true, true, true, 403}, + {"duplicate actions", strings.Replace(valid, `"actions":`, `"actions":[],"actions":`, 1), "", "", true, true, true, 400}, + {"field alias", strings.Replace(valid, `"actions":`, `"Actions":`, 1), "", "", true, true, true, 400}, + {"unknown scope field", strings.TrimSuffix(valid, "}") + `,"queues":["*"]}`, "", "", true, true, true, 400}, + {"null actions", strings.Replace(valid, `["tasks:submit","schedules:read","schedules:manage"]`, `null`, 1), "", "", true, true, true, 400}, + {"null element", strings.Replace(valid, `"tasks:submit"`, `null`, 1), "", "", true, true, true, 400}, + {"numeric element", strings.Replace(valid, `"tasks:submit"`, `12`, 1), "", "", true, true, true, 400}, + {"numeric revision", strings.Replace(valid, `"expected_revision":"7"`, `"expected_revision":7`, 1), "", "", true, true, true, 400}, + } { + t.Run(path+"/"+tc.name, func(t *testing.T) { + authStore := &fakeConsoleAuth{valid: tc.validSession} + store := &fakeApplications{} + server := NewWithConsoleAuth("", nil, authStore) + RegisterConsoleApplications(server, authStore, store) + req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(wrap(tc.body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+raw) + if tc.cookie { + req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw}) + } + if tc.header { + req.Header.Set("X-Orbit-Console", "1") + } + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.fetch != "" { + req.Header.Set("Sec-Fetch-Site", tc.fetch) + } + res := httptest.NewRecorder() + server.Handler.ServeHTTP(res, req) + if res.Code != tc.status { + t.Fatalf("status=%d want=%d", res.Code, tc.status) + } + if res.Header().Get("Cache-Control") != "no-store" { + t.Fatal("permission response is cacheable") + } + if tc.status != 200 { + if store.calls != 0 { + t.Fatal("rejected request reached store") + } + return + } + input := store.input + if apply { + input = store.op.Input + if store.op.Version != 3 || store.op.Token != "" { + t.Fatal("permission envelope changed") + } + } + if store.calls != 1 || input.Kind != "SET_PERMISSIONS" || input.ExpectedRevision != 7 || len(input.Actions) != 3 || input.Actions[2] != "schedules:manage" { + t.Fatal("permission intent changed") + } + }) + } + } +} diff --git a/scripts/test-console-state.cjs b/scripts/test-console-state.cjs index db818d2..00e3e25 100644 --- a/scripts/test-console-state.cjs +++ b/scripts/test-console-state.cjs @@ -55,3 +55,167 @@ test('时间边界验证、流程范围与无队列保护', () => { assert.throws(() => listURL('tasks', ''), /选择队列/); assert.equal(new URL(listURL('workflows', 'ignored'), 'http://localhost').searchParams.has('queue_id'), false); }); + +// 用页面原始 HTML 构建轻量 DOM,执行 applications.js 的真实点击与提交处理器。 +// 不引入浏览器依赖;网络边界独立模拟,断言最终请求及用户可见状态。 +function applicationsHarness({ actions = ['tasks:read', 'workers:connect'], revoked = false, loseApply = false } = {}) { + const decode = text => text.replace(/&(amp|lt|gt|quot|#39);/g, (_, key) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[key])); + class Element { + constructor(tag = '', attrs = {}) { + this.tag = tag; this.attrs = attrs; this.children = []; this.parent = null; this.listeners = {}; + this.value = attrs.value || ''; this.defaultValue = this.value; + this.checked = 'checked' in attrs; this.defaultChecked = this.checked; + this.disabled = 'disabled' in attrs; this.hidden = 'hidden' in attrs; this.open = false; this.text = ''; + this.dataset = Object.fromEntries(Object.entries(attrs).filter(([k]) => k.startsWith('data-')).map(([k, v]) => [k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v])); + this.classList = { toggle() {} }; + } + matches(selector) { + const id = selector.match(/#([\w-]+)/)?.[1], cls = selector.match(/\.([\w-]+)/)?.[1], tag = selector.match(/^[\w-]+/)?.[0]; + return (!id || this.attrs.id === id) && (!cls || (this.attrs.class || '').split(' ').includes(cls)) && (!tag || this.tag === tag) && + (!selector.includes(':checked') || this.checked) && [...selector.matchAll(/\[([\w-]+)\]/g)].every(([, a]) => a === 'open' ? this.open : a in this.attrs); + } + querySelectorAll(selector) { + const all = this.children.flatMap(c => [c, ...c.querySelectorAll('*')]); + return all.filter(el => selector.split(',').some(s => { + const parts = s.trim().split(/\s+/); if (!el.matches(parts.pop())) return false; + let ancestor = el.parent; + while (parts.length) { const part = parts.pop(); while (ancestor && !ancestor.matches(part)) ancestor = ancestor.parent; if (!ancestor) return false; ancestor = ancestor.parent; } + return true; + })); + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + closest(selector) { return selector.split(',').some(s => this.matches(s)) ? this : this.parent?.closest(selector); } + set innerHTML(html) { this.html = html; this.children = []; parse(html, this); } + get innerHTML() { return this.html || ''; } + set textContent(text) { this.text = String(text); this.children = []; } + get textContent() { return this.text + this.children.map(c => c.textContent).join(''); } + get elements() { return this.querySelectorAll('input,select,button,textarea'); } + reset() { this.elements.forEach(el => { el.value = el.defaultValue; el.checked = el.defaultChecked; }); } + addEventListener(name, handler) { (this.listeners[name] ||= []).push(handler); } + async fire(name, target = this) { for (const handler of this.listeners[name] || []) await handler({ target, preventDefault() {} }); } + showModal() { this.open = true; } + close() { this.open = false; } + focus() { document.activeElement = this; } + } + function parse(html, root) { + const stack = [root]; + for (const token of html.matchAll(/|<[^>]+>|[^<]+/g)) { + const raw = token[0]; if (raw.startsWith('