232 lines
11 KiB
Go
232 lines
11 KiB
Go
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)
|
||
}
|
||
}
|