feat: manage existing credential actions with audited replay

This commit is contained in:
2026-11-01 19:10:37 +08:00
parent baa81c4923
commit 98ad60c0b8
18 changed files with 1350 additions and 53 deletions

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