Remove pre-1.0 audit and protected request scaffolding
This commit is contained in:
@@ -9,10 +9,6 @@ import (
|
||||
|
||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
bridge := domain.GameClientBridgeManifest{
|
||||
Commands: []domain.GameClientBridgeCommandDeclaration{{
|
||||
Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone,
|
||||
PayloadSchemaRef: "schemas/bridge/announcement.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096,
|
||||
}},
|
||||
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
|
||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
||||
@@ -23,7 +19,6 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
|
||||
Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"},
|
||||
},
|
||||
}},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
@@ -44,11 +39,11 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
||||
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
|
||||
}},
|
||||
{name: "missing profile", expected: "must reference a declared game-client bridge profile", mutate: func(_ *domain.GameClientBridgeManifest, value *domain.GamePluginRuntimeProfiles) {
|
||||
value.ClientManagers = nil
|
||||
{name: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.TimestampField = "missingAt"
|
||||
}},
|
||||
{name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.Announcement.CommandType = "missing.command"
|
||||
{name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func validBridgeQueueRequest() domain.GameClientBridgeQueueRequest {
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Minute)}
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Minute)}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeRequests(t *testing.T) {
|
||||
|
||||
@@ -98,14 +98,6 @@ func ValidateSourceRCONExecutionInputRequest(request domain.SourceRCONExecutionI
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
func ValidateProtectedRequestExecutionInputRequest(request domain.ProtectedRequestExecutionInputRequest) error {
|
||||
violations := appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
||||
if request.FencingToken == 0 {
|
||||
violations = append(violations, "fencingToken is required")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
@@ -1,93 +1,11 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func ValidateCapacityAdmissionRequest(request domain.CapacityAdmissionRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "capability", request.Capability)
|
||||
if request.ServerInstanceID != "" && !safeIdentifier(request.ServerInstanceID) {
|
||||
violations = append(violations, "serverInstanceId is invalid")
|
||||
}
|
||||
if request.RunEndpointID != "" && !safeIdentifier(request.RunEndpointID) {
|
||||
violations = append(violations, "runEndpointId is invalid")
|
||||
}
|
||||
if request.TargetKey != "" && !validLogicalFileKey(request.TargetKey) {
|
||||
violations = append(violations, "targetKey is invalid")
|
||||
}
|
||||
if unsafeProductionText(request.Capability) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "capacity request contains unsafe content")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateCapacityAdmissionDecision(decision domain.CapacityAdmissionDecision) error {
|
||||
var violations []string
|
||||
if !validCapacityAdmissionState(decision.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
violations = appendRequired(violations, "reason", decision.Reason)
|
||||
if len(decision.Reason) > maxProductionMessageLength || unsafeProductionText(decision.Reason) {
|
||||
violations = append(violations, "reason is unsafe")
|
||||
}
|
||||
for i, code := range decision.PressureCodes {
|
||||
if !validCapacityPressureCode(code) {
|
||||
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
||||
}
|
||||
}
|
||||
if decision.RunningJobs < 0 || decision.QueuedJobs < 0 || decision.MaxJobs < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAlertRecord(alert domain.AlertRecord) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", alert.ID)
|
||||
violations = appendRequired(violations, "sourceKind", alert.SourceKind)
|
||||
violations = appendRequired(violations, "sourceId", alert.SourceID)
|
||||
violations = appendRequired(violations, "ruleKey", alert.RuleKey)
|
||||
violations = appendRequired(violations, "title", alert.Title)
|
||||
violations = appendRequired(violations, "message", alert.Message)
|
||||
if !validAlertSeverity(alert.Severity) {
|
||||
violations = append(violations, "severity is invalid")
|
||||
}
|
||||
if !validAlertState(alert.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
if alert.OccurrenceCount <= 0 {
|
||||
violations = append(violations, "occurrenceCount must be positive")
|
||||
}
|
||||
for _, value := range []fieldString{{field: "title", value: alert.Title}, {field: "message", value: alert.Message}, {field: "resolutionNote", value: alert.ResolutionNote}} {
|
||||
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
||||
violations = append(violations, value.field+" is unsafe")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAlertAcknowledgeRequest(request domain.AlertAcknowledgeRequest) error {
|
||||
return validateAlertNoteRequest(request.AlertID, request.Note)
|
||||
}
|
||||
|
||||
func ValidateAlertResolveRequest(request domain.AlertResolveRequest) error {
|
||||
return validateAlertNoteRequest(request.AlertID, request.Note)
|
||||
}
|
||||
|
||||
func ValidateAlertRetryRequest(request domain.AlertRetryRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "alertId", request.AlertID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if unsafeProductionText(request.AlertID) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "alert retry request is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidatePluginLifecycleInstallation(installation domain.PluginLifecycleInstallation) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", installation.ID)
|
||||
@@ -165,51 +83,6 @@ func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequ
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateAlertNoteRequest(alertID string, note string) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "alertId", alertID)
|
||||
if unsafeProductionText(alertID) || len(note) > maxProductionMessageLength || unsafeProductionText(note) {
|
||||
violations = append(violations, "alert note request is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validCapacityAdmissionState(state domain.CapacityAdmissionState) bool {
|
||||
switch state {
|
||||
case domain.CapacityAdmissionAccepted, domain.CapacityAdmissionDeferred, domain.CapacityAdmissionDenied:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validCapacityPressureCode(code domain.CapacityPressureCode) bool {
|
||||
switch code {
|
||||
case domain.CapacityPressureEndpointOffline, domain.CapacityPressureEndpointStale, domain.CapacityPressureCapabilityGap, domain.CapacityPressureJobLimit, domain.CapacityPressureQueueLimit, domain.CapacityPressureBacklog:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAlertSeverity(severity domain.AlertSeverity) bool {
|
||||
switch severity {
|
||||
case domain.AlertSeverityInfo, domain.AlertSeverityWarning, domain.AlertSeverityCritical:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAlertState(state domain.AlertState) bool {
|
||||
switch state {
|
||||
case domain.AlertStateActive, domain.AlertStateAcknowledged, domain.AlertStateResolved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
||||
switch state {
|
||||
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
||||
|
||||
+16
-133
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxAuditSummaryLength = 512
|
||||
maxSummaryLength = 512
|
||||
maxContactNoteLength = 160
|
||||
maxMarketplaceKeywordSize = 80
|
||||
maxMarketplaceListSize = 500
|
||||
@@ -500,7 +500,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
commandTypes := map[string]struct{}{}
|
||||
for index, command := range bridge.Commands {
|
||||
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) {
|
||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
|
||||
violations = append(violations, prefix+".type is invalid or unsafe")
|
||||
}
|
||||
if _, exists := commandTypes[command.Type]; exists {
|
||||
@@ -525,7 +525,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
|
||||
}
|
||||
snapshotTypes := map[string]struct{}{}
|
||||
for index, snapshot := range bridge.Snapshots {
|
||||
@@ -625,7 +624,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
logProjectionKeys[projection.Key] = struct{}{}
|
||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...)
|
||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
|
||||
}
|
||||
dataPackKeys := map[string]struct{}{}
|
||||
for index, dataPack := range bridge.DataPacks {
|
||||
@@ -640,9 +639,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
|
||||
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
||||
}
|
||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
||||
refs = append(refs, dataPack.DataRefs...)
|
||||
for _, ref := range refs {
|
||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
||||
refs = append(refs, dataPack.DataRefs...)
|
||||
for _, ref := range refs {
|
||||
if !safeRelativeJSONRef(ref) {
|
||||
violations = append(violations, prefix+" asset reference is invalid")
|
||||
}
|
||||
@@ -664,8 +663,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if !containsString(permissions, template.Permission) {
|
||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||
}
|
||||
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval")
|
||||
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
violations = append(violations, prefix+".approvalLevel is invalid")
|
||||
}
|
||||
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
@@ -689,8 +688,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
}
|
||||
switch template.Kind {
|
||||
case domain.GameClientBridgeOperationKindRCON:
|
||||
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) {
|
||||
violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability")
|
||||
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
||||
violations = append(violations, prefix+" transport must be rcon with remote.run.rcon.command capability")
|
||||
}
|
||||
if template.MaxRowsAffected != 0 {
|
||||
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
|
||||
@@ -822,7 +821,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string {
|
||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
|
||||
var violations []string
|
||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
||||
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
||||
@@ -887,43 +886,6 @@ func validateGameClientBridgeLogProjection(prefix string, projection domain.Game
|
||||
if presence.ActivityTarget != nil {
|
||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
||||
}
|
||||
|
||||
announcement := presence.Announcement
|
||||
if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) {
|
||||
violations = append(violations, prefix+".presence.announcement.profileKey is invalid")
|
||||
} else {
|
||||
profileFound := false
|
||||
for _, profile := range clientManagers {
|
||||
if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") {
|
||||
profileFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profileFound {
|
||||
violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile")
|
||||
}
|
||||
}
|
||||
var command *domain.GameClientBridgeCommandDeclaration
|
||||
for index := range commands {
|
||||
if commands[index].Type == announcement.CommandType {
|
||||
command = &commands[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if command == nil {
|
||||
violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command")
|
||||
}
|
||||
if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) {
|
||||
violations = append(violations, prefix+".presence.announcement.textField is invalid")
|
||||
} else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField {
|
||||
violations = append(violations, prefix+".presence.announcement.textField must match the command protected request")
|
||||
}
|
||||
if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 {
|
||||
violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large")
|
||||
}
|
||||
if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 {
|
||||
violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
@@ -1032,50 +994,6 @@ func unsafeGameClientBridgeCommandType(value string) bool {
|
||||
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
||||
}
|
||||
|
||||
func validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
if !oneOf(request.Kind, "sql", "rcon", "program") {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
}
|
||||
for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} {
|
||||
if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) {
|
||||
violations = append(violations, prefix+"."+field+" is invalid")
|
||||
}
|
||||
}
|
||||
if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString {
|
||||
violations = append(violations, prefix+".maxTextBytes is invalid")
|
||||
}
|
||||
transport, exists := transports[request.TransportKey]
|
||||
if !exists {
|
||||
return append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
||||
}
|
||||
if transport.TargetKey != request.TargetKey {
|
||||
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
||||
}
|
||||
wantKind, wantCapability := "", ""
|
||||
switch request.Kind {
|
||||
case "sql":
|
||||
wantCapability = domain.JobCapabilityRemoteRunProtectedSQL
|
||||
case "rcon":
|
||||
wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON
|
||||
case "program":
|
||||
wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram
|
||||
}
|
||||
if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" {
|
||||
violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests")
|
||||
}
|
||||
if wantKind != "" && transport.Kind != wantKind {
|
||||
violations = append(violations, prefix+".transportKey does not match protected request kind")
|
||||
}
|
||||
if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) {
|
||||
violations = append(violations, prefix+".transportKey is missing required protected transport capability")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool {
|
||||
return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0
|
||||
}
|
||||
@@ -1744,12 +1662,7 @@ func ValidateJob(job domain.Job) error {
|
||||
if job.ExecutionInput.SourceRCON != nil {
|
||||
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
||||
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
|
||||
isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON
|
||||
wantAdapterKind := "rcon"
|
||||
if isProtectedRCON {
|
||||
wantAdapterKind = "protected-rcon"
|
||||
}
|
||||
if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
|
||||
if !isSourceCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" {
|
||||
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
||||
}
|
||||
if job.RetryPolicy.MaxAttempts != 1 {
|
||||
@@ -1766,8 +1679,8 @@ func ValidateJob(job domain.Job) error {
|
||||
if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize {
|
||||
violations = append(violations, "executionResult.content is too large")
|
||||
}
|
||||
if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength {
|
||||
violations = append(violations, "executionResult.auditSummary is too long")
|
||||
if len(job.ExecutionResult.Summary) > maxSummaryLength {
|
||||
violations = append(violations, "executionResult.summary is too long")
|
||||
}
|
||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||
if job.ServerInstanceID == "" {
|
||||
@@ -1875,26 +1788,6 @@ func ValidateLogStream(stream domain.LogStream) error {
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAuditEvent(event domain.AuditEvent) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", event.ID)
|
||||
violations = appendRequired(violations, "actorId", event.ActorID)
|
||||
violations = appendRequired(violations, "action", event.Action)
|
||||
violations = appendRequired(violations, "resourceKind", event.ResourceKind)
|
||||
violations = appendRequired(violations, "resourceId", event.ResourceID)
|
||||
violations = appendRequired(violations, "summary", event.Summary)
|
||||
if !validAuditResult(event.Result) {
|
||||
violations = append(violations, "result is invalid")
|
||||
}
|
||||
if len(event.Summary) > maxAuditSummaryLength {
|
||||
violations = append(violations, "summary is too long")
|
||||
}
|
||||
if looksLikeRawSecret(event.Summary) {
|
||||
violations = append(violations, "summary must be redacted")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func MissingCapabilities(actual []string, required []string) []string {
|
||||
actualSet := make(map[string]struct{}, len(actual))
|
||||
for _, capability := range actual {
|
||||
@@ -2446,7 +2339,7 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
||||
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
||||
@@ -2479,8 +2372,7 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
|
||||
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -2781,12 +2673,3 @@ func validLogStorageBackend(backend domain.LogStorageBackend) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAuditResult(result domain.AuditResult) bool {
|
||||
switch result {
|
||||
case domain.AuditResultSuccess, domain.AuditResultDenied, domain.AuditResultFailed, domain.AuditResultQueued:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,24 +130,24 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi
|
||||
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL)
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL)
|
||||
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
|
||||
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
|
||||
{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}},
|
||||
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}},
|
||||
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}},
|
||||
{Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}},
|
||||
}
|
||||
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
|
||||
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
|
||||
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
|
||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
|
||||
OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{
|
||||
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
|
||||
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048},
|
||||
{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}},
|
||||
},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
|
||||
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"diagnostic.ping"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
|
||||
}
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
||||
@@ -252,8 +252,8 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
|
||||
{name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
|
||||
}},
|
||||
{name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
||||
{name: "mutation missing approval metadata", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[1].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
|
||||
}},
|
||||
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json"
|
||||
@@ -381,7 +381,7 @@ func TestValidateJobBoundsProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArtifactLogAndAudit(t *testing.T) {
|
||||
func TestValidateArtifactLogAndRuntimeMetadata(t *testing.T) {
|
||||
artifact := domain.Artifact{
|
||||
ID: "artifact-1",
|
||||
OwnerKind: domain.ArtifactOwnerKindJob,
|
||||
@@ -406,23 +406,6 @@ func TestValidateArtifactLogAndAudit(t *testing.T) {
|
||||
t.Fatalf("expected log stream to validate, got %v", err)
|
||||
}
|
||||
|
||||
audit := domain.AuditEvent{
|
||||
ID: "audit-1",
|
||||
ActorID: "user-1",
|
||||
Action: "server.create",
|
||||
ResourceKind: "server-instance",
|
||||
ResourceID: "server-1",
|
||||
Result: domain.AuditResultSuccess,
|
||||
Summary: "created server instance",
|
||||
}
|
||||
if err := ValidateAuditEvent(audit); err != nil {
|
||||
t.Fatalf("expected audit event to validate, got %v", err)
|
||||
}
|
||||
|
||||
audit.Summary = "bearer raw-secret"
|
||||
if err := ValidateAuditEvent(audit); err == nil || !strings.Contains(err.Error(), "summary must be redacted") {
|
||||
t.Fatalf("expected audit redaction error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validAIProvider() domain.AIProvider {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
- API handlers must use named DTOs from `platform/dto`.
|
||||
- Platform services must not accept raw plugin-provided host paths.
|
||||
- AI provider secrets must be stored by reference and redacted from logs, audit, and plugin bridge responses.
|
||||
- AI provider secrets must be stored by reference and redacted from logs and plugin bridge responses.
|
||||
- Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references.
|
||||
- Server instance creation must validate plugin installation state and run endpoint capability compatibility.
|
||||
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
||||
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
||||
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
||||
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||
# Client Manager lifecycle validation
|
||||
|
||||
@@ -87,8 +87,8 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *test
|
||||
unsafeEventTypes := []string{
|
||||
"ops.shell.execute",
|
||||
"ops.execute",
|
||||
"audit.sql.query",
|
||||
"audit.raw-host-path",
|
||||
"ops.sql.query",
|
||||
"ops.raw-host-path",
|
||||
"run.socket.open",
|
||||
"auth.credential.exposed",
|
||||
"auth.api-key.exposed",
|
||||
|
||||
Reference in New Issue
Block a user