Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cmd/claw-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 49 additions & 19 deletions internal/clawapi/principal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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 {
Expand Down
98 changes: 72 additions & 26 deletions internal/clawapi/principal_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package clawapi

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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
}
Loading