feat: show application ownership and scoped console management
This commit is contained in:
135
internal/storage/mysql/console_scope.go
Normal file
135
internal/storage/mysql/console_scope.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
// consoleScopeActor 在业务读写的同一事务校验原会话、部署指定管理员及目标应用。
|
||||
// 仅持共享身份锁,之后沿用业务既有的额度→队列→资源锁序。
|
||||
func consoleScopeActor(ctx context.Context, tx *sql.Tx, t auth.Token) (core.ID, error) {
|
||||
if t.Console == nil || t.Console.Username == "" || t.Console.SessionNamespaceID.Validate() != nil || t.NamespaceID.Validate() != nil {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
var user core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT u.id FROM orbit_console_sessions s
|
||||
JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id
|
||||
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
|
||||
JOIN orbit_namespaces n ON n.id=s.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
|
||||
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE AND u.username=?
|
||||
AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
|
||||
AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.Console.SessionNamespaceID, t.CredentialID, t.Digest, t.Console.Username).Scan(&user)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if t.Console.UserID != "" && user != t.Console.UserID {
|
||||
return "", fault.Forbidden
|
||||
}
|
||||
var id core.ID
|
||||
err = tx.QueryRowContext(ctx, `SELECT n.id FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=? AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, t.NamespaceID).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fault.NotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func consoleScopePermissions() auth.Permissions {
|
||||
return auth.Permissions{Actions: []string{"tasks:read", "tasks:cancel", "tasks:review", "tasks:retry", "tasks:audit:read", "queues:read", "queues:manage", "schedules:read", "schedules:manage", "events:read", "events:manage", "workflows:read", "workflows:control", "workflows:recover", "workflows:audit:read"}, Queues: []string{"*"}}
|
||||
}
|
||||
|
||||
func (s *ConsoleApplicationStore) ResolveScope(ctx context.Context, t auth.Token, target core.ID) (auth.Token, error) {
|
||||
if target.Validate() != nil {
|
||||
return auth.Token{}, fault.Invalid("invalid console namespace")
|
||||
}
|
||||
out := t
|
||||
out.NamespaceID = target
|
||||
out.Console = &auth.ConsoleIdentity{SessionNamespaceID: t.NamespaceID, Username: s.username}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
user, err := consoleScopeActor(ctx, tx, out)
|
||||
out.Console.UserID = user
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *ConsoleApplicationStore) GetScope(ctx context.Context, t auth.Token, after core.ID) (consoleauth.Scope, error) {
|
||||
out := consoleauth.Scope{Queues: []consoleauth.AccessQueue{}}
|
||||
if after != "" && after.Validate() != nil {
|
||||
return out, fault.Invalid("invalid queue cursor")
|
||||
}
|
||||
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
p, err := authenticate(ctx, tx, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.Console != nil {
|
||||
out.CanSwitch = true
|
||||
} else {
|
||||
// 元数据接口也必须核验真实控制台会话,SDK 不能读取此入口。
|
||||
var user core.ID
|
||||
err = tx.QueryRowContext(ctx, `SELECT u.id,(u.username=?) FROM orbit_console_sessions s JOIN orbit_console_users u ON u.id=s.user_id AND u.namespace_id=s.namespace_id AND u.credential_id=s.credential_id WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6) AND u.enabled=TRUE FOR SHARE`, s.username, t.NamespaceID, t.CredentialID, t.Digest).Scan(&user, &out.CanSwitch)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.Unauthorized
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e := &out.Entry
|
||||
err = tx.QueryRowContext(ctx, `SELECT a.id,a.name,n.id,n.name,n.environment,(a.enabled AND n.enabled) FROM orbit_namespaces n JOIN orbit_applications a ON a.id=n.application_id WHERE n.id=?`, t.NamespaceID).Scan(&e.ApplicationID, &e.Application, &e.NamespaceID, &e.Namespace, &e.Environment, &e.Enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := `SELECT id,name FROM pulse_queues WHERE namespace_id=?`
|
||||
args := []any{t.NamespaceID}
|
||||
if after != "" {
|
||||
query += ` AND id>?`
|
||||
args = append(args, after)
|
||||
}
|
||||
all := false
|
||||
for _, q := range p.Queues {
|
||||
if q == "*" {
|
||||
all = true
|
||||
}
|
||||
}
|
||||
if !all {
|
||||
query += " AND id IN ("
|
||||
for i, q := range p.Queues {
|
||||
if i > 0 {
|
||||
query += ","
|
||||
}
|
||||
query += "?"
|
||||
args = append(args, core.ID(q))
|
||||
}
|
||||
query += ")"
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, query+` ORDER BY id LIMIT 101`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var q consoleauth.AccessQueue
|
||||
if err := rows.Scan(&q.ID, &q.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Queues = append(out.Queues, q)
|
||||
}
|
||||
if len(out.Queues) > 100 {
|
||||
out.Queues = out.Queues[:100]
|
||||
out.Next = out.Queues[99].ID
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
137
internal/storage/mysql/console_scope_integration_test.go
Normal file
137
internal/storage/mysql/console_scope_integration_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/retention"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
func TestIntegrationConsoleScope(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 8, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
username := "scope-" + string(testID(t))
|
||||
password := "test-password-for-scopes-123"
|
||||
var source, target BootstrapResult
|
||||
for i, dest := range []*BootstrapResult{&source, &target} {
|
||||
in := BootstrapInput{Application: string(testID(t)), Namespace: "default", Environment: "test", Queue: "scope-queue"}
|
||||
if i == 0 {
|
||||
in.ConsoleUsername = username
|
||||
in.ConsolePassword = password
|
||||
}
|
||||
if err = Bootstrap(ctx, db, in, func(v BootstrapResult) error { *dest = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
accounts := NewConsoleAuthStore(db)
|
||||
login, err := accounts.Login(ctx, username, password, "scope-test", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, _ := auth.Parse(login.Token)
|
||||
sdk, _ := auth.Parse(source.Token)
|
||||
scopes := NewConsoleApplicationStore(db, username)
|
||||
initial, err := scopes.GetScope(ctx, session, "")
|
||||
if err != nil || initial.Entry.NamespaceID != source.NamespaceID || !initial.CanSwitch {
|
||||
t.Fatalf("initial scope: %+v %v", initial, err)
|
||||
}
|
||||
if _, err = scopes.GetScope(ctx, sdk, ""); fault.Code(err) != "UNAUTHENTICATED" {
|
||||
t.Fatalf("SDK scope metadata: %v", err)
|
||||
}
|
||||
// 普通账号的队列白名单使用 BINARY ID 查询,不能以字符串错误比较成空列表。
|
||||
if _, err = db.ExecContext(ctx, `UPDATE orbit_credentials SET permissions=JSON_OBJECT('actions',JSON_ARRAY('queues:read'),'queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(source.QueueID), source.NamespaceID, source.CredentialID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limited, err := NewConsoleApplicationStore(db, "another-admin").GetScope(ctx, session, "")
|
||||
if err != nil || limited.CanSwitch || len(limited.Queues) != 1 || limited.Queues[0].ID != source.QueueID {
|
||||
t.Fatalf("limited scope: %+v %v", limited, err)
|
||||
}
|
||||
last, err := scopes.GetScope(ctx, session, source.QueueID)
|
||||
if err != nil || len(last.Queues) != 0 || last.Next != "" {
|
||||
t.Fatalf("queue cursor: %+v %v", last, err)
|
||||
}
|
||||
if _, err = scopes.ResolveScope(ctx, sdk, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("SDK override: %v", err)
|
||||
}
|
||||
for _, name := range []string{"", "another-admin"} {
|
||||
if _, err = NewConsoleApplicationStore(db, name).ResolveScope(ctx, session, target.NamespaceID); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unconfigured admin: %v", err)
|
||||
}
|
||||
}
|
||||
scoped, err := scopes.ResolveScope(ctx, session, target.NamespaceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := NewTaskStore(db)
|
||||
if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tasks.GetQueue(ctx, scoped, source.QueueID); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatalf("foreign queue: %v", err)
|
||||
}
|
||||
if err = tasks.AuthorizeSubmit(ctx, scoped); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("scope must not create work: %v", err)
|
||||
}
|
||||
got, err := scopes.GetScope(ctx, scoped, "")
|
||||
if err != nil || got.Entry.ApplicationID != target.ApplicationID || len(got.Queues) != 1 || got.Queues[0].ID != target.QueueID {
|
||||
t.Fatalf("target metadata: %+v %v", got, err)
|
||||
}
|
||||
// 完整应用/队列名称从数据库读取,包含特殊字符也不改变授权身份。
|
||||
policy := NewRetentionPolicyStore(db)
|
||||
in := retention.UpdateInput{ExpectedRevision: 0, Enabled: false, IdempotencyDays: 120, TaskHistoryDays: 400, WorkflowHistoryDays: 400, EventLogDays: 180, DeliveryHistoryDays: 120, InboxHistoryDays: 120, AuditLogDays: 2555, Reason: "scope policy test"}
|
||||
saved, err := policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in)
|
||||
if err != nil || saved.NamespaceID != target.NamespaceID {
|
||||
t.Fatalf("policy: %+v %v", saved, err)
|
||||
}
|
||||
if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "scope-policy", in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original, err := policy.GetRetentionPolicy(ctx, source.NamespaceID)
|
||||
if err != nil || original.Revision != 0 {
|
||||
t.Fatalf("changed original scope: %+v %v", original, err)
|
||||
}
|
||||
var actor string
|
||||
if err = db.QueryRowContext(ctx, `SELECT actor_id FROM orbit_audit_events WHERE namespace_id=? AND action='retention.policy.update'`, target.NamespaceID).Scan(&actor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actor != scoped.ActorID() {
|
||||
t.Fatalf("audit actor: %s", actor)
|
||||
}
|
||||
// 解析后停用目标应用/退出会话,旧的内存范围不能继续授权。
|
||||
if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=FALSE WHERE id=?`, target.ApplicationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tasks.GetQueue(ctx, scoped, target.QueueID); fault.Code(err) != "NOT_FOUND" {
|
||||
t.Fatalf("disabled target: %v", err)
|
||||
}
|
||||
if _, err = db.ExecContext(ctx, `UPDATE orbit_applications SET enabled=TRUE WHERE id=?`, target.ApplicationID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = accounts.RevokeSession(ctx, session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.ExpectedRevision = 1
|
||||
if _, err = policy.UpdateRetentionPolicy(ctx, scoped, "after-logout", in); fault.Code(err) != "PERMISSION_DENIED" {
|
||||
t.Fatalf("stale scope write: %v", 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 %v", foreignKeys, err)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,10 @@ func (s *EventStore) ListSubscriptions(ctx context.Context, t auth.Token, queue
|
||||
}
|
||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND credential_id=? AND queue_id=?`
|
||||
args := []any{t.NamespaceID, t.CredentialID, queue}
|
||||
if t.Console != nil {
|
||||
q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND queue_id=?`
|
||||
args = []any{t.NamespaceID, queue}
|
||||
}
|
||||
if in.Cursor != "" {
|
||||
c, err := parseEventCursor(in.Cursor, t, queue, "subscriptions", "")
|
||||
if err != nil {
|
||||
|
||||
@@ -28,10 +28,16 @@ const subscriptionColumns = `id,queue_id,credential_id,name,revision,enabled,fil
|
||||
|
||||
func readSubscription(ctx context.Context, tx *sql.Tx, t auth.Token, p auth.Permissions, id core.ID, lock bool) (events.Subscription, error) {
|
||||
q := `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND credential_id=? AND queue_id IS NOT NULL`
|
||||
args := []any{t.NamespaceID, id, t.CredentialID}
|
||||
if t.Console != nil {
|
||||
// 调用方已在同一事务 authenticate;管理员可管理应用内已有订阅,SDK 仍绑定原凭据。
|
||||
q = `SELECT ` + subscriptionColumns + ` FROM orbit_subscriptions WHERE namespace_id=? AND id=? AND queue_id IS NOT NULL`
|
||||
args = []any{t.NamespaceID, id}
|
||||
}
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
out, err := scanSubscription(tx.QueryRowContext(ctx, q, t.NamespaceID, id, t.CredentialID))
|
||||
out, err := scanSubscription(tx.QueryRowContext(ctx, q, args...))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
|
||||
@@ -71,8 +71,11 @@ func (s *RetentionPolicyStore) GetRetentionPolicy(ctx context.Context, namespace
|
||||
}
|
||||
|
||||
// retentionActor 在策略事务内验证控制台会话。普通 SDK 凭据不能修改保留策略。
|
||||
// 管理员必须具备当前命名空间全部队列的管理范围;不添加角色体系或跨命名空间权限。
|
||||
// 普通会话要求全部队列管理范围;部署指定管理员的显式范围在同一事务重新核验。
|
||||
func retentionActor(ctx context.Context, tx *sql.Tx, token auth.Token) (core.ID, error) {
|
||||
if token.Console != nil {
|
||||
return consoleScopeActor(ctx, tx, token)
|
||||
}
|
||||
p, err := authenticate(ctx, tx, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -45,6 +45,10 @@ func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action st
|
||||
})
|
||||
}
|
||||
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
|
||||
if token.Console != nil {
|
||||
_, err := consoleScopeActor(ctx, tx, token)
|
||||
return consoleScopePermissions(), err
|
||||
}
|
||||
var digest core.Digest
|
||||
var raw, previous []byte
|
||||
var currentValid, previousValid bool
|
||||
@@ -178,7 +182,7 @@ func appendAuditWithEvidence(ctx context.Context, tx *sql.Tx, token auth.Token,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, evidence, string(old), string(current))
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,NULLIF(?,''),?,?)", token.NamespaceID, id, operation, token.ActorID(), action, kind, resource, reason, evidence, string(old), string(current))
|
||||
return id, err
|
||||
}
|
||||
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||
|
||||
Reference in New Issue
Block a user