// Command flex-auth is the CLI entry point for the flex-auth authorization // registry and control plane. package main import ( "context" "encoding/json" "flag" "fmt" "io" "net/http" "os" "strings" "gopkg.in/yaml.v3" "github.com/netkingdom/flex-auth/internal/audit" decisioncore "github.com/netkingdom/flex-auth/internal/decision" "github.com/netkingdom/flex-auth/internal/policy" "github.com/netkingdom/flex-auth/internal/registry" "github.com/netkingdom/flex-auth/pkg/api" ) // version is set at build time via -ldflags "-X main.version=...". var version = "0.0.0-dev" func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { printUsage(stderr) return 64 } switch args[0] { case "version", "--version", "-version": fmt.Fprintln(stdout, version) return 0 case "validate": return runValidate(args[1:], stdout, stderr) case "load-registry": return runLoadRegistry(args[1:], stdout, stderr) case "test-policy": return runTestPolicy(args[1:], stdout, stderr) case "check": return runCheck(args[1:], stdout, stderr) case "batch-check": return runBatchCheck(args[1:], stdout, stderr) case "list-allowed": return runListAllowed(args[1:], stdout, stderr) case "explain": return runExplain(args[1:], stdout, stderr) case "serve": return runServe(args[1:], stdout, stderr) case "help", "-h", "--help": printUsage(stdout) return 0 default: fmt.Fprintf(stderr, "flex-auth: unknown subcommand %q\n", args[0]) printUsage(stderr) return 64 } } func runValidate(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("validate", stderr) kind := fs.String("kind", "", "resource-manifest, subject-manifest, protected-system, relationship, access-descriptor, or policy") file := fs.String("file", "", "YAML/JSON file to validate") if err := fs.Parse(args); err != nil { return 64 } if *kind == "" || *file == "" { fmt.Fprintln(stderr, "validate requires --kind and --file") return 64 } switch *kind { case "resource-manifest": var manifest api.ResourceManifest if err := readYAML(*file, &manifest); err != nil { return fail(stderr, err) } if err := registry.NewStore().ImportResourceManifest(manifest); err != nil { return fail(stderr, err) } case "subject-manifest": var manifest api.SubjectManifest if err := readYAML(*file, &manifest); err != nil { return fail(stderr, err) } if err := registry.NewStore().ImportSubjectManifest(manifest); err != nil { return fail(stderr, err) } case "protected-system": var manifest api.ProtectedSystemManifest if err := readYAML(*file, &manifest); err != nil { return fail(stderr, err) } if err := registry.NewStore().PutProtectedSystem(manifest); err != nil { return fail(stderr, err) } case "relationship": var fact api.RelationshipFact if err := readYAML(*file, &fact); err != nil { return fail(stderr, err) } if err := registry.NewStore().PutRelationship(fact); err != nil { return fail(stderr, err) } case "access-descriptor": var descriptor api.CaringAccessDescriptor if err := readYAML(*file, &descriptor); err != nil { return fail(stderr, err) } if err := validateDescriptor(descriptor); err != nil { return fail(stderr, err) } case "policy", "policy-package": pkg, err := policy.LoadAndValidateFile(context.Background(), *file) if err != nil { return fail(stderr, err) } if err := writeJSON(stdout, pkg.Validation); err != nil { return fail(stderr, err) } if !pkg.Valid { return 1 } return 0 default: fmt.Fprintf(stderr, "unsupported validate kind %q\n", *kind) return 64 } return writeStatus(stdout, "valid", map[string]any{"kind": *kind, "file": *file}) } func runLoadRegistry(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("load-registry", stderr) file := fs.String("file", "", "registry snapshot JSON file") if err := fs.Parse(args); err != nil { return 64 } if *file == "" { fmt.Fprintln(stderr, "load-registry requires --file") return 64 } store, err := registry.LoadFile(*file) if err != nil { return fail(stderr, err) } snapshot := store.Snapshot() return writeStatus(stdout, "loaded", map[string]any{ "file": *file, "systems": len(snapshot.Systems), "resource_manifests": len(snapshot.ResourceManifests), "subjects": len(snapshot.Subjects), "groups": len(snapshot.Groups), "teams": len(snapshot.Teams), "tenants": len(snapshot.Tenants), "relationships": len(snapshot.Relationships), }) } func runTestPolicy(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("test-policy", stderr) file := fs.String("file", "", "policy package Markdown file") if err := fs.Parse(args); err != nil { return 64 } if *file == "" { fmt.Fprintln(stderr, "test-policy requires --file") return 64 } pkg, err := policy.LoadAndValidateFile(context.Background(), *file) if err != nil { return fail(stderr, err) } if err := writeJSON(stdout, pkg.Validation); err != nil { return fail(stderr, err) } if !pkg.Valid { return 1 } return 0 } func runCheck(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("check", stderr) registryPath := fs.String("registry", "", "registry snapshot JSON file") policyPath := fs.String("policy", "", "policy package Markdown file") requestPath := fs.String("request", "", "check request YAML/JSON file") logPath := fs.String("log", "", "optional JSONL decision log path") if err := fs.Parse(args); err != nil { return 64 } if *registryPath == "" || *policyPath == "" || *requestPath == "" { fmt.Fprintln(stderr, "check requires --registry, --policy, and --request") return 64 } engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath) if err != nil { return fail(stderr, err) } var request api.CheckRequest if err := readYAML(*requestPath, &request); err != nil { return fail(stderr, err) } decision, err := engine.Check(context.Background(), request) if err != nil { return fail(stderr, err) } if err := writeJSON(stdout, decision); err != nil { return fail(stderr, err) } return 0 } func runBatchCheck(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("batch-check", stderr) registryPath := fs.String("registry", "", "registry snapshot JSON file") policyPath := fs.String("policy", "", "policy package Markdown file") requestPath := fs.String("request", "", "batch check request YAML/JSON file") logPath := fs.String("log", "", "optional JSONL decision log path") if err := fs.Parse(args); err != nil { return 64 } if *registryPath == "" || *policyPath == "" || *requestPath == "" { fmt.Fprintln(stderr, "batch-check requires --registry, --policy, and --request") return 64 } engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath) if err != nil { return fail(stderr, err) } var request api.BatchCheckRequest if err := readYAML(*requestPath, &request); err != nil { return fail(stderr, err) } decisions, err := engine.BatchCheck(context.Background(), request) if err != nil { return fail(stderr, err) } if err := writeJSON(stdout, decisions); err != nil { return fail(stderr, err) } return 0 } func runListAllowed(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("list-allowed", stderr) registryPath := fs.String("registry", "", "registry snapshot JSON file") policyPath := fs.String("policy", "", "policy package Markdown file") subject := fs.String("subject", "", "subject id") action := fs.String("action", "", "action name") system := fs.String("system", "", "protected system id") resourceType := fs.String("resource-type", "", "resource type") logPath := fs.String("log", "", "optional JSONL decision log path") var filters keyValueFlags fs.Var(&filters, "filter", "resource filter as key=value; may be repeated") if err := fs.Parse(args); err != nil { return 64 } if *registryPath == "" || *policyPath == "" || *subject == "" || *action == "" { fmt.Fprintln(stderr, "list-allowed requires --registry, --policy, --subject, and --action") return 64 } engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath) if err != nil { return fail(stderr, err) } decisions, err := engine.ListAllowed(context.Background(), decisioncore.ListAllowedRequest{ Subject: api.SubjectRef{ID: *subject}, Action: *action, System: *system, ResourceType: *resourceType, Filters: filters.Map(), }) if err != nil { return fail(stderr, err) } if err := writeJSON(stdout, decisions); err != nil { return fail(stderr, err) } return 0 } func runExplain(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("explain", stderr) logPath := fs.String("decision-log", "", "JSONL decision log path") decisionID := fs.String("decision-id", "", "decision id to explain") if err := fs.Parse(args); err != nil { return 64 } if *logPath == "" || *decisionID == "" { fmt.Fprintln(stderr, "explain requires --decision-log and --decision-id") return 64 } log := audit.NewJSONLDecisionLog(*logPath) envelope, ok, err := log.Find(*decisionID) if err != nil { return fail(stderr, err) } if !ok { return fail(stderr, fmt.Errorf("decision %q not found", *decisionID)) } if err := writeJSON(stdout, decisioncore.ExplainEnvelope(envelope)); err != nil { return fail(stderr, err) } return 0 } func runServe(args []string, stdout, stderr io.Writer) int { fs := newFlagSet("serve", stderr) addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address") registryPath := fs.String("registry", "", "registry snapshot JSON file") policyPath := fs.String("policy", "", "policy package Markdown file") logPath := fs.String("log", "", "optional JSONL decision log path") if err := fs.Parse(args); err != nil { return 64 } if *registryPath == "" || *policyPath == "" { fmt.Fprintln(stderr, "serve requires --registry and --policy") return 64 } engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath) if err != nil { return fail(stderr, err) } mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var request api.CheckRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } decision, err := engine.Check(r.Context(), request) writeHTTP(w, decision, err) }) mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var request api.BatchCheckRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } decisions, err := engine.BatchCheck(r.Context(), request) writeHTTP(w, decisions, err) }) fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr) if err := http.ListenAndServe(*addr, mux); err != nil { return fail(stderr, err) } return 0 } func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) { store, err := registry.LoadFile(registryPath) if err != nil { return nil, err } pkg, err := policy.LoadAndValidateFile(ctx, policyPath) if err != nil { return nil, err } engine, err := decisioncore.NewEngine(store, pkg) if err != nil { return nil, err } if logPath != "" { engine.SetDecisionLog(audit.NewJSONLDecisionLog(logPath)) } return engine, nil } func validateDescriptor(descriptor api.CaringAccessDescriptor) error { if descriptor.Profile != api.CaringProfileCaring040RC2 { return fmt.Errorf("unsupported CARING profile %q", descriptor.Profile) } if descriptor.SubjectType == "" { return fmt.Errorf("subject_type is required") } if descriptor.OrganizationRelation == "" { return fmt.Errorf("organization_relation is required") } if descriptor.CanonicalRole == "" { return fmt.Errorf("canonical_role is required") } if descriptor.Scope.Level == "" || descriptor.Scope.ID == "" { return fmt.Errorf("scope.level and scope.id are required") } if len(descriptor.Planes) == 0 { return fmt.Errorf("at least one plane is required") } if len(descriptor.Capabilities) == 0 { return fmt.Errorf("at least one capability is required") } return nil } func readYAML(path string, out any) error { data, err := os.ReadFile(path) if err != nil { return err } if err := yaml.Unmarshal(data, out); err != nil { return fmt.Errorf("unmarshal %s: %w", path, err) } return nil } func writeJSON(w io.Writer, value any) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") if err := encoder.Encode(value); err != nil { return err } return nil } func writeStatus(w io.Writer, status string, extra map[string]any) int { out := map[string]any{"status": status} for key, value := range extra { out[key] = value } if err := writeJSON(w, out); err != nil { return fail(w, err) } return 0 } func writeHTTP(w http.ResponseWriter, value any, err error) { w.Header().Set("content-type", "application/json") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(value) } func newFlagSet(name string, stderr io.Writer) *flag.FlagSet { fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(stderr) return fs } func fail(stderr io.Writer, err error) int { fmt.Fprintln(stderr, "flex-auth:", err) return 1 } func printUsage(w io.Writer) { fmt.Fprintln(w, "usage: flex-auth [options]") fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve") } type keyValueFlags []string func (f *keyValueFlags) String() string { return strings.Join(*f, ",") } func (f *keyValueFlags) Set(value string) error { if !strings.Contains(value, "=") { return fmt.Errorf("filter must be key=value") } *f = append(*f, value) return nil } func (f keyValueFlags) Map() map[string]any { out := make(map[string]any, len(f)) for _, item := range f { key, value, _ := strings.Cut(item, "=") out[key] = value } return out }