mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
d588898f5d
The just-landed signed workload token embedded (allocation_id, match_id, server_id) as claims. That doesn't actually work for its intended delivery channel: the token is meant to be requested as a GameServerAllocation annotation in the SAME request that asks Agones to pick a server, so at mint time the allocator knows allocation_id (it generates it) but not yet which server_id Agones will return -- server_id only exists in Agones's response, after the annotation request has already been sent. Embedding it was simply not possible for the real caller this was built for; only the (allocator -> signed_token) unit tests and hand-constructed integration tests happened to supply it directly, masking the gap. Fixes it by having the token bind only allocation_id (the one identifier actually known at mint time) plus expiry. match_id/server_id are resolved at verify time from the durable allocations table via the new store.AllocationBindingByAllocationID, keyed by allocation_id -- which the allocator already records immediately after Agones responds. This is strictly stronger, not just a workaround: a caller can no longer claim any match/server pairing at all, even one that happens to be internally consistent -- the binding returned is entirely durable-record-derived. Verified: server/workload's unit tests updated for the new two-field claim shape; server/api's Postgres integration suite gains TestWorkloadVerifierFromSignedTokenNeverTrustsCallerSuppliedBinding (two distinct real allocations each resolve to their own, and only their own, match/server pairing) replacing the now-inapplicable mismatched-triple test. Full `go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and `go test -tags integration ./... -race` both clean; the api integration suite re-run 3x clean against a live postgres:17-alpine container.
116 lines
4.6 KiB
Go
116 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
"github.com/cosmic-clash/cosmic-clash/server/store"
|
|
"github.com/cosmic-clash/cosmic-clash/server/workload"
|
|
)
|
|
|
|
// AssignmentProviderFromStore adapts the durable player-scoped assignment
|
|
// projection to the HTTP boundary. The store query filters expiry and binds
|
|
// both match and player; the API still performs its response-shape checks.
|
|
func AssignmentProviderFromStore(db *sql.DB) AssignmentProvider {
|
|
return func(ctx context.Context, playerID, matchID string, now time.Time) (AssignmentView, error) {
|
|
assignment, err := store.GetAssignment(ctx, db, playerID, matchID, now)
|
|
if err != nil {
|
|
return AssignmentView{}, err
|
|
}
|
|
return AssignmentView{
|
|
MatchID: assignment.MatchID,
|
|
ServerID: assignment.ServerID,
|
|
PlayerID: assignment.PlayerID,
|
|
Slot: assignment.Slot,
|
|
ExpiresAt: assignment.ExpiresAt,
|
|
ProtocolVersion: assignment.ProtocolVersion,
|
|
Transport: assignment.Transport,
|
|
JoinAuthorisation: assignment.JoinAuthorisation,
|
|
Endpoint: assignment.Endpoint,
|
|
Revision: assignment.Revision,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
type postgresProposalBackend struct{ db *sql.DB }
|
|
|
|
func (p postgresProposalBackend) Get(ctx context.Context, playerID, proposalID string, now time.Time) (domain.Proposal, error) {
|
|
return store.GetProposal(ctx, p.db, playerID, proposalID, now)
|
|
}
|
|
|
|
func (p postgresProposalBackend) Respond(ctx context.Context, playerID, proposalID, idempotencyKey string, accept bool, expectedRevision uint64, now time.Time) (domain.Proposal, error) {
|
|
return store.RespondToProposal(ctx, p.db, playerID, proposalID, idempotencyKey, accept, expectedRevision, now)
|
|
}
|
|
|
|
func ProposalProviderFromStore(db *sql.DB) ProposalBackend {
|
|
return postgresProposalBackend{db: db}
|
|
}
|
|
|
|
// ProposalPromoterFromStore turns a durably accepted proposal into its exact
|
|
// matcher-selected ALLOCATING match. The store chooses a deterministic match
|
|
// ID so an API retry after a transient failure cannot duplicate the match.
|
|
func ProposalPromoterFromStore(db *sql.DB) ProposalPromoter {
|
|
return ProposalPromoterFunc(func(ctx context.Context, proposal domain.Proposal, now time.Time) error {
|
|
if proposal.State != domain.Accepted {
|
|
return domain.ErrIllegalTransition
|
|
}
|
|
return store.PromoteStoredAcceptedProposal(ctx, db, proposal.ProposalID, now)
|
|
})
|
|
}
|
|
|
|
type postgresServerRegistrar struct{ db *sql.DB }
|
|
|
|
func (p postgresServerRegistrar) RegisterServer(ctx context.Context, binding domain.WorkloadBinding, protocol int, assignmentReady bool, idempotencyKey string, now time.Time) error {
|
|
return store.AdvanceServerRegistration(ctx, p.db, binding, protocol, assignmentReady, idempotencyKey, now)
|
|
}
|
|
|
|
func ServerRegistrarFromStore(db *sql.DB) ServerRegistrar {
|
|
if db == nil {
|
|
return nil
|
|
}
|
|
return postgresServerRegistrar{db: db}
|
|
}
|
|
|
|
// WorkloadVerifierFromSignedToken builds WorkloadVerify from a control-plane
|
|
// -owned signed token instead of a Kubernetes-projected JWT (see
|
|
// workload/signed_token.go for why: it needs no live cluster to verify).
|
|
// secret must be kept out of source control (env var in cmd/control-plane);
|
|
// an empty secret returns nil so a misconfigured deployment fails the same
|
|
// way an unwired verifier already does today (503, not a silent bypass).
|
|
func WorkloadVerifierFromSignedToken(secret []byte, db *sql.DB) WorkloadVerifier {
|
|
if len(secret) == 0 || db == nil {
|
|
return nil
|
|
}
|
|
return func(token string, now time.Time) (domain.WorkloadBinding, error) {
|
|
claims, err := workload.ParseSignedWorkloadToken(secret, token, now)
|
|
if err != nil {
|
|
return domain.WorkloadBinding{}, err
|
|
}
|
|
// WorkloadVerifier has no context parameter (see its type in
|
|
// service.go) so the durable lookup below cannot inherit the
|
|
// caller's request context; bound it locally instead of running
|
|
// unbounded against context.Background().
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
// The token only names allocation_id (see signed_token.go for why);
|
|
// match_id/server_id come from the durable allocator record, never
|
|
// from the caller, so a token can never claim a pairing that wasn't
|
|
// actually, durably allocated.
|
|
matchID, serverID, ok, err := store.AllocationBindingByAllocationID(ctx, db, claims.AllocationID)
|
|
if err != nil {
|
|
return domain.WorkloadBinding{}, err
|
|
}
|
|
if !ok {
|
|
return domain.WorkloadBinding{}, fmt.Errorf("signed workload token names an allocation that is no longer valid")
|
|
}
|
|
return domain.WorkloadBinding{
|
|
AllocationID: claims.AllocationID,
|
|
MatchID: matchID,
|
|
ServerID: serverID,
|
|
}, nil
|
|
}
|
|
}
|