diff --git a/cmd/claw-api/main.go b/cmd/claw-api/main.go index e77846d..70bf3b0 100644 --- a/cmd/claw-api/main.go +++ b/cmd/claw-api/main.go @@ -77,10 +77,13 @@ func run(args []string, stdout, stderr io.Writer) error { if err != nil { return err } - store, err := clawapi.LoadStore(cfg.PrincipalsPath) + store, warnings, err := clawapi.LoadStoreWithWarnings(cfg.PrincipalsPath) if err != nil { return err } + for _, warning := range warnings { + fmt.Fprintf(stderr, "claw-api warning: %s\n", warning) + } var scheduleState *scheduleStateStore if scheduleManifest != nil { scheduleState, err = newScheduleStateStore(cfg.GovernanceDir, scheduleManifest) diff --git a/internal/clawapi/principal.go b/internal/clawapi/principal.go index 60aaf37..fb866f2 100644 --- a/internal/clawapi/principal.go +++ b/internal/clawapi/principal.go @@ -43,18 +43,24 @@ type Principal struct { } func LoadStore(filePath string) (*Store, error) { + store, _, err := LoadStoreWithWarnings(filePath) + return store, err +} + +func LoadStoreWithWarnings(filePath string) (*Store, []string, error) { raw, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("read claw-api principals %q: %w", filePath, err) + return nil, nil, fmt.Errorf("read claw-api principals %q: %w", filePath, err) } var store Store if err := json.Unmarshal(raw, &store); err != nil { - return nil, fmt.Errorf("parse claw-api principals %q: %w", filePath, err) + return nil, nil, fmt.Errorf("parse claw-api principals %q: %w", filePath, err) } - if err := validateStore(&store); err != nil { - return nil, fmt.Errorf("validate claw-api principals %q: %w", filePath, err) + warnings, err := normalizeStore(&store) + if err != nil { + return nil, nil, fmt.Errorf("validate claw-api principals %q: %w", filePath, err) } - return &store, nil + return &store, warnings, nil } func (s *Store) ResolveBearer(header string) (*Principal, error) { @@ -199,7 +205,25 @@ func matchesAny(patterns []string, value string) bool { return false } -func validateStore(store *Store) error { +func normalizeStore(store *Store) ([]string, error) { + if err := validateStoreStructure(store); err != nil { + return nil, err + } + var warnings []string + for i := range store.Principals { + filtered, unknown := filterKnownVerbs(store.Principals[i].Verbs) + for _, verb := range unknown { + warnings = append(warnings, fmt.Sprintf("ignoring unknown verb %q for principal %q", verb, store.Principals[i].Name)) + } + if len(filtered) == 0 && len(store.Principals[i].Verbs) > 0 { + warnings = append(warnings, fmt.Sprintf("principal %q has no recognized verbs; token will authorize nothing", store.Principals[i].Name)) + } + store.Principals[i].Verbs = filtered + } + return warnings, nil +} + +func validateStoreStructure(store *Store) error { if store == nil { return fmt.Errorf("store is nil") } @@ -222,28 +246,34 @@ func validateStore(store *Store) error { if err := validatePatterns("compose_services", principal.ComposeServices); err != nil { return fmt.Errorf("principal %q: %w", principal.Name, err) } - if err := validateVerbs(principal.Verbs); err != nil { - return fmt.Errorf("principal %q: %w", principal.Name, err) - } } return nil } -func validateVerbs(verbs []string) error { +func filterKnownVerbs(verbs []string) ([]string, []string) { + filtered := make([]string, 0, len(verbs)) + var unknown []string for _, verb := range verbs { verb = strings.TrimSpace(verb) - known := false - for _, v := range AllVerbs { - if v == verb { - known = true - break - } + if verb == "" { + continue } - if !known { - return fmt.Errorf("unknown verb %q", verb) + if isKnownVerb(verb) { + filtered = append(filtered, verb) + continue } + unknown = append(unknown, verb) } - return nil + return filtered, unknown +} + +func isKnownVerb(verb string) bool { + for _, v := range AllVerbs { + if v == verb { + return true + } + } + return false } func validatePatterns(label string, patterns []string) error { diff --git a/internal/clawapi/principal_test.go b/internal/clawapi/principal_test.go index 1b1b7dc..f3c6943 100644 --- a/internal/clawapi/principal_test.go +++ b/internal/clawapi/principal_test.go @@ -1,6 +1,9 @@ package clawapi import ( + "os" + "path/filepath" + "reflect" "strings" "testing" ) @@ -61,14 +64,8 @@ func TestBuildMasterPrincipalHasAllVerbsAndOpaqueToken(t *testing.T) { } func TestLoadStoreRejectsInvalidGlobPattern(t *testing.T) { - store := &Store{ - Principals: []Principal{{ - Name: "octopus", - Token: "capi_deadbeef", - Services: []string{"[bad"}, - }}, - } - if err := validateStore(store); err == nil { + path := writeStoreFixture(t, `{"principals":[{"name":"octopus","token":"capi_deadbeef","services":["[bad"]}]}`) + if _, err := LoadStore(path); err == nil { t.Fatal("expected invalid pattern error") } } @@ -99,29 +96,69 @@ func TestPrincipalComposeServiceScope(t *testing.T) { } } -func TestValidateStoreRejectsUnknownVerb(t *testing.T) { - store := &Store{ - Principals: []Principal{{ - Name: "bad", - Token: "capi_x", - Verbs: []string{"fleet.explode"}, - }}, +func TestLoadStoreFiltersUnknownVerbs(t *testing.T) { + path := writeStoreFixture(t, `{"principals":[{"name":"future","token":"capi_x","verbs":["fleet.status","schedule.pause","fleet.logs"]}]}`) + store, err := LoadStore(path) + if err != nil { + t.Fatalf("LoadStore: %v", err) + } + if len(store.Principals) != 1 { + t.Fatalf("expected one principal, got %d", len(store.Principals)) } - if err := validateStore(store); err == nil { - t.Fatal("expected unknown verb error") + wantVerbs := []string{VerbFleetStatus, VerbFleetLogs} + if !reflect.DeepEqual(store.Principals[0].Verbs, wantVerbs) { + t.Fatalf("verbs=%v want %v", store.Principals[0].Verbs, wantVerbs) } } -func TestValidateStoreAcceptsAllKnownVerbs(t *testing.T) { - store := &Store{ - Principals: []Principal{{ - Name: "full", - Token: "capi_x", - Verbs: AllVerbs, - }}, +func TestLoadStoreWithWarningsReportsUnknownVerbs(t *testing.T) { + path := writeStoreFixture(t, `{"principals":[{"name":"future","token":"capi_x","verbs":["fleet.status","schedule.pause","fleet.logs"]}]}`) + store, warnings, err := LoadStoreWithWarnings(path) + if err != nil { + t.Fatalf("LoadStoreWithWarnings: %v", err) + } + if len(store.Principals) != 1 { + t.Fatalf("expected one principal, got %d", len(store.Principals)) + } + wantVerbs := []string{VerbFleetStatus, VerbFleetLogs} + if !reflect.DeepEqual(store.Principals[0].Verbs, wantVerbs) { + t.Fatalf("verbs=%v want %v", store.Principals[0].Verbs, wantVerbs) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], `ignoring unknown verb "schedule.pause"`) { + t.Fatalf("expected unknown-verb warning, got %v", warnings) + } +} + +func TestLoadStoreWithWarningsKeepsPrincipalWhenAllVerbsAreUnknown(t *testing.T) { + path := writeStoreFixture(t, `{"principals":[{"name":"future","token":"capi_x","verbs":["schedule.pause"]}]}`) + store, warnings, err := LoadStoreWithWarnings(path) + if err != nil { + t.Fatalf("LoadStoreWithWarnings: %v", err) + } + if len(store.Principals) != 1 { + t.Fatalf("expected one principal, got %d", len(store.Principals)) } - if err := validateStore(store); err != nil { - t.Fatalf("expected all known verbs to be valid: %v", err) + if len(store.Principals[0].Verbs) != 0 { + t.Fatalf("expected unknown verbs to be dropped, got %v", store.Principals[0].Verbs) + } + if len(warnings) != 2 { + t.Fatalf("expected 2 warnings, got %v", warnings) + } + if !strings.Contains(warnings[0], `ignoring unknown verb "schedule.pause"`) { + t.Fatalf("expected unknown-verb warning, got %v", warnings) + } + if !strings.Contains(warnings[1], `has no recognized verbs`) { + t.Fatalf("expected inert-principal warning, got %v", warnings) + } + principal, err := store.ResolveBearer("Bearer capi_x") + if err != nil { + t.Fatalf("ResolveBearer: %v", err) + } + if principal.Name != "future" { + t.Fatalf("unexpected principal: %+v", principal) + } + if principal.AllowsVerb(VerbFleetStatus) { + t.Fatalf("did not expect inert principal to authorize fleet.status") } } @@ -196,3 +233,12 @@ func TestBuildSchedulerPrincipalIsScheduleScoped(t *testing.T) { t.Fatalf("expected pod scope, got %+v", p) } } + +func writeStoreFixture(t *testing.T, raw string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "principals.json") + if err := os.WriteFile(path, []byte(raw), 0o644); err != nil { + t.Fatalf("write store fixture: %v", err) + } + return path +}