refactor(scum): declare protected run requests
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -251,6 +252,30 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue
|
||||
return values
|
||||
}
|
||||
|
||||
func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error {
|
||||
if declaration == nil {
|
||||
return nil
|
||||
}
|
||||
if len(payload) != 1 {
|
||||
return validationError("protected bridge request must contain only its declared text field")
|
||||
}
|
||||
value, exists := payload[declaration.TextField]
|
||||
if !exists {
|
||||
return validationError("protected bridge request text field is required")
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes {
|
||||
return validationError("protected bridge request text is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string {
|
||||
text, _ := payload[declaration.TextField].(string)
|
||||
digest := sha256.Sum256([]byte(text))
|
||||
return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8])
|
||||
}
|
||||
|
||||
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
@@ -274,6 +299,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
||||
}
|
||||
if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
|
||||
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||
if err == nil {
|
||||
@@ -312,7 +340,11 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
|
||||
summary := "queued declared game client bridge command"
|
||||
if declaration.ProtectedRequest != nil {
|
||||
summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload)
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
@@ -426,6 +458,8 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid
|
||||
command.State = domain.GameClientBridgeCommandSucceeded
|
||||
case domain.GameClientBridgeResultFailed:
|
||||
command.State = domain.GameClientBridgeCommandFailed
|
||||
case domain.GameClientBridgeResultUnknown:
|
||||
command.State = domain.GameClientBridgeCommandUnknown
|
||||
case domain.GameClientBridgeResultCancelled:
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
}
|
||||
@@ -684,7 +718,7 @@ func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, compon
|
||||
|
||||
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
||||
switch state {
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -82,6 +83,38 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "database", Kind: "sqlite", TargetKey: "database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}}
|
||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 60, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "database", TargetKey: "database", TextField: "requestText", MaxTextBytes: 1024}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := "UPDATE players SET rank = 2 WHERE id = 7"
|
||||
request := domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-1", ExpiresAt: clock.Add(time.Minute)}
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue protected request: %v", err)
|
||||
}
|
||||
if command.ApprovalState != domain.GameClientBridgeApprovalPending {
|
||||
t.Fatalf("protected request bypassed approval: %#v", command)
|
||||
}
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text, "unexpected": true}, IdempotencyKey: "protected-extra", ExpiresAt: clock.Add(time.Minute)}); err == nil {
|
||||
t.Fatal("protected request accepted undeclared payload field")
|
||||
}
|
||||
events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: command.ID})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("protected request audit: events=%#v err=%v", events, err)
|
||||
}
|
||||
if strings.Contains(events[0].Summary, text) || !strings.Contains(events[0].Summary, "text=redacted") {
|
||||
t.Fatalf("audit leaked protected request: %#v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "scope-key")
|
||||
|
||||
@@ -19,7 +19,7 @@ func scumDeploymentTestPlugin() domain.GamePlugin {
|
||||
Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini",
|
||||
Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}},
|
||||
ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}},
|
||||
VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "version", Kind: "version.matches", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}},
|
||||
VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user