feat(api): 内部面 token 按调用方拆分为 velocity/limbo/build/ops 并按路由限定调用方,审计来源区分调用方,安装器生成并下发各自 Secret,新增 felis rotate-token 轮换命令

This commit is contained in:
Lemon-miaow committed 2026-09-25 15:21:41 +08:00
1 parent d3769b5c31
commit a883c1fe07
38 files changed
+1359 -209

No files matched your search

+51 -16
View File
@@ -14,6 +14,7 @@ package api
import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
@@ -352,6 +353,10 @@ type apiRoute struct {
// since the browser calls it every few seconds while it waits.
AuthDoor bool
// Callers lists the machines an internal-face route serves; every
// authenticated internal route names at least one, and external routes none.
Callers []Caller
h http.HandlerFunc
}
@@ -359,6 +364,14 @@ type apiRoute struct {
// service-token auth, never Zero Trust. It carries both health probes and the
// metrics scrape.
func (a *API) internalAPIRoutes() []apiRoute {
// Who may call what (Caller). The proxy drives the game-facing routes; the
// login gate only checks a joining player's bar and link and mints their bind
// code; the build Job only reads the context of the submission it builds; the
// on-node console only asks for a break-glass backup.
proxy := []Caller{CallerVelocity}
gate := []Caller{CallerVelocity, CallerLimbo}
build := []Caller{CallerBuild}
ops := []Caller{CallerOps}
return []apiRoute{
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
@@ -366,47 +379,47 @@ func (a *API) internalAPIRoutes() []apiRoute {
// no token, internal-only so it is never exposed off-cluster.
{Method: "GET", Pattern: "/metrics", Public: true, h: a.handleMetrics},
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
{Method: "GET", Pattern: "/api/v1/servers", Callers: proxy, h: a.handleListServers},
// The build Pod's context-fetch initContainer streams a submission's stored
// modpack through this route (build namespace cannot mount the uploads PVC).
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", h: a.handleInternalSubmissionContext},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", Callers: build, h: a.handleInternalSubmissionContext},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", Callers: proxy, h: a.handleReady},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", Callers: proxy, h: a.handleJoinEvent},
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
// status with its service token, identifying the joining player by online-mode
// UUID. These live on the internal face because velocity holds no web Principal;
// the external face keeps its own Principal-gated wake/status for the panel.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/wake", h: a.handleInternalWake},
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/status", h: a.handleStatus},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/wake", Callers: proxy, h: a.handleInternalWake},
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/status", Callers: proxy, h: a.handleStatus},
// Lobby `/menu` (spec §12): the felis-paper lobby is a pure UI face holding no
// token, so velocity drives these on its behalf — claim by online-mode UUID
// (the lobby's `Claim & Start`, separate from the autostartPolicy-gated wake)
// and the menu projection that adds the ownership-derived `claimable` the §11
// list/status views never carry.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", h: a.handleInternalClaim},
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", h: a.handleInternalMenuStatus},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/claim", Callers: proxy, h: a.handleInternalClaim},
{Method: "GET", Pattern: "/api/v1/internal/servers/{name}/menu", Callers: proxy, h: a.handleInternalMenuStatus},
// Account linking (spec §10): the in-game /link side mints a one-time code for a
// verified UUID. Internal-only — the code is born from an online-mode UUID the
// web never holds (account_link_codes has no user_id column).
{Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode},
{Method: "POST", Pattern: "/api/v1/internal/account/link/code", Callers: gate, h: a.handleCreateLinkCode},
// QR scan-to-login completion poll (spec §B3 player game-login). After the player
// scans the QR-encoded code and the web verify writes the durable link, velocity
// polls this for the UUID it minted against and admits on {linked:true}. Read-only
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
// and is safe to poll repeatedly.
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", h: a.handleLinkStatus},
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", Callers: gate, h: a.handleLinkStatus},
// Account migration (spec §B3 inherit), in-game side: /felis migrate puts the
// account linked to the running player's verified UUID into migrate mode. Internal
// only — the initiator is proven by online-mode auth, and the sensitive proof
// (step-up) still happens web-side before anything transfers.
{Method: "POST", Pattern: "/api/v1/internal/account/migrate/start", h: a.handleMigrateStart},
{Method: "POST", Pattern: "/api/v1/internal/account/migrate/start", Callers: proxy, h: a.handleMigrateStart},
// Username-collision reclaim (spec §B3): velocity records a Mojang-priority
// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the
// limbo login by checking whether a connecting UUID was barred. Internal-only —
// velocity holds a service token, and the bar is keyed by UUID so the genuine
// Mojang player (same name, different UUID) always passes.
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", Callers: proxy, h: a.handleReclaimUsername},
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", Callers: gate, h: a.handleCheckBlacklist},
// Felis-nano multi-source session verifier, behind player game-login. Velocity is
// pointed here with -Dmojang.sessionserver and issues the request itself; it speaks
// the vanilla sessionserver protocol and carries no token, so this is Public. It
@@ -419,13 +432,13 @@ func (a *API) internalAPIRoutes() []apiRoute {
// /felis web op approve. Internal face carries the pending queue and the
// approve action (service-token auth, no Principal); the public face carries
// the start/status/finish the staff member's browser drives.
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", Callers: proxy, h: a.handleOpLoginPending},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", Callers: proxy, h: a.handleOpLoginApprove},
// Break-glass backup (spec §B4 "Sync"): the on-node console POSTs here to
// snapshot a stopped world while the API is alive. Service-token auth (no
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", h: a.handleInternalBackup},
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
}
}
@@ -705,6 +718,12 @@ func (a *API) buildFace(face string, routes []apiRoute, guard func(http.Handler)
continue
}
h := rt.h
if (face == "internal") != (len(rt.Callers) > 0) {
panic(fmt.Sprintf("%s route %s %s: internal routes list their callers, external ones none", face, rt.Method, rt.Pattern))
}
if len(rt.Callers) > 0 {
h = callersOnly(rt.Callers, h)
}
if rt.Owner {
h = a.ownerOnly(rt.h)
}
@@ -832,6 +851,7 @@ const (
ctxKeyRequestID ctxKey = iota
ctxKeyPrincipal
ctxKeyReqInfo
ctxKeyCaller
)
func requestIDFromContext(ctx context.Context) string {
@@ -849,6 +869,21 @@ func principalFromContext(ctx context.Context) *Principal {
return nil
}
// callerFromContext returns the internal-face caller, or "" off that face.
func callerFromContext(ctx context.Context) Caller {
c, _ := ctx.Value(ctxKeyCaller).(Caller)
return c
}
// internalSource is the audit Source for an action taken on the internal face,
// naming the caller whose token asked for it ("internal:velocity").
func internalSource(r *http.Request) string {
if c := callerFromContext(r.Context()); c != "" {
return "internal:" + string(c)
}
return "internal"
}
// ---- per-key cooldown (wake + OTP) ----
// cooldownLimiter is an in-memory per-key cooldown. It backs two throttles with
+11 -5
View File
@@ -1752,9 +1752,15 @@ type staticExternal struct {
func (s staticExternal) Authenticate(*http.Request) (*Principal, error) { return s.p, s.err }
type okInternal struct{}
// okInternal admits every request as one caller, the proxy unless set.
type okInternal struct{ caller Caller }
func (okInternal) Authenticate(*http.Request) error { return nil }
func (o okInternal) Authenticate(*http.Request) (Caller, error) {
if o.caller == "" {
return CallerVelocity, nil
}
return o.caller, nil
}
// ---- helpers ----
@@ -1798,7 +1804,7 @@ func decodeErr(t *testing.T, w *httptest.ResponseRecorder) string {
func TestInternalFaceRequiresServiceToken(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
h := api.InternalHandler()
// no token -> 401
@@ -1817,7 +1823,7 @@ func TestInternalFaceRequiresServiceToken(t *testing.T) {
func TestHealthzIsUnauthenticated(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
if w := do(api.InternalHandler(), "GET", "/healthz", "", nil); w.Code != http.StatusOK {
t.Fatalf("healthz code = %d, want 200", w.Code)
}
@@ -1829,7 +1835,7 @@ func TestReadyzPingsDependencies(t *testing.T) {
repo := newFakeRepo()
cl := newFakeCluster()
api := newTestAPI(repo, cl)
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
// Both healthy.
if w := do(api.InternalHandler(), "GET", "/readyz", "", nil); w.Code != http.StatusOK {
+59 -18
View File
@@ -71,11 +71,33 @@ func (p *Principal) IsOwner() bool {
return p != nil && p.Role == "owner" && p.ViaAdminAccess
}
// InternalAuth authenticates the internal face (velocity / backend callbacks):
// a static service token presented as a Bearer credential. The internal face
// is never wrapped in Zero Trust (spec §1.8, §14 red line).
// Caller names the machine behind an internal-face token. Each caller holds a
// token of its own and each internal route lists the callers it serves
// (apiRoute.Callers), so a token copied out of one namespace opens only what
// that caller needs: the build Job's token reads a submission's context and
// nothing else, and only the proxy and the login gate can mint link codes.
type Caller string
const (
// CallerVelocity is the proxy's felis-link plugin (felis-service-token,
// written into felis-link.properties on the host).
CallerVelocity Caller = "velocity"
// CallerLimbo is the login gate's felis-limbo plugin (felis-limbo-token,
// injected into the login pod only).
CallerLimbo Caller = "limbo"
// CallerBuild is the build Job's context-fetch initContainer
// (felis-build-token in the build namespace).
CallerBuild Caller = "build"
// CallerOps is the on-node console, `felis backup-now` (felis-ops-token,
// control namespace only).
CallerOps Caller = "ops"
)
// InternalAuth authenticates the internal face: a per-caller static token
// presented as a Bearer credential, answered with the caller it belongs to. The
// internal face is never wrapped in Zero Trust (spec §1.8, §14 red line).
type InternalAuth interface {
Authenticate(r *http.Request) error
Authenticate(r *http.Request) (Caller, error)
}
// ExternalAuth authenticates the external face (people / panel) and returns the
@@ -86,26 +108,45 @@ type ExternalAuth interface {
Authenticate(r *http.Request) (*Principal, error)
}
// BearerTokenAuth is the production InternalAuth: a constant-time comparison
// against the configured service token. A zero token fails closed so a
// misconfiguration can never silently disable internal-face auth.
type BearerTokenAuth struct {
Token string
// CallerTokens is the production InternalAuth: each caller's token, compared in
// constant time. A caller with no token cannot authenticate, so a missing
// Secret fails closed for that caller alone.
type CallerTokens map[Caller]string
// NewCallerTokens refuses a set that would make the caller ambiguous: two
// callers sharing a value, which is also what an install whose callers all
// still hold the one old service token would look like.
func NewCallerTokens(tokens map[Caller]string) (CallerTokens, error) {
seen := map[string]Caller{}
for caller, tok := range tokens {
if tok == "" {
continue
}
if other, dup := seen[tok]; dup {
return nil, fmt.Errorf("the %s and %s tokens are the same value; each caller needs its own", other, caller)
}
seen[tok] = caller
}
return CallerTokens(tokens), nil
}
// Authenticate checks the Authorization: Bearer header against the token.
func (b BearerTokenAuth) Authenticate(r *http.Request) error {
if b.Token == "" {
return fmt.Errorf("internal auth not configured")
}
// Authenticate matches the Authorization: Bearer header against every caller's
// token, comparing each so the time taken does not say which one matched.
func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
got := bearerToken(r)
if got == "" {
return fmt.Errorf("missing bearer token")
return "", fmt.Errorf("missing bearer token")
}
if subtle.ConstantTimeCompare([]byte(got), []byte(b.Token)) != 1 {
return fmt.Errorf("invalid service token")
var match Caller
for caller, tok := range c {
if tok != "" && subtle.ConstantTimeCompare([]byte(got), []byte(tok)) == 1 {
match = caller
}
}
return nil
if match == "" {
return "", fmt.Errorf("invalid service token")
}
return match, nil
}
// AccessVerifier is the production ExternalAuth: it parses a Cloudflare Access
+4 -2
View File
@@ -163,8 +163,10 @@ var (
// 503 "retry" instead of a 401 that reads as "log in again".
errAuthUnavailable = newError(http.StatusServiceUnavailable, "auth_unavailable",
"authentication is temporarily unavailable; retry shortly")
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
// errWrongCaller: a valid internal token for a caller this route does not serve.
errWrongCaller = newError(http.StatusForbidden, "wrong_caller", "this token's caller may not use this route")
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
)
// writeJSON writes v as an indented JSON body with the given status.
+1 -1
View File
@@ -116,7 +116,7 @@ func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
// Internal-face event: attribute to the in-game initiator, Source 'internal'.
a.auditEntry(r, AuditEntry{
Actor: "mc:" + mcUUID,
Source: "internal",
Source: internalSource(r),
Action: "account.migrate.start",
})
writeJSON(w, http.StatusCreated, map[string]any{"started": true, "state": "initiated"})
+4 -3
View File
@@ -272,7 +272,7 @@ func TestBackupNow(t *testing.T) {
// the stopped-gate / 503 / async-202 behaviour is proven there; here the focus is the
// internal-face difference: no Principal (service-token auth), no owner gate — even a
// server owned by someone else backs up (the on-node operator is trusted) — and the
// audit is attributed to "break-glass"/"internal", not an email/"external".
// audit is attributed to "break-glass"/"internal:ops", not an email/"external".
func TestInternalBackup(t *testing.T) {
mk := func() (*API, *fakeRepo, *fakeCluster, *fakeBackuper) {
repo := newFakeRepo()
@@ -282,6 +282,7 @@ func TestInternalBackup(t *testing.T) {
Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
backuper := &fakeBackuper{}
api := newTestAPI(repo, cl)
api.Internal = okInternal{caller: CallerOps}
api.Backuper = backuper
return api, repo, cl, backuper
}
@@ -301,7 +302,7 @@ func TestInternalBackup(t *testing.T) {
backuper.calls, backuper.gotName, backuper.gotFormerOwn)
}
if len(repo.audits) != 1 || repo.audits[0].Action != "backup.create" ||
repo.audits[0].Actor != "break-glass" || repo.audits[0].Source != "internal" {
repo.audits[0].Actor != "break-glass" || repo.audits[0].Source != "internal:ops" {
t.Fatalf("audit not attributed to break-glass/internal: %+v", repo.audits)
}
})
@@ -312,7 +313,7 @@ func TestInternalBackup(t *testing.T) {
if w.Code != http.StatusAccepted {
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if len(repo.audits) != 1 || repo.audits[0].Actor != "alice" || repo.audits[0].Source != "internal" {
if len(repo.audits) != 1 || repo.audits[0].Actor != "alice" || repo.audits[0].Source != "internal:ops" {
t.Fatalf("audit actor should be the os_user, not break-glass: %+v", repo.audits)
}
})
+1 -1
View File
@@ -375,7 +375,7 @@ func (a *API) handleInternalBackup(w http.ResponseWriter, r *http.Request) {
return
}
a.enqueueBackup(w, r, name, rec, actor, "internal")
a.enqueueBackup(w, r, name, rec, actor, internalSource(r))
}
// enqueueBackup is the shared tail of both backup faces: the RWO stopped-gate, the
+3 -3
View File
@@ -56,7 +56,7 @@ func (a *API) handleReady(w http.ResponseWriter, r *http.Request) {
return
}
a.auditEntry(r, AuditEntry{
Actor: "backend", Source: "internal", Action: "ready", ServerName: name,
Actor: "backend", Source: internalSource(r), Action: "ready", ServerName: name,
})
w.WriteHeader(http.StatusNoContent)
}
@@ -167,7 +167,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
// untouched and the next join attempt is not also throttled.
a.limiter().record(name)
a.auditEntry(r, AuditEntry{
Actor: "velocity", Source: "internal", Action: "wake", ServerName: name,
Actor: "velocity", Source: internalSource(r), Action: "wake", ServerName: name,
})
writeJSON(w, http.StatusAccepted, map[string]any{
"name": name, "desiredState": "Running",
@@ -259,7 +259,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) {
}
a.auditEntry(r, AuditEntry{
Actor: "velocity", Source: "internal", Action: "claim", ServerName: name,
Actor: "velocity", Source: internalSource(r), Action: "claim", ServerName: name,
})
writeJSON(w, http.StatusOK, map[string]any{"name": name, "claimed": true})
}
+1 -1
View File
@@ -211,7 +211,7 @@ func assertEq(t *testing.T, key string, got, want any) {
func (f *fakeRepo) assertClaimAudit(t *testing.T, name string) {
t.Helper()
for _, e := range f.audits {
if e.Action == "claim" && e.ServerName == name && e.Actor == "velocity" && e.Source == "internal" {
if e.Action == "claim" && e.ServerName == name && e.Actor == "velocity" && e.Source == "internal:velocity" {
return
}
}
+1 -1
View File
@@ -430,7 +430,7 @@ func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
}
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
a.auditEntry(r, AuditEntry{
Actor: approver.Username, ActorUserID: approverID, Source: "internal",
Actor: approver.Username, ActorUserID: approverID, Source: internalSource(r),
Action: "auth.op_login.approved", Payload: payload,
})
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
+2 -2
View File
@@ -99,7 +99,7 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
payload, _ := json.Marshal(map[string]string{
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
a.auditEntry(r, AuditEntry{
Actor: "velocity", Source: "internal", Action: "player.reclaim.refused", Payload: payload,
Actor: "velocity", Source: internalSource(r), Action: "player.reclaim.refused", Payload: payload,
})
writeError(w, r, newError(http.StatusConflict, "protected_admin",
"that username belongs to a linked administrator on the login server and cannot be reclaimed"))
@@ -126,7 +126,7 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
// internal since velocity, not a human, drives it.
payload, _ := json.Marshal(map[string]string{"username": req.Username, "squatter_uuid": req.SquatterUUID})
a.auditEntry(r, AuditEntry{
Actor: "velocity", Source: "internal", Action: "player.reclaim", Payload: payload,
Actor: "velocity", Source: internalSource(r), Action: "player.reclaim", Payload: payload,
})
writeJSON(w, http.StatusOK, map[string]any{
"blacklisted": true,
+2 -2
View File
@@ -130,7 +130,7 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
t.Fatalf("audits = %d, want 1 refusal row", len(repo.audits))
}
a := repo.audits[0]
if a.Action != "player.reclaim.refused" || a.Actor != "velocity" || a.Source != "internal" {
if a.Action != "player.reclaim.refused" || a.Actor != "velocity" || a.Source != "internal:velocity" {
t.Fatalf("audit = %+v, want player.reclaim.refused/velocity/internal", a)
}
var p map[string]string
@@ -278,7 +278,7 @@ func TestReclaimAudited(t *testing.T) {
t.Fatalf("audits = %d, want 1", len(repo.audits))
}
a := repo.audits[0]
if a.Action != "player.reclaim" || a.Actor != "velocity" || a.Source != "internal" {
if a.Action != "player.reclaim" || a.Actor != "velocity" || a.Source != "internal:velocity" {
t.Fatalf("audit = %+v, want player.reclaim/velocity/internal", a)
}
var p map[string]string
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func callerTokensForTest() CallerTokens {
return CallerTokens{
CallerVelocity: "tok-velocity",
CallerLimbo: "tok-limbo",
CallerBuild: "tok-build",
CallerOps: "tok-ops",
}
}
func bearer(tok string) map[string]string {
return map[string]string{"Authorization": "Bearer " + tok, "Content-Type": "application/json"}
}
// Each token answers with its own caller, and nothing else gets in.
func TestCallerTokensNameTheCaller(t *testing.T) {
c := callerTokensForTest()
for tok, want := range map[string]Caller{
"tok-velocity": CallerVelocity,
"tok-limbo": CallerLimbo,
"tok-build": CallerBuild,
"tok-ops": CallerOps,
} {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer "+tok)
got, err := c.Authenticate(r)
if err != nil || got != want {
t.Errorf("%s: got (%q, %v), want %q", tok, got, err, want)
}
}
for _, header := range []string{"", "Bearer tok-velocityX", "Bearer tok-veloci", "Basic tok-ops"} {
r := httptest.NewRequest("GET", "/", nil)
if header != "" {
r.Header.Set("Authorization", header)
}
if got, err := c.Authenticate(r); err == nil {
t.Errorf("%q authenticated as %q", header, got)
}
}
// A caller whose Secret is missing has an empty token; that must not turn into
// "any empty-ish credential passes".
partial := CallerTokens{CallerVelocity: "tok-velocity", CallerBuild: ""}
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer ")
if got, err := partial.Authenticate(r); err == nil {
t.Fatalf("blank bearer authenticated as %q", got)
}
}
func TestNewCallerTokensRefusesAmbiguousSets(t *testing.T) {
if _, err := NewCallerTokens(map[Caller]string{CallerVelocity: "a", CallerLimbo: "b", CallerBuild: "", CallerOps: "c"}); err != nil {
t.Fatalf("distinct tokens refused: %v", err)
}
_, err := NewCallerTokens(map[Caller]string{CallerVelocity: "same", CallerBuild: "same"})
if err == nil || !strings.Contains(err.Error(), "same value") {
t.Fatalf("shared value: err = %v, want a same-value refusal", err)
}
}
// The caller scopes the gap asked for, spelled out rather than read back from the
// route table: the build token reads a submission's context and nothing else, only
// the proxy and the login gate mint link codes, only the proxy approves an
// op-login, and only the on-node console asks for a break-glass backup.
func TestInternalRoutesServeOnlyTheirCallers(t *testing.T) {
a := newTestAPI(newFakeRepo(), newFakeCluster())
a.Internal = callerTokensForTest()
h := a.InternalHandler()
cases := []struct {
method, path string
allowed []Caller
}{
{"GET", "/api/v1/servers", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/submissions/sub-1/context", []Caller{CallerBuild}},
{"POST", "/api/v1/internal/account/link/code", []Caller{CallerVelocity, CallerLimbo}},
{"GET", "/api/v1/internal/account/link/status/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
{"GET", "/api/v1/internal/player/blacklist/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
{"POST", "/api/v1/internal/op-login/req-1/approve", []Caller{CallerVelocity}},
{"GET", "/api/v1/internal/op-login/pending", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/account/migrate/start", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/player/reclaim", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/wake", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/claim", []Caller{CallerVelocity}},
{"POST", "/api/v1/internal/servers/survival/backup", []Caller{CallerOps}},
}
tokens := map[Caller]string{CallerVelocity: "tok-velocity", CallerLimbo: "tok-limbo", CallerBuild: "tok-build", CallerOps: "tok-ops"}
for _, tc := range cases {
for caller, tok := range tokens {
allowed := false
for _, c := range tc.allowed {
allowed = allowed || c == caller
}
w := do(h, tc.method, tc.path, "{}", bearer(tok))
refused := w.Code == http.StatusForbidden && decodeErr(t, w) == "wrong_caller"
if allowed && (refused || w.Code == http.StatusUnauthorized) {
t.Errorf("%s %s as %s: refused (%d %s), want it served", tc.method, tc.path, caller, w.Code, w.Body.String())
}
if !allowed && !refused {
t.Errorf("%s %s as %s: got %d %s, want 403 wrong_caller", tc.method, tc.path, caller, w.Code, w.Body.String())
}
}
}
}
// The audit names the caller whose token asked for the action.
func TestInternalAuditNamesTheCaller(t *testing.T) {
repo := newFakeRepo()
a := newTestAPI(repo, newFakeCluster())
a.Internal = callerTokensForTest()
r := httptest.NewRequest("POST", "/", nil)
if got := internalSource(r); got != "internal" {
t.Fatalf("no caller: source = %q, want internal", got)
}
var seen string
probe := a.requireInternal(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = internalSource(r)
}))
r.Header.Set("Authorization", "Bearer tok-limbo")
probe.ServeHTTP(httptest.NewRecorder(), r)
if seen != "internal:limbo" {
t.Fatalf("source = %q, want internal:limbo", seen)
}
}
// A route added to the internal table without saying who calls it would be open
// to every token; the face refuses to build instead. Callers on an external route
// would mean nothing, so that is refused too.
func TestBuildFaceRequiresCallersOnInternalRoutes(t *testing.T) {
a := newTestAPI(newFakeRepo(), newFakeCluster())
noop := func(w http.ResponseWriter, r *http.Request) {}
pass := func(h http.Handler) http.Handler { return h }
mustPanic := func(name string, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s: built without complaint", name)
}
}()
fn()
}
mustPanic("internal route without callers", func() {
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/api/v1/internal/x", h: noop}}, pass)
})
mustPanic("external route with callers", func() {
a.buildFace("external", []apiRoute{{Method: "GET", Pattern: "/api/v1/x", Callers: []Caller{CallerOps}, h: noop}}, pass)
})
// Public internal routes (probes, hasJoined) carry no token and list no callers.
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/readyz", Public: true, h: noop}}, pass)
}
+21 -4
View File
@@ -210,18 +210,35 @@ func (b *deadlineBody) Read(p []byte) (int, error) {
return n, err
}
// requireInternal enforces service-token auth for the internal face. It never
// applies Zero Trust (spec §14 red line).
// requireInternal enforces service-token auth for the internal face and stashes
// the caller the token belongs to, which callersOnly checks against the route.
// It never applies Zero Trust (spec §14 red line).
func (a *API) requireInternal(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := a.Internal.Authenticate(r); err != nil {
caller, err := a.Internal.Authenticate(r)
if err != nil {
writeError(w, r, errUnauthorized)
return
}
next.ServeHTTP(w, r)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyCaller, caller)))
})
}
// callersOnly refuses an internal route to a caller it does not list: the token
// is genuine, it just belongs to a machine this route does not serve.
func callersOnly(callers []Caller, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller := callerFromContext(r.Context())
for _, c := range callers {
if c == caller {
next(w, r)
return
}
}
writeError(w, r, errWrongCaller)
}
}
// requireExternal enforces Access-JWT auth for the external face and stashes the
// resolved Principal in the request context.
func (a *API) requireExternal(next http.Handler) http.Handler {
+20 -6
View File
@@ -40,8 +40,9 @@ type oasDoc struct {
}
type oasOp struct {
Faces []string `json:"x-felis-face"`
Tier string `json:"x-felis-tier"`
Faces []string `json:"x-felis-face"`
Tier string `json:"x-felis-tier"`
Callers []string `json:"x-felis-callers"`
}
// oasFacet is the classification of one {method, path}: which face(s) serve it
@@ -49,6 +50,8 @@ type oasOp struct {
type oasFacet struct {
faces map[string]bool
tier string
// callers is the internal route's caller set, empty elsewhere.
callers map[string]bool
}
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
@@ -80,6 +83,10 @@ func TestOpenAPIMatchesServedRoutes(t *testing.T) {
if s.tier != d.tier {
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier)
}
if !oasSameSet(s.callers, d.callers) {
t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v",
key, oasSortedKeys(s.callers), oasSortedKeys(d.callers))
}
}
}
@@ -92,11 +99,14 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
t.Helper()
a := &API{}
out := map[string]oasFacet{}
add := func(method, pattern, face, tier string) {
add := func(method, pattern, face, tier string, callers ...Caller) {
key := method + " " + pattern
f, ok := out[key]
if !ok {
f = oasFacet{faces: map[string]bool{}}
f = oasFacet{faces: map[string]bool{}, callers: map[string]bool{}}
}
for _, c := range callers {
f.callers[string(c)] = true
}
f.faces[face] = true
if f.tier != "" && f.tier != tier {
@@ -110,7 +120,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
if rt.Public {
tier = "public"
}
add(rt.Method, rt.Pattern, "internal", tier)
add(rt.Method, rt.Pattern, "internal", tier, rt.Callers...)
}
for _, rt := range a.externalAPIRoutes() {
var tier string
@@ -172,7 +182,11 @@ func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
if _, dup := out[key]; dup {
t.Errorf("%s: documented more than once", key)
}
out[key] = oasFacet{faces: faces, tier: op.Tier}
callers := map[string]bool{}
for _, c := range op.Callers {
callers[c] = true
}
out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers}
}
}
return out
+1
View File
@@ -605,6 +605,7 @@ func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) {
func TestInternalSubmissionContextRoute(t *testing.T) {
newAPI := func(s SubmissionService) *API {
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.Internal = okInternal{caller: CallerBuild}
api.Submissions = s
return api
}