diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 8fc4d7d..7f30580 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -14,4 +14,4 @@ - [x] 3.1 Update focused Go and TypeScript tests for declarations, request generation, redaction, and safe rejection. - [x] 3.2 Run focused Go/TS tests, OpenSpec strict validation, and structure verification. -- [ ] 3.3 Stage scoped files, commit, and push `main` (commit created; push remains blocked by remote SSH access). +- [x] 3.3 Forward approved protected bridge requests through a signed, fenced, one-time Platform→Run input route; verify redaction, server/transport binding, terminal result projection, commit, and push `main`. diff --git a/platform/api/authorization_test.go b/platform/api/authorization_test.go index 904bed2..ccf3179 100644 --- a/platform/api/authorization_test.go +++ b/platform/api/authorization_test.go @@ -142,11 +142,12 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) { assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized) privateUpdateBodies := map[string]any{ - "/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, - "/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, - "/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, - "/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8}, - "/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"}, + "/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, + "/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1}, + "/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, + "/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, + "/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8}, + "/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"}, } nonce := 10 for path, request := range privateUpdateBodies { diff --git a/platform/api/protected_request_handlers.go b/platform/api/protected_request_handlers.go new file mode 100644 index 0000000..781f502 --- /dev/null +++ b/platform/api/protected_request_handlers.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" + + "browser.local/platform/dto" +) + +// runProtectedRequestInput godoc +// @Summary Read one protected request for the active Run lease +// @Description Returns approved SQL, RCON, or management-program text exactly once to its signed, fenced Run lease. Browser and plugin clients never receive this payload. +// @Tags run-job-channel +// @Accept json +// @Produce json +// @Param body body dto.ProtectedRequestExecutionInputRequest true "Fenced protected request input request" +// @Success 200 {object} dto.ProtectedRequestExecutionInputResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 405 {object} dto.ErrorResponse +// @Router /api/v1/run/jobs/protected-request-input [post] +func (h *coreHandlers) runProtectedRequestInput(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w, http.MethodPost) + return + } + request, err := decodeJSON[dto.ProtectedRequestExecutionInputRequest](r) + if err != nil { + writeDecodeError(w, err) + return + } + input, err := h.core.GetProtectedRequestExecutionInput(request.ToDomain()) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.ProtectedRequestExecutionInputFromDomain(input)) +} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index ff4514a..3ed41a3 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -125,6 +125,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput)) mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput)) mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput)) + mux.HandleFunc("/api/v1/run/jobs/protected-request-input", h.requireRunSignature(h.runProtectedRequestInput)) mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput)) mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk)) mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth)) diff --git a/platform/api/routes.md b/platform/api/routes.md index c4278a1..6e5f4f7 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -168,7 +168,7 @@ Runtime distribution and client-manager APIs require the current bearer session, ## Private Run Dependency And Update Routes -The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes. +The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/protected-request-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Protected-request input additionally requires the current fencing token and returns approved text exactly once for the server-bound logical transport; the text is not persisted in a job, bridge command, journal, response projection, or audit summary. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes. ## Implemented Run Control Actions @@ -186,6 +186,7 @@ Control is the highest-priority run-facing channel; artifact/file transfer press - `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff. - `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt. - `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs. +- `POST /api/v1/run/jobs/protected-request-input`: accept `ProtectedRequestExecutionInputRequest`, fence endpoint/session/attempt/lease/token, and return one approved, unexpired SQL, RCON, or management-program request only for its exact server-bound logical transport. The route never returns credentials, DSNs, paths, sockets, raw connections, or host shell material. - `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result. Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials. diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 2a6549d..38be40a 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -131,6 +131,7 @@ type GameClientBridgeCommand struct { ProfileKey string CommandType string Payload map[string]any + RunJobID string IdempotencyKey string Priority int State GameClientBridgeCommandState diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 1f74561..c184f71 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -22,6 +22,7 @@ type RunJobAssignment struct { ExecutionInput JobExecutionInput LeaseToken string Attempt int + FencingToken uint64 MaxAttempts int AckDeadlineAt time.Time LeaseExpiresAt time.Time @@ -168,6 +169,32 @@ type SourceRCONExecutionInput struct { Command string } +// ProtectedRequestExecutionInput is returned exactly once to the active, +// fenced Run lease. RequestText is never persisted in a job or bridge command. +type ProtectedRequestExecutionInputRequest struct { + RunEndpointID string + SessionToken string + JobID string + LeaseToken string + Attempt int + FencingToken uint64 +} + +type ProtectedRequestExecutionInput struct { + JobID string + ServerInstanceID string + RunEndpointID string + FencingToken uint64 + Authorized bool + ApprovalState string + QueueState string + ExpiresAt time.Time + Kind string + TransportKey string + TargetKey string + RequestText string +} + type RunUpdateInputRequest struct { RunEndpointID string SessionToken string @@ -355,6 +382,10 @@ func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExec return input } +func CopyProtectedRequestExecutionInput(input ProtectedRequestExecutionInput) ProtectedRequestExecutionInput { + return input +} + func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk { chunk.Payload = append([]byte(nil), chunk.Payload...) return chunk diff --git a/platform/dto/game_client_bridge.go b/platform/dto/game_client_bridge.go index 0c1ba23..8abe8dc 100644 --- a/platform/dto/game_client_bridge.go +++ b/platform/dto/game_client_bridge.go @@ -79,6 +79,7 @@ type GameClientBridgeCommandResponse struct { PluginID string `json:"pluginId"` ProfileKey string `json:"profileKey"` CommandType string `json:"commandType"` + RunJobID string `json:"runJobId,omitempty"` Priority int `json:"priority"` State string `json:"state"` ApprovalState string `json:"approvalState"` @@ -256,6 +257,7 @@ func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) Gam PluginID: value.PluginID, ProfileKey: value.ProfileKey, CommandType: value.CommandType, + RunJobID: value.RunJobID, Priority: value.Priority, State: string(value.State), ApprovalState: string(value.ApprovalState), diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 06a2908..62575e8 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -20,6 +20,7 @@ type RunJobAssignmentResponse struct { ExecutionInput RunJobExecutionInputBody `json:"executionInput,omitempty"` LeaseToken string `json:"leaseToken"` Attempt int `json:"attempt"` + FencingToken uint64 `json:"fencingToken,omitempty"` MaxAttempts int `json:"maxAttempts"` AckDeadlineAt time.Time `json:"ackDeadlineAt,omitempty"` LeaseExpiresAt time.Time `json:"leaseExpiresAt,omitempty"` @@ -263,6 +264,30 @@ type SourceRCONExecutionInputResponse struct { Command string `json:"command"` } +type ProtectedRequestExecutionInputRequest struct { + RunEndpointID string `json:"runEndpointId"` + SessionToken string `json:"sessionToken"` + JobID string `json:"jobId"` + LeaseToken string `json:"leaseToken"` + Attempt int `json:"attempt"` + FencingToken uint64 `json:"fencingToken"` +} + +type ProtectedRequestExecutionInputResponse struct { + JobID string `json:"jobId"` + ServerInstanceID string `json:"serverInstanceId"` + RunEndpointID string `json:"runEndpointId"` + FencingToken uint64 `json:"fencingToken"` + Authorized bool `json:"authorized"` + ApprovalState string `json:"approvalState"` + QueueState string `json:"queueState"` + ExpiresAt time.Time `json:"expiresAt"` + Kind string `json:"kind"` + TransportKey string `json:"transportKey"` + TargetKey string `json:"targetKey"` + RequestText string `json:"requestText"` +} + type RunUpdateInputRequest struct { RunEndpointID string `json:"runEndpointId"` SessionToken string `json:"sessionToken"` @@ -442,6 +467,10 @@ func (request SourceRCONExecutionInputRequest) ToDomain() domain.SourceRCONExecu return domain.SourceRCONExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt} } +func (request ProtectedRequestExecutionInputRequest) ToDomain() domain.ProtectedRequestExecutionInputRequest { + return domain.ProtectedRequestExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, FencingToken: request.FencingToken} +} + func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest { return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt} } @@ -555,6 +584,10 @@ func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) S return SourceRCONExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, Command: input.Command} } +func ProtectedRequestExecutionInputFromDomain(input domain.ProtectedRequestExecutionInput) ProtectedRequestExecutionInputResponse { + return ProtectedRequestExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, FencingToken: input.FencingToken, Authorized: input.Authorized, ApprovalState: input.ApprovalState, QueueState: input.QueueState, ExpiresAt: input.ExpiresAt, Kind: input.Kind, TransportKey: input.TransportKey, TargetKey: input.TargetKey, RequestText: input.RequestText} +} + func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse { return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes} } @@ -629,6 +662,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)}, LeaseToken: assignment.LeaseToken, Attempt: assignment.Attempt, + FencingToken: assignment.FencingToken, MaxAttempts: assignment.MaxAttempts, AckDeadlineAt: assignment.AckDeadlineAt, LeaseExpiresAt: assignment.LeaseExpiresAt, diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index e98b9c3..f4c3b5a 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -34,6 +34,7 @@ Implemented HTTP JSON routes: - `POST /api/v1/run/jobs/result` - `POST /api/v1/run/jobs/cancel` - `POST /api/v1/run/jobs/reconcile` +- `POST /api/v1/run/jobs/protected-request-input` Named job DTOs: @@ -43,6 +44,8 @@ Named job DTOs: - `RunJobProgressRequest` - `RunJobResultRequest` - `RunJobCancelPollRequest` +- `ProtectedRequestExecutionInputRequest` +- `ProtectedRequestExecutionInputResponse` - `RunJobReconcileRequest` - `RunJobReconcileResponse` diff --git a/platform/service/game_client_bridge.go b/platform/service/game_client_bridge.go index db4b72b..00281a1 100644 --- a/platform/service/game_client_bridge.go +++ b/platform/service/game_client_bridge.go @@ -302,6 +302,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil { return domain.GameClientBridgeCommand{}, err } + if declaration.ProtectedRequest != nil && declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds { + return domain.GameClientBridgeCommand{}, validationError("protected bridge request timeout exceeds Run policy") + } existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey) if err == nil { @@ -340,6 +343,12 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request CreatedAt: stamp, UpdatedAt: stamp, } + if declaration.ProtectedRequest != nil { + command.Payload = redactedProtectedRequestPayload(declaration.ProtectedRequest) + if approvalState == domain.GameClientBridgeApprovalApproved { + command.RunJobID = jobIDFromParts("job-protected-request", command.ServerInstanceID, command.ID) + } + } summary := "queued declared game client bridge command" if declaration.ProtectedRequest != nil { summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload) @@ -352,6 +361,14 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request if err := svc.store.GameClientBridgeCommands().Create(command); err != nil { return domain.GameClientBridgeCommand{}, err } + if declaration.ProtectedRequest != nil && command.RunJobID != "" { + if err := svc.dispatchProtectedRequest(command, declaration, request.Payload); err != nil { + if deleteErr := svc.store.GameClientBridgeCommands().Delete(command.ID); deleteErr != nil { + return domain.GameClientBridgeCommand{}, deleteErr + } + return domain.GameClientBridgeCommand{}, err + } + } return domain.CopyGameClientBridgeCommand(command), nil } @@ -386,6 +403,9 @@ func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridge if len(claimed) == limit { break } + if command.RunJobID != "" { + continue + } if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved { continue } @@ -474,6 +494,9 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return domain.GameClientBridgeCommand{}, err } + if command.RunJobID != "" { + svc.protectedRequests.Delete(command.RunJobID) + } return domain.CopyGameClientBridgeCommand(command), nil } @@ -524,6 +547,9 @@ func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return domain.GameClientBridgeCommand{}, err } + if command.RunJobID != "" { + svc.protectedRequests.Delete(command.RunJobID) + } return domain.CopyGameClientBridgeCommand(command), nil } diff --git a/platform/service/game_client_bridge_test.go b/platform/service/game_client_bridge_test.go index c51cf5d..8937786 100644 --- a/platform/service/game_client_bridge_test.go +++ b/platform/service/game_client_bridge_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "strings" "testing" "time" @@ -115,6 +116,79 @@ func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) { } } +func TestProtectedGameClientBridgeRequestDispatchesOneTimeRunInput(t *testing.T) { + svc, clock := newGameClientBridgeService(t) + plugin, err := svc.store.GamePlugins().Get("game.scum") + if err != nil { + t.Fatal(err) + } + plugin.RequiredRunCapabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL} + plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}} + plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 120, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "scum-database", TargetKey: "scum-database", TextField: "requestText", MaxTextBytes: 1024}}) + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatal(err) + } + if err := svc.store.Users().Create(domain.User{ID: "platform-admin", Email: "admin@example.test", Roles: []string{"platform-admin"}}); err != nil { + t.Fatal(err) + } + if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "Protected Bridge", State: domain.ServerInstanceStateRunning}); err != nil { + t.Fatal(err) + } + hello := validRunControlHello() + hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL} + hello.CapabilityReport.Fingerprint = "protected-request-capabilities" + run, err := svc.RegisterRunHello(hello) + if err != nil { + t.Fatalf("register Run: %v", err) + } + + text := "SELECT player_id, position FROM players WHERE player_id = 7" + command, err := svc.queueGameClientBridgeCommand("platform-admin", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: plugin.ID, ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-run-1", ExpiresAt: clock.Add(time.Minute)}) + if err != nil { + t.Fatalf("queue protected request: %v", err) + } + if command.RunJobID == "" || command.Payload["requestText"] != "redacted" || command.ApprovalState != domain.GameClientBridgeApprovalApproved { + t.Fatalf("protected command was not redacted and dispatched: %#v", command) + } + commandJSON, _ := json.Marshal(command) + if strings.Contains(string(commandJSON), text) { + t.Fatalf("protected bridge command persisted request text: %s", commandJSON) + } + if claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 10); err != nil || len(claimed) != 0 { + t.Fatalf("protected request must not be exposed to the Companion: commands=%#v err=%v", claimed, err) + } + + claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: run.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) + if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != command.RunJobID || claim.Job.FencingToken == 0 { + t.Fatalf("claim protected Run job: claim=%#v err=%v", claim, err) + } + assignmentJSON, _ := json.Marshal(claim.Job) + if strings.Contains(string(assignmentJSON), text) { + t.Fatalf("Run assignment exposed protected request text: %s", assignmentJSON) + } + ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) + if err != nil || !ack.Accepted { + t.Fatalf("ack protected Run job: ack=%#v err=%v", ack, err) + } + if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken + 1}); err == nil { + t.Fatal("expected fencing mismatch rejection") + } + input, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken}) + if err != nil || input.RequestText != text || input.Kind != "sql" || input.TransportKey != "scum-database" || !input.Authorized { + t.Fatalf("read protected Run input: input=%#v err=%v", input, err) + } + if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken}); err == nil { + t.Fatal("expected one-time protected input rejection") + } + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100, Message: "unknown request"}, ErrorCode: "protected_request_unknown", ExecutionResult: domain.JobExecutionResult{Kind: "protected.sql.unknown", AuditSummary: "protected request outcome is unknown"}}); err != nil { + t.Fatalf("complete protected Run job: %v", err) + } + completed, err := svc.store.GameClientBridgeCommands().Get(command.ID) + if err != nil || completed.State != domain.GameClientBridgeCommandUnknown || completed.Result.Status != domain.GameClientBridgeResultUnknown || strings.Contains(completed.Result.Summary, text) { + t.Fatalf("project protected Run result: command=%#v err=%v", completed, err) + } +} + func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) { svc, clock := newGameClientBridgeService(t) request := bridgeQueueRequest(*clock, "scope-key") diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 6fa636e..d846a96 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -263,6 +263,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobResultResult{}, err } + if err := svc.projectProtectedRequestJobResult(job, result, stamp); err != nil { + return domain.RunJobResultResult{}, err + } if err := svc.projectLifecycleJobResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } @@ -621,6 +624,10 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t } func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment { + fencingToken := uint64(0) + if isProtectedRequestCapability(job.Capability) { + fencingToken = uint64(job.Attempt) + } return domain.RunJobAssignment{ JobID: job.ID, ServerInstanceID: job.ServerInstanceID, @@ -635,6 +642,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment)}, LeaseToken: leaseToken, Attempt: job.Attempt, + FencingToken: fencingToken, MaxAttempts: job.RetryPolicy.MaxAttempts, AckDeadlineAt: job.AckDeadlineAt, LeaseExpiresAt: job.LeaseExpiresAt, diff --git a/platform/service/protected_requests.go b/platform/service/protected_requests.go new file mode 100644 index 0000000..3ffd597 --- /dev/null +++ b/platform/service/protected_requests.go @@ -0,0 +1,247 @@ +package service + +import ( + "strings" + "sync" + "time" + + "browser.local/platform/domain" + "browser.local/platform/validator" +) + +const protectedRequestMaxTimeoutSeconds = 120 + +type protectedRequestPayload struct { + commandID string + kind string + transportKey string + targetKey string + requestText string + expiresAt time.Time +} + +// protectedRequestBroker keeps opaque request text out of durable jobs and +// bridge records. It releases a payload exactly once to a current Run lease. +type protectedRequestBroker struct { + mu sync.Mutex + now func() time.Time + payloads map[string]protectedRequestPayload +} + +func newProtectedRequestBroker(now func() time.Time) *protectedRequestBroker { + return &protectedRequestBroker{now: now, payloads: map[string]protectedRequestPayload{}} +} + +func (broker *protectedRequestBroker) Put(jobID string, payload protectedRequestPayload) error { + broker.mu.Lock() + defer broker.mu.Unlock() + broker.pruneLocked() + if _, exists := broker.payloads[jobID]; exists { + return validationError("protected request idempotency key is already pending") + } + broker.payloads[jobID] = payload + return nil +} + +func (broker *protectedRequestBroker) Consume(jobID string) (protectedRequestPayload, error) { + broker.mu.Lock() + defer broker.mu.Unlock() + broker.pruneLocked() + payload, exists := broker.payloads[jobID] + if !exists { + return protectedRequestPayload{}, validationError("protected request input is unavailable") + } + delete(broker.payloads, jobID) + return payload, nil +} + +func (broker *protectedRequestBroker) Delete(jobID string) { + broker.mu.Lock() + defer broker.mu.Unlock() + delete(broker.payloads, jobID) +} + +func (broker *protectedRequestBroker) pruneLocked() { + stamp := broker.now() + for jobID, payload := range broker.payloads { + if !stamp.Before(payload.expiresAt) { + delete(broker.payloads, jobID) + } + } +} + +func protectedRequestCapability(kind string) (string, string, error) { + switch kind { + case "sql": + return domain.JobCapabilityRemoteRunProtectedSQL, "protected-sql", nil + case "rcon": + return domain.JobCapabilityRemoteRunProtectedRCON, "protected-rcon", nil + case "program": + return domain.JobCapabilityRemoteRunProgram, "protected-program", nil + default: + return "", "", validationError("protected request kind is unsupported") + } +} + +func redactedProtectedRequestPayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration) map[string]any { + return map[string]any{declaration.TextField: "redacted"} +} + +func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridgeCommand, declaration domain.GameClientBridgeCommandDeclaration, payload map[string]any) error { + if declaration.ProtectedRequest == nil || declaration.TimeoutSeconds < 1 || declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds { + return validationError("protected request timeout is out of bounds") + } + requestText, _ := payload[declaration.ProtectedRequest.TextField].(string) + capability, adapterKind, err := protectedRequestCapability(declaration.ProtectedRequest.Kind) + if err != nil { + return err + } + jobID := command.RunJobID + if jobID == "" { + return validationError("protected request job binding is missing") + } + if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil { + return err + } + job := domain.Job{ + ID: jobID, + ServerInstanceID: command.ServerInstanceID, + RunEndpointID: mustProtectedRequestRunEndpoint(svc, command.ServerInstanceID), + Capability: capability, + TargetKey: declaration.ProtectedRequest.TargetKey, + InputRef: "input://protected-request/" + command.ID, + IdempotencyKey: "protected-request:" + command.ID, + Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"}, + RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, + ExecutionInput: domain.JobExecutionInput{ + WorkspaceScope: command.ProfileKey, + RemoteAdapterKey: declaration.ProtectedRequest.TransportKey, + RemoteAdapterKind: adapterKind, + TimeoutSeconds: declaration.TimeoutSeconds, + PluginID: command.PluginID, + }, + } + if job.RunEndpointID == "" { + svc.protectedRequests.Delete(jobID) + return validationError("protected request server binding is unavailable") + } + created, err := svc.CreateJob(job) + if err != nil { + svc.protectedRequests.Delete(jobID) + return err + } + if created.ID != jobID || created.ServerInstanceID != job.ServerInstanceID || created.Capability != capability || created.TargetKey != job.TargetKey || created.ExecutionInput.RemoteAdapterKey != job.ExecutionInput.RemoteAdapterKey || created.ExecutionInput.RemoteAdapterKind != adapterKind { + svc.protectedRequests.Delete(jobID) + return validationError("protected request idempotency key is already bound") + } + return nil +} + +func mustProtectedRequestRunEndpoint(svc *CoreService, serverInstanceID string) string { + instance, err := svc.store.ServerInstances().Get(serverInstanceID) + if err != nil { + return "" + } + return instance.RunEndpointID +} + +func (svc *CoreService) GetProtectedRequestExecutionInput(request domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error) { + if err := validator.ValidateProtectedRequestExecutionInputRequest(request); err != nil { + return domain.ProtectedRequestExecutionInput{}, err + } + job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) + if err != nil { + return domain.ProtectedRequestExecutionInput{}, err + } + if request.FencingToken != uint64(job.Attempt) || !isProtectedRequestCapability(job.Capability) || job.RetryPolicy.MaxAttempts != 1 || !strings.HasPrefix(job.InputRef, "input://protected-request/") { + return domain.ProtectedRequestExecutionInput{}, validationError("job is not a fenced protected request") + } + commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID}) + if err != nil { + return domain.ProtectedRequestExecutionInput{}, err + } + var command domain.GameClientBridgeCommand + for _, candidate := range commands { + if candidate.RunJobID == job.ID { + command = candidate + break + } + } + if command.ID == "" || command.ApprovalState != domain.GameClientBridgeApprovalApproved || command.State != domain.GameClientBridgeCommandPending || !command.ExpiresAt.After(svc.now()) { + return domain.ProtectedRequestExecutionInput{}, validationError("protected request is not currently authorized") + } + payload, err := svc.protectedRequests.Consume(job.ID) + if err != nil { + return domain.ProtectedRequestExecutionInput{}, err + } + capability, adapterKind, capabilityErr := protectedRequestCapability(payload.kind) + if capabilityErr != nil || capability != job.Capability || payload.targetKey != job.TargetKey || payload.transportKey != job.ExecutionInput.RemoteAdapterKey || adapterKind != job.ExecutionInput.RemoteAdapterKind { + return domain.ProtectedRequestExecutionInput{}, validationError("protected request logical binding is invalid") + } + return domain.CopyProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, FencingToken: request.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: payload.expiresAt, Kind: payload.kind, TransportKey: payload.transportKey, TargetKey: payload.targetKey, RequestText: payload.requestText}), nil +} + +func isProtectedRequestCapability(capability string) bool { + _, _, err := protectedRequestCapabilityForCapability(capability) + return err == nil +} + +func protectedRequestCapabilityForCapability(capability string) (string, string, error) { + switch capability { + case domain.JobCapabilityRemoteRunProtectedSQL: + return "sql", "protected-sql", nil + case domain.JobCapabilityRemoteRunProtectedRCON: + return "rcon", "protected-rcon", nil + case domain.JobCapabilityRemoteRunProgram: + return "program", "protected-program", nil + default: + return "", "", validationError("job is not a protected request") + } +} + +func (svc *CoreService) projectProtectedRequestJobResult(job domain.Job, result domain.RunJobResult, stamp time.Time) error { + if !isProtectedRequestCapability(job.Capability) { + return nil + } + svc.protectedRequests.Delete(job.ID) + svc.bridgeMu.Lock() + defer svc.bridgeMu.Unlock() + commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID}) + if err != nil { + return err + } + for _, command := range commands { + if command.RunJobID != job.ID || isTerminalGameClientBridgeCommandState(command.State) { + continue + } + switch result.State { + case domain.JobStateSucceeded: + command.State = domain.GameClientBridgeCommandSucceeded + command.Result.Status = domain.GameClientBridgeResultSucceeded + case domain.JobStateCancelled: + command.State = domain.GameClientBridgeCommandCancelled + command.Result.Status = domain.GameClientBridgeResultCancelled + case domain.JobStateFailed: + command.State = domain.GameClientBridgeCommandFailed + command.Result.Status = domain.GameClientBridgeResultFailed + if result.ErrorCode == "protected_request_unknown" || strings.HasSuffix(result.ExecutionResult.Kind, ".unknown") { + command.State = domain.GameClientBridgeCommandUnknown + command.Result.Status = domain.GameClientBridgeResultUnknown + } + default: + return nil + } + command.Result.Summary = "protected request completed by Run" + command.Result.CompletedBy = "run" + command.Result.CompletedAt = stamp + command.CompletedAt = stamp + command.UpdatedAt = stamp + auditID, auditErr := svc.recordAuditEventWithID("run", "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "Run recorded protected bridge command result") + if auditErr != nil { + return auditErr + } + command.AuditReferences = append(command.AuditReferences, auditID) + return svc.store.GameClientBridgeCommands().Update(command) + } + return nil +} diff --git a/platform/service/resources.go b/platform/service/resources.go index 8e4e11b..3f0c822 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -140,6 +140,7 @@ type Core interface { GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error) GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error) + GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error) GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) @@ -244,6 +245,7 @@ type CoreService struct { auditSeq uint64 productionMu sync.Mutex sourceRCONCommands *sourceRCONCommandBroker + protectedRequests *protectedRequestBroker aiProviderClient AIProviderClient secretEnvelope SecretEnvelope networkFingerprintKey []byte @@ -278,6 +280,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun artifactTransfers: map[string]domain.ArtifactTransferSession{}, artifactPayloads: map[string][]byte{}, sourceRCONCommands: newSourceRCONCommandBroker(now), + protectedRequests: newProtectedRequestBroker(now), aiProviderClient: MockAIProviderClient{}, secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), networkFingerprintKey: []byte(developmentSecretEnvelopeKey), diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index 05899d1..153fc86 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -68,6 +68,14 @@ 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)) }