From 494e793fb51f9f7514d57aac8dddccc1ab96f34f Mon Sep 17 00:00:00 2001 From: Wojtek Date: Sun, 12 Apr 2026 23:07:21 -0400 Subject: [PATCH] claw-api: warn on principal verb skew --- cmd/claw-api/handler.go | 17 +++++- cmd/claw-api/handler_test.go | 57 ++++++++++++++++- cmd/claw/compose_up.go | 3 + cmd/claw/compose_up_test.go | 35 +++++++++++ internal/clawapi/principal.go | 98 +++++++++++++++++++++++++++++- internal/clawapi/principal_test.go | 50 +++++++++++++++ 6 files changed, 256 insertions(+), 4 deletions(-) diff --git a/cmd/claw-api/handler.go b/cmd/claw-api/handler.go index 3275fa1..2495915 100644 --- a/cmd/claw-api/handler.go +++ b/cmd/claw-api/handler.go @@ -103,7 +103,22 @@ func newHandler(manifest *manifestpkg.PodManifest, scheduleManifest *schedulepkg func (h *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/health": - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + principalCount := 0 + inert := []string(nil) + warnings := []string(nil) + if h.store != nil { + principalCount = len(h.store.Principals) + inert = h.store.InertPrincipalNames() + warnings = h.store.NormalizationWarnings + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "principals": map[string]any{ + "count": principalCount, + "inert": inert, + "normalization_warnings": warnings, + }, + }) return case r.Method == http.MethodGet && r.URL.Path == "/fleet/status": h.handleStatus(w, r) diff --git a/cmd/claw-api/handler_test.go b/cmd/claw-api/handler_test.go index dfc62a9..f99dd54 100644 --- a/cmd/claw-api/handler_test.go +++ b/cmd/claw-api/handler_test.go @@ -27,8 +27,61 @@ func TestHandlerHealthEndpoint(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } - if !strings.Contains(w.Body.String(), `"ok":true`) { - t.Fatalf("unexpected health body: %s", w.Body.String()) + var body struct { + OK bool `json:"ok"` + Principals struct { + Count int `json:"count"` + Inert []string `json:"inert"` + NormalizationWarnings []string `json:"normalization_warnings"` + } `json:"principals"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal health body: %v body=%s", err, w.Body.String()) + } + if !body.OK || body.Principals.Count != 0 || len(body.Principals.Inert) != 0 || len(body.Principals.NormalizationWarnings) != 0 { + t.Fatalf("unexpected health body: %+v raw=%s", body, w.Body.String()) + } +} + +func TestHandlerHealthEndpointIncludesPrincipalNormalizationDetails(t *testing.T) { + h := newHandler(&manifestpkg.PodManifest{PodName: "ops"}, nil, nil, nil, &clawapi.Store{ + Principals: []clawapi.Principal{{ + Name: "future", + Token: "capi_x", + Verbs: nil, + Pods: []string{"ops"}, + }}, + NormalizationWarnings: []string{ + `ignoring unknown verb "schedule.pause" for principal "future"`, + `principal "future" has no recognized verbs; token will authorize nothing`, + }, + }, nil, nil, clawapi.DefaultThresholds(), t.TempDir()) + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var body struct { + Principals struct { + Count int `json:"count"` + Inert []string `json:"inert"` + NormalizationWarnings []string `json:"normalization_warnings"` + } `json:"principals"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal health body: %v body=%s", err, w.Body.String()) + } + if body.Principals.Count != 1 { + t.Fatalf("expected principal count 1, got %+v", body.Principals) + } + if !strings.Contains(strings.Join(body.Principals.NormalizationWarnings, "\n"), `has no recognized verbs`) { + t.Fatalf("expected normalization warning in health body, got %+v", body.Principals) + } + if len(body.Principals.Inert) != 1 || body.Principals.Inert[0] != "future" { + t.Fatalf("expected inert principal in health body, got %+v", body.Principals) } } diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index b607cfa..4db6d30 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -1784,6 +1784,9 @@ func prepareClawAPIRuntime(runtimeDir string, p *pod.Pod, resolvedClaws map[stri principals[i] = m.Principal } store := clawapi.Store{Principals: principals} + for _, warning := range clawapi.PrincipalVersionSkewWarnings(&store, p.ClawAPI.Image) { + fmt.Printf("[claw] warning: %s\n", warning) + } if err := writeClawAPIPrincipalStore(runtimeDir, p.ClawAPI.PrincipalsHostPath, store); err != nil { return nil, fmt.Errorf("write claw-api principals: %w", err) } diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index 086d566..2df133a 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -902,6 +902,41 @@ func TestPrepareClawAPIRuntimeWithMasterAndInvokeAlsoWritesSchedulerPrincipal(t } } +func TestPrepareClawAPIRuntimeWarnsWhenClawAPIImageMayNotSupportPrincipalVerb(t *testing.T) { + runtimeDir := t.TempDir() + p := &pod.Pod{ + Name: "ops", + Services: map[string]*pod.Service{ + "westin": { + Claw: &pod.ClawBlock{ + Invoke: []pod.InvokeEntry{{ + Schedule: "0 9 * * 1-5", + Message: "Open the market.", + }}, + }, + }, + }, + ClawAPI: &pod.ClawAPIConfig{ + Image: "ghcr.io/mostlydev/claw-api:v0.4.2", + Addr: ":8080", + PrincipalsHostPath: filepath.Join(runtimeDir, "claw-api", "principals.json"), + }, + } + + out, err := captureStdout(t, func() error { + _, err := prepareClawAPIRuntime(runtimeDir, p, map[string]*driver.ResolvedClaw{ + "westin": {Count: 1}, + }) + return err + }) + if err != nil { + t.Fatalf("prepareClawAPIRuntime: %v", err) + } + if !strings.Contains(out, `ghcr.io/mostlydev/claw-api:v0.4.2`) || !strings.Contains(out, `known minimum v0.6.0`) { + t.Fatalf("expected skew warning in output, got %q", out) + } +} + func TestPrepareClawAPIRuntimeRejectsInjectIntoReservedMasterService(t *testing.T) { runtimeDir := t.TempDir() p := &pod.Pod{ diff --git a/internal/clawapi/principal.go b/internal/clawapi/principal.go index fb866f2..7bad2f8 100644 --- a/internal/clawapi/principal.go +++ b/internal/clawapi/principal.go @@ -9,6 +9,8 @@ import ( "os" "path" "strings" + + "golang.org/x/mod/semver" ) const ( @@ -29,7 +31,8 @@ var AllWriteVerbs = []string{VerbFleetRestart, VerbFleetQuarantine, VerbFleetBud var AllVerbs = append(append([]string{}, AllReadVerbs...), AllWriteVerbs...) type Store struct { - Principals []Principal `json:"principals"` + Principals []Principal `json:"principals"` + NormalizationWarnings []string `json:"-"` } type Principal struct { @@ -60,9 +63,23 @@ func LoadStoreWithWarnings(filePath string) (*Store, []string, error) { if err != nil { return nil, nil, fmt.Errorf("validate claw-api principals %q: %w", filePath, err) } + store.NormalizationWarnings = append([]string(nil), warnings...) return &store, warnings, nil } +func (s *Store) InertPrincipalNames() []string { + if s == nil { + return nil + } + names := make([]string, 0) + for _, principal := range s.Principals { + if len(principal.Verbs) == 0 { + names = append(names, principal.Name) + } + } + return names +} + func (s *Store) ResolveBearer(header string) (*Principal, error) { if s == nil { return nil, fmt.Errorf("principal store not configured") @@ -293,6 +310,85 @@ func containsGlobMeta(pattern string) bool { return strings.ContainsAny(pattern, "*?[\\") } +var minClawAPIImageVersionByVerb = map[string]string{ + VerbScheduleRead: "v0.6.0", + VerbScheduleControl: "v0.6.0", +} + +func PrincipalVersionSkewWarnings(store *Store, imageRef string) []string { + if store == nil { + return nil + } + imageRef = strings.TrimSpace(imageRef) + if imageRef == "" { + return nil + } + + tag, tagged := imageTagFromRef(imageRef) + version, versioned := normalizeSemverTag(tag) + + seen := make(map[string]struct{}) + warnings := make([]string, 0) + for _, principal := range store.Principals { + for _, verb := range principal.Verbs { + minVersion, tracked := minClawAPIImageVersionByVerb[verb] + if !tracked { + continue + } + + var warning string + switch { + case !tagged || !versioned: + warning = fmt.Sprintf("claw-api image %q is not version-pinned; cannot verify support for principal %q verb %q (known minimum %s)", imageRef, principal.Name, verb, minVersion) + case semver.Compare(version, minVersion) < 0: + warning = fmt.Sprintf("claw-api image %q may not support principal %q verb %q (known minimum %s)", imageRef, principal.Name, verb, minVersion) + default: + continue + } + if _, ok := seen[warning]; ok { + continue + } + seen[warning] = struct{}{} + warnings = append(warnings, warning) + } + } + return warnings +} + +func imageTagFromRef(imageRef string) (string, bool) { + imageRef = strings.TrimSpace(imageRef) + if imageRef == "" { + return "", false + } + if i := strings.Index(imageRef, "@"); i >= 0 { + imageRef = imageRef[:i] + } + slash := strings.LastIndex(imageRef, "/") + colon := strings.LastIndex(imageRef, ":") + if colon <= slash { + return "", false + } + tag := strings.TrimSpace(imageRef[colon+1:]) + if tag == "" { + return "", false + } + return tag, true +} + +func normalizeSemverTag(tag string) (string, bool) { + tag = strings.TrimSpace(tag) + if tag == "" { + return "", false + } + if tag[0] >= '0' && tag[0] <= '9' { + tag = "v" + tag + } + if !semver.IsValid(tag) { + return "", false + } + return tag, true +} + func secureEqual(a, b string) bool { ab := []byte(a) bb := []byte(b) diff --git a/internal/clawapi/principal_test.go b/internal/clawapi/principal_test.go index f3c6943..359af6c 100644 --- a/internal/clawapi/principal_test.go +++ b/internal/clawapi/principal_test.go @@ -144,6 +144,9 @@ func TestLoadStoreWithWarningsKeepsPrincipalWhenAllVerbsAreUnknown(t *testing.T) if len(warnings) != 2 { t.Fatalf("expected 2 warnings, got %v", warnings) } + if !reflect.DeepEqual(store.NormalizationWarnings, warnings) { + t.Fatalf("expected warnings to be retained on store, got %v want %v", store.NormalizationWarnings, warnings) + } if !strings.Contains(warnings[0], `ignoring unknown verb "schedule.pause"`) { t.Fatalf("expected unknown-verb warning, got %v", warnings) } @@ -160,6 +163,53 @@ func TestLoadStoreWithWarningsKeepsPrincipalWhenAllVerbsAreUnknown(t *testing.T) if principal.AllowsVerb(VerbFleetStatus) { t.Fatalf("did not expect inert principal to authorize fleet.status") } + if !reflect.DeepEqual(store.InertPrincipalNames(), []string{"future"}) { + t.Fatalf("expected inert principal summary, got %v", store.InertPrincipalNames()) + } +} + +func TestPrincipalVersionSkewWarningsWarnForOlderImage(t *testing.T) { + store := &Store{Principals: []Principal{{ + Name: "claw-scheduler", + Token: "capi_sched", + Verbs: []string{VerbScheduleRead, VerbScheduleControl}, + Pods: []string{"ops"}, + }}} + + warnings := PrincipalVersionSkewWarnings(store, "ghcr.io/mostlydev/claw-api:v0.4.2") + if len(warnings) != 2 { + t.Fatalf("expected 2 warnings, got %v", warnings) + } + if !strings.Contains(warnings[0], `ghcr.io/mostlydev/claw-api:v0.4.2`) || !strings.Contains(warnings[0], `known minimum v0.6.0`) { + t.Fatalf("expected version warning, got %v", warnings) + } +} + +func TestPrincipalVersionSkewWarningsSkipSupportedImage(t *testing.T) { + store := &Store{Principals: []Principal{{ + Name: "claw-scheduler", + Token: "capi_sched", + Verbs: []string{VerbScheduleRead, VerbScheduleControl}, + Pods: []string{"ops"}, + }}} + + if warnings := PrincipalVersionSkewWarnings(store, "ghcr.io/mostlydev/claw-api:v0.6.0"); len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } +} + +func TestPrincipalVersionSkewWarningsWarnWhenImageTagUnknown(t *testing.T) { + store := &Store{Principals: []Principal{{ + Name: "claw-scheduler", + Token: "capi_sched", + Verbs: []string{VerbScheduleRead}, + Pods: []string{"ops"}, + }}} + + warnings := PrincipalVersionSkewWarnings(store, "ghcr.io/mostlydev/claw-api:latest") + if len(warnings) != 1 || !strings.Contains(warnings[0], `is not version-pinned`) { + t.Fatalf("expected uncertainty warning, got %v", warnings) + } } func TestPrincipalPodScopeGrantsComposeServiceAccess(t *testing.T) {