feat: manage existing credential actions with audited replay
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user