Ship SCUM user SQL management page
This commit is contained in:
@@ -1199,28 +1199,30 @@ type RunCapacity struct {
|
||||
}
|
||||
|
||||
const (
|
||||
JobCapabilityConfigWrite = "config.write"
|
||||
JobCapabilityFilesList = "files.list"
|
||||
JobCapabilityFilesRead = "files.read"
|
||||
JobCapabilityFilesWrite = "files.write"
|
||||
JobCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||
JobCapabilityRemoteFTPWrite = "remote.ftp.write"
|
||||
JobCapabilityRemoteRsyncRead = "remote.rsync.read"
|
||||
JobCapabilityRemoteRsyncWrite = "remote.rsync.write"
|
||||
JobCapabilityRemoteRunFilesRead = "remote.run.files.read"
|
||||
JobCapabilityRemoteRunFilesWrite = "remote.run.files.write"
|
||||
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||
JobCapabilityDistributionBuild = "distribution.build"
|
||||
JobCapabilityDependenciesCheck = "dependencies.check"
|
||||
JobCapabilityDependenciesInstall = "dependencies.install"
|
||||
JobCapabilityLogsBackfill = "logs.backfill"
|
||||
JobCapabilityConfigWrite = "config.write"
|
||||
JobCapabilityFilesList = "files.list"
|
||||
JobCapabilityFilesRead = "files.read"
|
||||
JobCapabilityFilesWrite = "files.write"
|
||||
JobCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||
JobCapabilityRemoteFTPWrite = "remote.ftp.write"
|
||||
JobCapabilityRemoteRsyncRead = "remote.rsync.read"
|
||||
JobCapabilityRemoteRsyncWrite = "remote.rsync.write"
|
||||
JobCapabilityRemoteRunFilesRead = "remote.run.files.read"
|
||||
JobCapabilityRemoteRunFilesWrite = "remote.run.files.write"
|
||||
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||
JobCapabilityRemoteRunDBMySQLExecute = "remote.run.db.mysql.execute"
|
||||
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
JobCapabilityRemoteRunDBSQLiteExecute = "remote.run.db.sqlite.execute"
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||
JobCapabilityDistributionBuild = "distribution.build"
|
||||
JobCapabilityDependenciesCheck = "dependencies.check"
|
||||
JobCapabilityDependenciesInstall = "dependencies.install"
|
||||
JobCapabilityLogsBackfill = "logs.backfill"
|
||||
// JobCapabilityDeploymentPlan gates Run implementations that understand
|
||||
// protected deployment definitions, absolute paths, and custom commands.
|
||||
JobCapabilityDeploymentPlan = "deployment.plan.v1"
|
||||
|
||||
@@ -119,7 +119,7 @@ func isRemoteAdapterCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
||||
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand:
|
||||
return true
|
||||
default:
|
||||
@@ -156,7 +156,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind
|
||||
return domain.RemoteAdapterRunFile
|
||||
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
|
||||
return domain.RemoteAdapterRunProcess
|
||||
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery:
|
||||
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute:
|
||||
return domain.RemoteAdapterDatabase
|
||||
case domain.JobCapabilityRemoteRunRCONCommand:
|
||||
return domain.RemoteAdapterRCON
|
||||
|
||||
@@ -1624,36 +1624,36 @@ func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) {
|
||||
func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) {
|
||||
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
|
||||
|
||||
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-sql-rejected-1",
|
||||
RequestID: "sqlite-execute-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "query-template-sql-rejected-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.sqlText": "SELECT * FROM users",
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "sqlite-execute-1",
|
||||
"input.mode": "execute",
|
||||
"input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute arbitrary SQL bridge input: %v", err)
|
||||
t.Fatalf("execute sqlite SQL bridge input: %v", err)
|
||||
}
|
||||
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") {
|
||||
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
|
||||
if result.Status != "queued" || result.Result["jobId"] == "" {
|
||||
t.Fatalf("expected queued sqlite execute job, got %+v", result)
|
||||
}
|
||||
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if listErr != nil {
|
||||
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
|
||||
job, getErr := svc.store.Jobs().Get(result.Result["jobId"])
|
||||
if getErr != nil {
|
||||
t.Fatalf("get sqlite execute job: %v", getErr)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
|
||||
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" {
|
||||
t.Fatalf("expected sqlite execute inputs, got %#v", job)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1898,7 +1898,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability)
|
||||
executeCapability := domain.JobCapabilityRemoteRunDBSQLiteExecute
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability, executeCapability)
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
|
||||
plugin.Permissions.RemoteAccess = true
|
||||
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
@@ -1911,14 +1912,14 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
})
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
|
||||
Methods: []string{"run"},
|
||||
RunCapabilities: []string{capability},
|
||||
RunCapabilities: []string{capability, executeCapability},
|
||||
DatabaseEngines: []string{"sqlite"},
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
||||
Key: "scum-db-read",
|
||||
Kind: "sqlite",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
Capabilities: []string{capability},
|
||||
Capabilities: []string{capability, executeCapability},
|
||||
})
|
||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
|
||||
@@ -1944,7 +1945,7 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatalf("update sqlite query plugin fixture: %v", err)
|
||||
}
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability)
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, capability, executeCapability)
|
||||
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||
t.Fatalf("update sqlite query endpoint fixture: %v", err)
|
||||
}
|
||||
|
||||
@@ -109,10 +109,14 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
|
||||
}
|
||||
var violations []string
|
||||
for key, value := range inputs {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) {
|
||||
violations = append(violations, field+" key is invalid or unsafe")
|
||||
}
|
||||
if len([]rune(value)) > 2048 {
|
||||
limit := 2048
|
||||
if remoteAdapterSQLInputKey(key) {
|
||||
limit = 16 * 1024
|
||||
}
|
||||
if len([]rune(value)) > limit {
|
||||
violations = append(violations, field+"."+key+" is too long")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
@@ -121,3 +125,20 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func unsafeRemoteAdapterInputKey(key string) bool {
|
||||
if remoteAdapterSQLInputKey(key) {
|
||||
return false
|
||||
}
|
||||
return unsafeGameClientBridgePayloadKey(key)
|
||||
}
|
||||
|
||||
func remoteAdapterSQLInputKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key))
|
||||
switch normalized {
|
||||
case "sql", "sqltext", "sqlstatement", "sqlquery", "rawsql", "rawquery", "statement":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T
|
||||
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil {
|
||||
t.Fatal("expected unsafe remote target rejection")
|
||||
}
|
||||
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil {
|
||||
t.Fatalf("expected SQL text input to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 { return &value }
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
maxPluginDescriptionLength = 240
|
||||
maxPluginPageTitleLength = 40
|
||||
maxPluginBridgePayloadKeys = 16
|
||||
maxPluginBridgePayloadSize = 4096
|
||||
maxPluginBridgePayloadSize = 16 * 1024
|
||||
maxProgressMessageLength = 256
|
||||
maxServerConfigContentSize = 64 * 1024
|
||||
maxJobExecutionContentSize = 64 * 1024
|
||||
@@ -971,7 +971,11 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
|
||||
violations = append(violations, "payload key is invalid")
|
||||
}
|
||||
if len([]rune(value)) > 1024 {
|
||||
valueLimit := 1024
|
||||
if remoteAdapterSQLInputKey(strings.TrimPrefix(key, "input.")) {
|
||||
valueLimit = 16 * 1024
|
||||
}
|
||||
if len([]rune(value)) > valueLimit {
|
||||
violations = append(violations, "payload value is too long")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(key) {
|
||||
@@ -2053,12 +2057,14 @@ func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, de
|
||||
violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
|
||||
}
|
||||
for _, engine := range remote.DatabaseEngines {
|
||||
required := domain.JobCapabilityRemoteRunDBMySQLQuery
|
||||
queryCapability := domain.JobCapabilityRemoteRunDBMySQLQuery
|
||||
executeCapability := domain.JobCapabilityRemoteRunDBMySQLExecute
|
||||
if engine == "sqlite" {
|
||||
required = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
queryCapability = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
executeCapability = domain.JobCapabilityRemoteRunDBSQLiteExecute
|
||||
}
|
||||
if !containsString(remote.RunCapabilities, required) {
|
||||
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required))
|
||||
if !containsString(remote.RunCapabilities, queryCapability) && !containsString(remote.RunCapabilities, executeCapability) {
|
||||
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s or %s", field, queryCapability, executeCapability))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
@@ -2352,7 +2358,7 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
||||
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
@@ -2386,7 +2392,9 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
|
||||
domain.JobCapabilityRemoteRsyncWrite,
|
||||
domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||
domain.JobCapabilityRemoteRunDBMySQLExecute,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram:
|
||||
return true
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user