Files
browser/platform/api/authorization_test.go
T

232 lines
12 KiB
Go

package api
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
)
func TestAuthorizedRouterEnforcesAdminAndCrossOwnerBoundaries(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if err := core.SeedLocalPlatformAdmin(); err != nil {
t.Fatalf("seed admin: %v", err)
}
for _, user := range []domain.User{
{ID: "user-owner", DisplayName: "Owner", Email: "owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
{ID: "user-other", DisplayName: "Other", Email: "other@example.test", Status: domain.UserStatusActive, Roles: []string{"server-admin"}, PasswordHash: "secret-password"},
} {
if _, err := core.CreateUser(user); err != nil {
t.Fatalf("create user %s: %v", user.ID, err)
}
}
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
t.Fatalf("create plugin: %v", err)
}
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{
ID: "server-owner", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Owner Server",
OwnerUserID: "user-owner", State: domain.ServerInstanceStateReady, ConfigVersion: 1,
}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := core.CreateJob(domain.Job{
ID: "job-owner", ServerInstanceID: "server-owner", RunEndpointID: "run-local",
Capability: "process.start", IdempotencyKey: "job-owner",
}); err != nil {
t.Fatalf("create job: %v", err)
}
router := NewAuthorizedRouterWithCore(core)
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", ""), http.StatusUnauthorized, errorCodeUnauthorized)
ownerAuth, err := core.LoginUser(domain.UserLogin{Account: "owner@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login owner: %v", err)
}
otherAuth, err := core.LoginUser(domain.UserLogin{Account: "other@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login other: %v", err)
}
ownerSession := ownerAuth.SessionID
otherSession := otherAuth.SessionID
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-owner", ownerSession)
if jobs.Count != 1 || jobs.Items[0].ID != "job-owner" {
t.Fatalf("owner did not receive own jobs: %+v", jobs)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/jobs?serverInstanceId=server-owner", "", otherSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/jobs/job-owner/cancel", dto.RunJobCancelRequestBody{Reason: "cross-owner"}, otherSession), http.StatusForbidden, errorCodeForbidden)
encodedJobs, err := json.Marshal(jobs)
if err != nil {
t.Fatalf("encode safe job projection: %v", err)
}
for _, forbidden := range []string{"leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket"} {
if strings.Contains(string(encodedJobs), forbidden) {
t.Fatalf("job projection exposed forbidden field %q: %s", forbidden, encodedJobs)
}
}
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/game-plugins", validGamePluginRequest(), ownerSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, performJSON(t, router, http.MethodPost, "/api/v1/plugin-marketplace/plugins/server.scum/state", dto.MarketplacePluginStateRequest{Action: domain.PluginMarketplaceStateActionDisable}), http.StatusUnauthorized, errorCodeUnauthorized)
}
func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
token := "run-session-secret"
stamp := time.Now().UTC()
hash := sha256.Sum256([]byte(token))
if err := store.RunControlSessions().Create(domain.RunControlSession{
RunEndpointID: "run-local", SessionTokenHash: hex.EncodeToString(hash[:]), Status: domain.AuthSessionStatusActive,
Generation: 1, CapabilityFingerprint: "cap-v1", HeartbeatIntervalSeconds: 15,
CreatedAt: stamp, UpdatedAt: stamp, ExpiresAt: stamp.Add(time.Hour), RequireSignedRequests: true,
}); err != nil {
t.Fatalf("create Run session: %v", err)
}
router := NewTestRouterWithCore(core)
request := dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local", SessionToken: token, Version: "0.1.1", Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}
body, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal heartbeat: %v", err)
}
unsigned := performRequest(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", bytes.NewReader(body))
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
signed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
assertStatus(t, signed, http.StatusOK)
replayed := signedRunRequest(t, router, "/api/v1/run/control/heartbeat", body, token, "nonce-api-1", stamp)
assertErrorResponse(t, replayed, http.StatusUnauthorized, errorCodeUnauthorized)
if _, err := core.CreateJob(domain.Job{ID: "job-signed", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "job-signed"}); err != nil {
t.Fatalf("create signed job: %v", err)
}
claimBody, err := json.Marshal(dto.RunJobClaimRequest{
RunEndpointID: "run-local", SessionToken: token, Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
})
if err != nil {
t.Fatalf("marshal signed claim: %v", err)
}
unsignedClaim := performRequest(t, router, http.MethodPost, "/api/v1/run/jobs/claim", bytes.NewReader(claimBody))
assertErrorResponse(t, unsignedClaim, http.StatusUnauthorized, errorCodeUnauthorized)
signedClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", claimBody, token, "nonce-api-job-1", stamp)
assertStatus(t, signedClaim, http.StatusOK)
staleClaimBody, err := json.Marshal(dto.RunJobClaimRequest{
RunEndpointID: "run-local", SessionToken: "stale-session", Capabilities: []string{"process.start"}, Capacity: dto.RunCapacityResponse{MaxJobs: 1},
})
if err != nil {
t.Fatalf("marshal stale claim: %v", err)
}
staleClaim := signedRunRequest(t, router, "/api/v1/run/jobs/claim", staleClaimBody, "stale-session", "nonce-api-job-2", stamp)
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
privateUpdateBodies := map[string]any{
"/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}},
"/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 {
body, err := json.Marshal(request)
if err != nil {
t.Fatalf("marshal private Run request for %s: %v", path, err)
}
unsigned := performRequest(t, router, http.MethodPost, path, bytes.NewReader(body))
assertErrorResponse(t, unsigned, http.StatusUnauthorized, errorCodeUnauthorized)
nonce++
signed := signedRunRequest(t, router, path, body, token, fmt.Sprintf("nonce-api-private-%d", nonce), stamp)
if signed.Code == http.StatusUnauthorized {
t.Fatalf("valid signature was rejected for %s: %s", path, signed.Body.String())
}
}
}
func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
t.Fatalf("create plugin: %v", err)
}
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-authorized-lifecycle", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Authorized Lifecycle", State: domain.ServerInstanceStateFailed, ConfigVersion: 1}); err != nil {
t.Fatalf("create server: %v", err)
}
router := NewAuthorizedRouterWithCore(core)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
if hello.SessionToken == "" {
t.Fatalf("expected run session token")
}
body, err := json.Marshal(dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-authorized-lifecycle", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}})
if err != nil {
t.Fatalf("marshal lifecycle report: %v", err)
}
signed := signedRunRequest(t, router, "/api/v1/run/lifecycle/report", body, hello.SessionToken, "nonce-api-lifecycle-authorized", time.Now().UTC())
assertStatus(t, signed, http.StatusOK)
updated, err := core.GetServerInstance("server-authorized-lifecycle")
if err != nil {
t.Fatalf("get server: %v", err)
}
if updated.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected run lifecycle report to project running, got %s", updated.State)
}
}
func signedRunRequest(t *testing.T, router http.Handler, path string, body []byte, token string, nonce string, stamp time.Time) *httptest.ResponseRecorder {
t.Helper()
timestamp := strconv.FormatInt(stamp.Unix(), 10)
bodyHash := sha256.Sum256(body)
canonical := strings.Join([]string{http.MethodPost, path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
mac := hmac.New(sha256.New, []byte(token))
_, _ = mac.Write([]byte(canonical))
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Run-Endpoint", "run-local")
req.Header.Set("X-Run-Timestamp", timestamp)
req.Header.Set("X-Run-Nonce", nonce)
req.Header.Set("X-Run-Signature", hex.EncodeToString(mac.Sum(nil)))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
func TestSessionRotationRouteRevokesPreviousBearer(t *testing.T) {
router := newTestRouter()
current := createAdminSession(t, router)
rotated := postOKJSONWithAuth[dto.AuthSessionResponse](t, router, "/api/v1/auth/rotate", map[string]string{}, current)
if rotated.SessionID == "" || rotated.SessionID == current || rotated.ExpiresAt.IsZero() {
t.Fatalf("unexpected rotated session: %+v", rotated)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/users/current", "", current), http.StatusUnauthorized, errorCodeUnauthorized)
currentUser := getJSONWithAuth[dto.CurrentUserResponse](t, router, "/api/v1/users/current", rotated.SessionID)
if currentUser.ID != "user-admin" {
t.Fatalf("rotated session resolved unexpected user: %+v", currentUser)
}
}