Files
orbit/internal/storage/mysql/console_permissions_integration_test.go

232 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}