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) } }