diff --git a/cmd/flex-auth/main.go b/cmd/flex-auth/main.go index 02cd71a..e9ffb49 100644 --- a/cmd/flex-auth/main.go +++ b/cmd/flex-auth/main.go @@ -1,30 +1,500 @@ // Command flex-auth is the CLI entry point for the flex-auth authorization // registry and control plane. -// -// At skeleton stage this binary only reports its version. Subcommands -// (validate, load, test, check, batch-check, explain) are added in -// FLEX-WP-0002. 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=…". +// version is set at build time via -ldflags "-X main.version=...". var version = "0.0.0-dev" func main() { - showVersion := flag.Bool("version", false, "print version and exit") - flag.Parse() + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} - if *showVersion || (flag.NArg() > 0 && flag.Arg(0) == "version") { - fmt.Println(version) - return +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 64 } - fmt.Fprintln(os.Stderr, "flex-auth: no subcommand yet (skeleton stage — see workplans/FLEX-WP-0002).") - fmt.Fprintln(os.Stderr, "Try: flex-auth --version") - os.Exit(64) // EX_USAGE + 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 } diff --git a/cmd/flex-auth/main_test.go b/cmd/flex-auth/main_test.go index 83bc3e3..73f4aa0 100644 --- a/cmd/flex-auth/main_test.go +++ b/cmd/flex-auth/main_test.go @@ -1,9 +1,90 @@ package main -import "testing" +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" -func TestVersionDefault(t *testing.T) { - if version == "" { - t.Fatal("version must not be empty") + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestRunVersion(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"version"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + if strings.TrimSpace(stdout.String()) == "" { + t.Fatal("version output is empty") } } + +func TestRunTestPolicy(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"test-policy", "--file", examplePath("policy_package.md")}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"valid": true`) { + t.Fatalf("stdout = %s; want valid policy result", stdout.String()) + } +} + +func TestRunCheck(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{ + "check", + "--registry", examplePath("registry_snapshot.json"), + "--policy", examplePath("policy_package.md"), + "--request", examplePath("check_request.yaml"), + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + + var decision api.DecisionEnvelope + if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil { + t.Fatalf("unmarshal decision: %v\n%s", err, stdout.String()) + } + if decision.Effect != api.DecisionEffectAllow { + t.Fatalf("decision.Effect = %q; want allow", decision.Effect) + } +} + +func TestRunBatchCheck(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{ + "batch-check", + "--registry", examplePath("registry_snapshot.json"), + "--policy", examplePath("policy_package.md"), + "--request", examplePath("batch_check_request.yaml"), + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + + var decisions []api.DecisionEnvelope + if err := json.Unmarshal(stdout.Bytes(), &decisions); err != nil { + t.Fatalf("unmarshal decisions: %v\n%s", err, stdout.String()) + } + if len(decisions) != 2 || decisions[0].Effect != api.DecisionEffectAllow || decisions[1].Effect != api.DecisionEffectDeny { + t.Fatalf("decisions = %+v; want allow then deny", decisions) + } +} + +func TestRunValidateAccessDescriptor(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"validate", "--kind", "access-descriptor", "--file", examplePath("access_descriptor.yaml")}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"status": "valid"`) { + t.Fatalf("stdout = %s; want valid status", stdout.String()) + } +} + +func examplePath(name string) string { + return filepath.Join("..", "..", "examples", "caring", name) +} diff --git a/examples/caring/batch_check_request.yaml b/examples/caring/batch_check_request.yaml new file mode 100644 index 0000000..8f3b2e5 --- /dev/null +++ b/examples/caring/batch_check_request.yaml @@ -0,0 +1,12 @@ +id: batch:tenant-alpha-documents +subject: + id: user:alice + type: Human + tenant: tenant:alpha +action: read +resources: + - id: document:internal-note + system: markitect-tool + - id: document:missing + type: document + system: markitect-tool diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 6698b6f..1a82865 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -154,6 +154,12 @@ func (e *Engine) Explain(decisionID string) (Explanation, error) { return Explanation{}, fmt.Errorf("decision %q not found", decisionID) } + return ExplainEnvelope(decision), nil +} + +// ExplainEnvelope returns the same explanation shape for an already-loaded +// decision envelope. +func ExplainEnvelope(decision api.DecisionEnvelope) Explanation { return Explanation{ DecisionID: decision.ID, Effect: decision.Effect, @@ -166,7 +172,7 @@ func (e *Engine) Explain(decisionID string) (Explanation, error) { MatchedRule: decision.MatchedRule, Diagnostics: decision.Diagnostics, Caring: decision.Caring, - }, nil + } } type registryFacts struct { diff --git a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md index 6261ad8..35af8f8 100644 --- a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md +++ b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md @@ -209,7 +209,7 @@ induced access, and privilege-escalation findings. ```task id: FLEX-WP-0002-T007 -status: todo +status: done priority: high state_hub_task_id: "ee9ae6dd-c31f-4d4e-b238-533a2b8040d4" ```