feat: manage existing credential actions with audited replay
This commit is contained in:
@@ -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 := ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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、凭据或业务数据。
|
||||
|
||||
@@ -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. 最终交付目标
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ go run ./cmd/orbit-admin credential status \
|
||||
--credential-id <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 <namespace_id> --credential-id <credential_id> \
|
||||
--expected-revision <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 必须立即停用时执行撤销:
|
||||
|
||||
|
||||
@@ -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 来代替核对。
|
||||
|
||||
39
docs/reports/credential-permissions-20261101.md
Normal file
39
docs/reports/credential-permissions-20261101.md
Normal file
@@ -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 协议或赋予普通凭据跨应用权限。上述验证不能替代生产多节点部署、权限变更审批和目标拓扑的正式验收;实际接入方授权记录位于工作区对接文档,不写入通用平台代码。
|
||||
@@ -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 新建或已核验的应用标识。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
231
internal/storage/mysql/console_permissions_integration_test.go
Normal file
231
internal/storage/mysql/console_permissions_integration_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]) => `<fieldset class="access-action-group${advanced ? ' access-action-advanced' : ''}"><legend>${escape(name)}</legend>${actions.map(([action, label]) => `<label class="access-action-option"><input type="checkbox" value="${escape(action)}" ${selected.has(action) ? 'checked' : ''}><span>${escape(label)}<code>${escape(action)}</code></span></label>`).join('')}</fieldset>`).join('');
|
||||
}
|
||||
let tokenRequest = 0, tokenTarget = null, selectionSequence = 0;
|
||||
const resourceMessages = {
|
||||
'resource is retired; history remains available':'资源已退役,历史仍可查询。',
|
||||
@@ -102,7 +128,7 @@
|
||||
<div class="access-token-summary"><div><strong>接入配置 · 当前 Token</strong><span>${c.revoked_at ? '凭据已撤销' : c.token_stored ? '•••••••••••••••• · 已加密保管' : '历史凭据 · 导入当前 Token 后可随时查看'}</span></div></div>
|
||||
<div class="access-form-actions"><button type="button" class="primary-button compact" data-access-token="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>${c.token_stored ? '查看接入信息' : '导入当前 Token'}</button><button type="button" class="secondary-button compact" data-access-copy-config="${escape(c.id)}" ${c.revoked_at || !c.token_stored ? 'disabled' : ''}>复制接入配置 ↗</button></div>
|
||||
${rotationHelp}
|
||||
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<details class="credential-controls"><summary>凭据管理与权限</summary><div class="access-form-actions"><button class="text-button" data-access-permissions="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整权限</button><button class="text-button" data-access-expiry="${escape(c.id)}" ${c.revoked_at ? 'disabled' : ''}>调整有效期</button>${c.previous_valid_until && !c.revoked_at ? `<button class="text-button" data-access-finalize="${escape(c.id)}">结束过渡,停用旧 Token</button>` : ''}</div>
|
||||
<div class="access-permissions">${groups.map(a => `<span>${escape(a)}</span>`).join('')}</div><details class="access-permission-details"><summary>查看具体权限</summary><p>${c.permissions.actions.map(escape).join(' · ')}<br>授权队列:${escape(c.permissions.queues.join(', '))}</p></details></details>
|
||||
</section>`;
|
||||
}).join('') || '<p class="panel-description">该环境暂无凭据。</p>';
|
||||
@@ -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());
|
||||
|
||||
@@ -379,6 +379,14 @@
|
||||
<label>初始队列名称<input id="access-queue-name" value="default" maxlength="128" required pattern="[A-Za-z0-9][A-Za-z0-9_.:\-]*"></label>
|
||||
<fieldset class="access-capabilities"><legend>接入能力 <small>默认包含任务提交、查询与队列管理</small></legend><label><input type="checkbox" value="worker" checked> Worker 执行</label><label><input type="checkbox" value="events" checked> 结果订阅</label><label><input type="checkbox" value="schedules" checked> 定时调度</label><label><input type="checkbox" value="workflows" checked> 流程编排</label><label><input type="checkbox" value="recovery"> 人工恢复</label><label><input type="checkbox" value="resources"> 应用额度管理</label></fieldset>
|
||||
</div>
|
||||
<section id="access-permission-editor" class="access-permission-editor" hidden aria-label="凭据动作权限">
|
||||
<p class="panel-description" id="access-permission-scope"></p>
|
||||
<details class="access-permission-details"><summary>当前精确权限</summary><p id="access-permission-current"></p></details>
|
||||
<div class="access-permission-readonly"><strong>授权队列 · 只读</strong><code id="access-permission-queues"></code><small>星号表示当前命名空间内的全部队列。此次操作保留现有范围。</small><p id="access-permission-lifetime"></p></div>
|
||||
<p class="action-error" id="access-permission-warning" role="alert" hidden>当前凭据包含页面不支持或重复的动作,已禁止提交。请更新控制台并核对权限,避免遗漏现有授权。</p>
|
||||
<p class="panel-description">逐项选择所需权限。高级管理动作需明确勾选,不会随其他能力自动开通。</p>
|
||||
<div id="access-permission-options"></div>
|
||||
</section>
|
||||
<div class="access-config-grid"><label id="access-valid-field">凭据有效期<select id="access-valid-days"><option value="0">长期有效</option><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option></select></label><label>操作原因<input id="access-reason" maxlength="1024" required placeholder="例如:接入订单服务"></label></div>
|
||||
<label id="access-grace-field" hidden>旧 Token 过渡期<select id="access-grace-days"><option value="7">7 天</option><option value="1">1 天</option><option value="30">30 天</option></select><small>新旧 Token 同时可用。请在截止前完成所有服务的配置更新;旧 Token 原到期时间更早时,以原到期时间为准。</small></label>
|
||||
<p class="action-error" id="access-form-error" role="alert"></p>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g)) {
|
||||
const raw = token[0]; if (raw.startsWith('<!--') || raw.startsWith('<!')) continue;
|
||||
const tag = raw.match(/^<(\/?)([\w-]+)/);
|
||||
if (!tag) { stack.at(-1).text += decode(raw); continue; }
|
||||
if (tag[1]) { if (stack.at(-1).tag === tag[2]) stack.pop(); continue; }
|
||||
const attrs = Object.fromEntries([...raw.slice(tag[0].length).matchAll(/([\w:-]+)(?:="([^"]*)")?/g)].map(([, k, v]) => [k, decode(v || '')]));
|
||||
const element = new Element(tag[2], attrs); element.parent = stack.at(-1); element.parent.children.push(element);
|
||||
if (element.tag === 'option' && element.parent.tag === 'select' && (element.parent.children.length === 1 || 'selected' in attrs)) element.parent.value = element.parent.defaultValue = element.value;
|
||||
if (!['input', 'meta', 'link', 'br', 'hr', 'img', 'path'].includes(element.tag) && !raw.endsWith('/>')) stack.push(element);
|
||||
}
|
||||
}
|
||||
const document = new Element('document');
|
||||
document.getElementById = id => { const el = document.querySelector(`#${id}`); assert.ok(el, `HTML 缺少 ${id}`); return el; };
|
||||
const base = require('node:path').join(__dirname, '../internal/transport/httpserver/console/');
|
||||
parse(fs.readFileSync(base + 'index.html', 'utf8'), document);
|
||||
const detail = {
|
||||
entry: { application: 'sample-service', environment: 'test', namespace: 'default', namespace_id: 'ns-one', application_id: 'app-one', enabled: true },
|
||||
queues: [{ id: 'queue-one', name: 'primary' }, { id: 'queue-two', name: 'secondary' }],
|
||||
credentials: [{ id: 'cred-one', name: 'main', revision: '7', token_stored: true, expires_at: '2030-01-01T00:00:00Z', previous_valid_until: '2029-01-01T00:00:00Z', ...(revoked ? { revoked_at: '2026-01-01T00:00:00Z' } : {}), permissions: { actions, queues: ['queue-two'] } }]
|
||||
};
|
||||
const requests = [], window = { addEventListener() {}, confirm: () => true };
|
||||
let failed = false;
|
||||
const sandbox = vm.createContext({ document, window, location: { origin: 'http://localhost', replace() {} }, performance: { now: () => 0 }, URL, localStorage: { getItem() { return null; } }, fetch: async (path, options) => {
|
||||
const body = options.body ? JSON.parse(options.body) : undefined;
|
||||
requests.push({ path, body });
|
||||
let data;
|
||||
if (path === '/console/applications') data = { items: [detail.entry] };
|
||||
else if (path === '/console/applications/ns-one') data = detail;
|
||||
else if (path === '/console/applications/prepare') data = { version: body.kind === 'SET_PERMISSIONS' ? 3 : 2, operation_id: 'op-fixed', namespace_id: body.namespace_id, credential_id: body.credential_id, input: body };
|
||||
else if (path === '/console/applications/apply') {
|
||||
detail.credentials[0].revision = '8';
|
||||
if (body.input.actions) detail.credentials[0].permissions.actions = [...body.input.actions];
|
||||
if (loseApply && !failed) { failed = true; throw new Error('响应连接中断'); }
|
||||
data = {};
|
||||
} else throw new Error(`意外请求:${path}`);
|
||||
return { ok: true, status: 200, json: async () => ({ data: JSON.parse(JSON.stringify(data)) }) };
|
||||
} });
|
||||
vm.runInContext(fs.readFileSync(base + 'applications.js', 'utf8'), sandbox);
|
||||
const get = id => document.getElementById(id);
|
||||
return {
|
||||
get, requests, detail,
|
||||
load: () => window.orbitApplications.load(),
|
||||
open: async (action = 'permissions') => { const button = get('access-credentials').querySelector(`[data-access-${action}]`); assert.ok(button, `缺少 ${action} 操作`); await get('access-credentials').fire('click', button); },
|
||||
options: () => get('access-permission-options').querySelectorAll('input'),
|
||||
submit: () => get('access-form').fire('submit')
|
||||
};
|
||||
}
|
||||
|
||||
test('凭据权限弹窗展示完整动作目录,默认保留精确权限和只读队列', async () => {
|
||||
const h = applicationsHarness(); await h.load(); await h.open();
|
||||
assert.equal(h.get('access-dialog').open, true);
|
||||
assert.equal(h.get('access-permission-editor').hidden, false);
|
||||
assert.deepEqual(h.options().filter(el => el.checked).map(el => el.value).sort(), ['tasks:read', 'workers:connect']);
|
||||
const authSource = fs.readFileSync(require('node:path').join(__dirname, '../platform/auth/token.go'), 'utf8');
|
||||
const catalog = [...authSource.match(/var supported = map\[string\]bool\{([^}]+)\}/)[1].matchAll(/"([^"]+)": true/g)].map(([, a]) => a).sort();
|
||||
assert.deepEqual(h.options().map(el => el.value).sort(), catalog, '新增服务端动作必须同步精确选择目录');
|
||||
assert.match(h.get('access-permission-options').textContent, /高级管理 · 人工恢复与审计/);
|
||||
assert.equal(h.get('access-permission-current').textContent, 'tasks:read · workers:connect');
|
||||
assert.equal(h.get('access-permission-queues').textContent, 'queue-two');
|
||||
assert.equal(h.get('access-permission-queues').querySelectorAll('input,select').length, 0);
|
||||
assert.equal(h.get('access-valid-field').hidden, true);
|
||||
assert.equal(h.get('access-grace-field').hidden, true);
|
||||
assert.equal(h.get('access-token-once-note').hidden, true);
|
||||
});
|
||||
|
||||
test('调整权限提交精确全量集合,响应丢失后原操作重放并刷新详情,不读取 Token', async () => {
|
||||
const h = applicationsHarness({ loseApply: true }); await h.load(); await h.open();
|
||||
h.options().forEach(el => { el.checked = ['tasks:read', 'schedules:read', 'schedules:manage'].includes(el.value); });
|
||||
h.get('access-reason').value = '启用计划管理';
|
||||
h.get('access-valid-days').value = '365'; h.get('access-grace-days').value = '30';
|
||||
await h.submit();
|
||||
const prepared = h.requests.find(r => r.path.endsWith('/prepare')).body;
|
||||
assert.deepEqual(prepared, { kind: 'SET_PERMISSIONS', valid_days: 0, grace_days: 0, reason: '启用计划管理', expected_revision: '7', namespace_id: 'ns-one', credential_id: 'cred-one', actions: ['schedules:manage', 'schedules:read', 'tasks:read'] });
|
||||
assert.equal(h.get('access-submit').textContent, '重试确认 →');
|
||||
assert.equal(h.get('access-backup').hidden, false);
|
||||
assert.ok(h.options().every(el => el.disabled));
|
||||
const fixed = h.requests.find(r => r.path.endsWith('/apply')).body;
|
||||
assert.equal(fixed.version, 3); assert.equal(fixed.token, undefined);
|
||||
h.options().forEach(el => { el.checked = true; }); h.get('access-reason').value = '不同的原因';
|
||||
await h.submit();
|
||||
assert.equal(h.requests.filter(r => r.path.endsWith('/prepare')).length, 1);
|
||||
assert.deepEqual(h.requests.filter(r => r.path.endsWith('/apply'))[1].body, fixed);
|
||||
assert.equal(h.get('access-form').hidden, true);
|
||||
assert.equal(h.get('access-result-title').textContent, '权限已更新,原 Token 继续使用');
|
||||
assert.equal(h.get('access-result-config-field').hidden, true);
|
||||
assert.equal(h.get('access-result-actions').hidden, true);
|
||||
assert.equal(h.get('access-result-config').value, '');
|
||||
assert.equal(h.requests.filter(r => r.path === '/console/applications/ns-one').length, 2);
|
||||
assert.equal(h.requests.some(r => r.path.includes('/token/')), false);
|
||||
assert.deepEqual(h.detail.credentials[0].permissions.queues, ['queue-two']);
|
||||
assert.equal(h.detail.credentials[0].expires_at, '2030-01-01T00:00:00Z');
|
||||
assert.equal(h.detail.credentials[0].previous_valid_until, '2029-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
test('空权限、未知旧动作、通配符及重复旧动作不能静默提交', async () => {
|
||||
const empty = applicationsHarness(); await empty.load(); await empty.open();
|
||||
empty.options().forEach(el => { el.checked = false; }); await empty.submit();
|
||||
assert.match(empty.get('access-form-error').textContent, /至少保留一项/);
|
||||
assert.equal(empty.requests.some(r => r.body), false);
|
||||
for (const actions of [['tasks:read', 'future:manage'], ['*'], ['tasks:read', 'tasks:read']]) {
|
||||
const h = applicationsHarness({ actions }); await h.load(); await h.open();
|
||||
assert.equal(h.get('access-permission-warning').hidden, false);
|
||||
assert.equal(h.get('access-submit').disabled, true);
|
||||
assert.equal(h.get('access-permission-current').textContent, actions.join(' · '));
|
||||
await h.submit();
|
||||
assert.equal(h.requests.some(r => r.body), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('撤销凭据禁止打开权限调整,其他操作不携带动作字段', async () => {
|
||||
const revoked = applicationsHarness({ revoked: true }); await revoked.load(); await revoked.open();
|
||||
assert.equal(revoked.get('access-dialog').open, false);
|
||||
const h = applicationsHarness(); await h.load(); await h.open(); await h.open('expiry');
|
||||
assert.equal(h.get('access-permission-editor').hidden, true);
|
||||
assert.ok(h.options().every(el => el.disabled));
|
||||
h.get('access-reason').value = '调整有效期'; await h.submit();
|
||||
const prepared = h.requests.find(r => r.path.endsWith('/prepare')).body;
|
||||
assert.equal(prepared.kind, 'SET_EXPIRY');
|
||||
assert.equal(Object.hasOwn(prepared, 'actions'), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user