From 0ced2995ee3b8df9854667210a36e78fa23e6eb5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 10 Oct 2026 13:26:19 +0200 Subject: [PATCH] FLEX-WP-0036: binary renamed to access-engine; flex-auth is a stub naming the rename (FLEX-DEC-2026-020) - cmd/flex-auth -> cmd/access-engine; usage and serving lines say access-engine - cmd/flex-auth: prints the rename and exits 2, with a test - image: entrypoint /usr/local/bin/access-engine, flex-auth stub alongside; live pins pass only args, so their next roll is unaffected - Makefile, CI, verify-posture, tenancy.yaml, live docs updated; workplans, decisions and ADRs left as written - runtime names (module, audience, k8s, image package, /opt paths) unchanged Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 735967@bnt-lap001 Assistant-Session: 5971967f-77f8-4ced-9e02-c6ef67040ffc --- .github/workflows/ci.yml | 2 +- Containerfile | 6 +- Makefile | 4 +- SCOPE.md | 2 +- cmd/access-engine/main.go | 671 ++++++++++++++++++ cmd/access-engine/main_test.go | 465 ++++++++++++ .../ops_warden_fetch_test.go | 0 cmd/{flex-auth => access-engine}/readback.go | 2 +- .../readback_pin_test.go | 0 .../readback_test.go | 0 .../readback/demo-app/policy_fixtures.yaml | 0 .../readback/demo-app/policy_package.md | 0 .../readback/demo-app/registry_snapshot.json | 0 .../readback/other-app/policy_fixtures.yaml | 0 .../readback/other-app/policy_package.md | 0 .../readback/other-app/registry_snapshot.json | 0 cmd/flex-auth/main.go | 670 +---------------- cmd/flex-auth/main_test.go | 469 +----------- decisions/decisions.md | 31 + docs/application-access-readback.md | 4 +- docs/decision-input-freshness.md | 2 +- docs/delegated-mode-operations.md | 2 +- docs/ops-warden-policy-gate-handoff.md | 4 +- docs/ops-warden-registry-sync.md | 4 +- docs/request-enrichment.md | 4 +- examples/ops-warden-fetch/README.md | 8 +- examples/ops-warden/README.md | 18 +- examples/qonto-assistant/README.md | 16 +- examples/railiance-platform/README.md | 10 +- examples/secrets-engine/README.md | 4 +- examples/secrets-engine/replay/README.md | 2 +- examples/tenant-engine/README.md | 20 +- examples/user-engine/README.md | 4 +- tenancy.yaml | 2 +- tools/verify-posture.sh | 2 +- ...EX-WP-0036-runtime-rename-access-engine.md | 18 +- 36 files changed, 1268 insertions(+), 1178 deletions(-) create mode 100644 cmd/access-engine/main.go create mode 100644 cmd/access-engine/main_test.go rename cmd/{flex-auth => access-engine}/ops_warden_fetch_test.go (100%) rename cmd/{flex-auth => access-engine}/readback.go (98%) rename cmd/{flex-auth => access-engine}/readback_pin_test.go (100%) rename cmd/{flex-auth => access-engine}/readback_test.go (100%) rename cmd/{flex-auth => access-engine}/testdata/readback/demo-app/policy_fixtures.yaml (100%) rename cmd/{flex-auth => access-engine}/testdata/readback/demo-app/policy_package.md (100%) rename cmd/{flex-auth => access-engine}/testdata/readback/demo-app/registry_snapshot.json (100%) rename cmd/{flex-auth => access-engine}/testdata/readback/other-app/policy_fixtures.yaml (100%) rename cmd/{flex-auth => access-engine}/testdata/readback/other-app/policy_package.md (100%) rename cmd/{flex-auth => access-engine}/testdata/readback/other-app/registry_snapshot.json (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82a7264..ccaddc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: Build run: | mkdir -p bin - go build -o bin/flex-auth ./cmd/flex-auth + go build -o bin/access-engine ./cmd/access-engine && go build -o bin/flex-auth ./cmd/flex-auth lint: name: Lint diff --git a/Containerfile b/Containerfile index 2050c6a..8c36542 100644 --- a/Containerfile +++ b/Containerfile @@ -3,11 +3,13 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/flex-auth ./cmd/flex-auth +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/access-engine ./cmd/access-engine && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/flex-auth ./cmd/flex-auth FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab +COPY --from=build /out/access-engine /usr/local/bin/access-engine +# Retired name: prints the rename and exits 2 (FLEX-DEC-2026-020). COPY --from=build /out/flex-auth /usr/local/bin/flex-auth COPY examples /opt/flex-auth/examples EXPOSE 8080 -ENTRYPOINT ["/usr/local/bin/flex-auth"] +ENTRYPOINT ["/usr/local/bin/access-engine"] CMD ["serve", "--addr", "0.0.0.0:8080", "--registry", "/opt/flex-auth/examples/tenant-engine/registry_snapshot.json", "--policy", "/opt/flex-auth/examples/tenant-engine/policy_package.md"] diff --git a/Makefile b/Makefile index 26ae34f..7648266 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BIN_DIR ?= bin -BIN := $(BIN_DIR)/flex-auth +BIN := $(BIN_DIR)/access-engine PKG := ./... VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev) LDFLAGS := -X main.version=$(VERSION) @@ -10,7 +10,7 @@ all: vet lint test build build: @mkdir -p $(BIN_DIR) - go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/flex-auth + go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/access-engine test: check-layer go test -race $(PKG) diff --git a/SCOPE.md b/SCOPE.md index e6e4468..38c95be 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -99,7 +99,7 @@ can be coordinated behind a stable flex-auth API. The standalone core is implemented and **all seven baseline workplans (`FLEX-WP-0001` through `FLEX-WP-0007`) are complete.** The repository carries the intent baseline, authorization landscape research, ADR set, and a working -Go service (`cmd/flex-auth`) with `validate`, `load-registry`, `serve`, and +Go service (`cmd/access-engine`) with `validate`, `load-registry`, `serve`, and `POST /v1/check` plus registry, policy, decision, audit, Markitect, and delegated-adapter internals. The standalone policy-as-code core (`FLEX-WP-0002`), Markitect consumer integration (`FLEX-WP-0003`, manifest ingest, decisions, and diff --git a/cmd/access-engine/main.go b/cmd/access-engine/main.go new file mode 100644 index 0000000..1b18906 --- /dev/null +++ b/cmd/access-engine/main.go @@ -0,0 +1,671 @@ +// Command flex-auth is the CLI entry point for the flex-auth authorization +// registry and control plane. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/netkingdom/flex-auth/internal/audit" + "github.com/netkingdom/flex-auth/internal/callerauth" + decisioncore "github.com/netkingdom/flex-auth/internal/decision" + "github.com/netkingdom/flex-auth/internal/emission" + "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 "serve-readback": + return runServeReadback(args[1:], stdout, stderr) + case "help", "-h", "--help": + printUsage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "access-engine: 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") + outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log") + emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine") + auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats") + auditTokenFile := fs.String("audit-token-file", "", "mounted sender token file, never a token value") + callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce") + callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience") + callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview") + callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential") + callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle") + var callerBindings keyValueFlags + fs.Var(&callerBindings, "caller-binding", "resource system=authenticated Kubernetes principal (repeatable)") + if err := fs.Parse(args); err != nil { + return 64 + } + if *registryPath == "" || *policyPath == "" { + fmt.Fprintln(stderr, "serve requires --registry and --policy") + return 64 + } + + if *outboxDir != "" && (*logPath != "" || *emissionSource == "") { + fmt.Fprintln(stderr, "serve --outbox-dir requires --emission-source and excludes --log") + return 64 + } + delivery := emission.DeliveryConfig{URL: *auditURL, TokenFile: *auditTokenFile} + if *auditURL != "" || *auditTokenFile != "" { + if *outboxDir == "" { + fmt.Fprintln(stderr, "audit delivery requires --outbox-dir") + return 64 + } + if err := delivery.Validate(); err != nil { + fmt.Fprintln(stderr, err) + return 64 + } + } + + engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath) + if err != nil { + return fail(stderr, err) + } + var outbox *emission.Outbox + if *outboxDir != "" { + outbox, err = emission.Open(*outboxDir, *emissionSource) + if err != nil { + return fail(stderr, err) + } + defer outbox.Close() + engine.SetDecisionLog(outbox) + } + + authenticator, err := buildCallerAuthenticator( + callerauth.Mode(*callerAuthMode), + *callerAudience, + *callerKubernetesURL, + *callerReviewerTokenFile, + *callerCAFile, + callerBindings.StringMap(), + stderr, + ) + if err != nil { + return fail(stderr, err) + } + + mux := newServeMuxWithCallerAuth(engine, authenticator) + if outbox != nil { + mountEmission(mux, outbox) + } + if *auditURL != "" { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + runEmissionDelivery(ctx, outbox, delivery, stderr) + }() + defer func() { cancel(); <-done }() + } + + fmt.Fprintf(stderr, "access-engine serving on http://%s\n", *addr) + if err := http.ListenAndServe(*addr, mux); err != nil { + return fail(stderr, err) + } + return 0 +} + +func runEmissionDelivery(ctx context.Context, outbox *emission.Outbox, config emission.DeliveryConfig, stderr io.Writer) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return + } + if err := outbox.AppendDueHeartbeats(); err != nil { + fmt.Fprintf(stderr, "emission heartbeat commit: %v\n", err) + } + n, err := outbox.Drain(ctx, config, 100) + if err != nil && ctx.Err() == nil { + fmt.Fprintf(stderr, "emission drain: %v\n", err) + } + if err == nil && n == 100 { + continue // Drain the next bounded batch without imposing a rate cap. + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func newServeMux(engine *decisioncore.Engine) *http.ServeMux { + return newServeMuxWithCallerAuth(engine, callerauth.Disabled()) +} + +func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *callerauth.Authenticator) *http.ServeMux { + 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 + } + if request.Purpose != "" { + // Only the readback pin issues purpose-bearing decisions + // (FLEX-DEC-2026-019); an enforcement pin never does. + http.Error(w, "purpose is not accepted on /v1/check", http.StatusBadRequest) + return + } + identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System}) + if err != nil { + writeCallerAuthError(w, err) + return + } + decision, err := engine.Check(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), 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 + } + systems := make([]string, 0, len(request.Resources)) + for _, resource := range request.Resources { + systems = append(systems, resource.System) + } + identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems) + if err != nil { + writeCallerAuthError(w, err) + return + } + decisions, err := engine.BatchCheck(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request) + writeHTTP(w, decisions, err) + }) + return mux +} + +func buildCallerAuthenticator(mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string, stderr io.Writer) (*callerauth.Authenticator, error) { + if mode == callerauth.ModeDisabled { + return callerauth.New(mode, nil, "", nil, nil) + } + reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile) + if err != nil { + return nil, err + } + return callerauth.New(mode, reviewer, audience, bindings, func(format string, args ...any) { + fmt.Fprintf(stderr, format+"\n", args...) + }) +} + +func writeCallerAuthError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, callerauth.ErrUnauthenticated): + http.Error(w, "unauthenticated", http.StatusUnauthorized) + case errors.Is(err, callerauth.ErrForbidden): + http.Error(w, "forbidden", http.StatusForbidden) + default: + http.Error(w, "caller authentication unavailable", http.StatusServiceUnavailable) + } +} + +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 +} + +// mountEmission exposes the local half of reconciliation: committed and +// released-uncommitted counts per class. Counts only, never records. +func mountEmission(mux *http.ServeMux, outbox *emission.Outbox) { + mux.HandleFunc("/v1/emission", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeHTTP(w, outbox.Status(), nil) + }) +} + +func writeHTTP(w http.ResponseWriter, value any, err error) { + w.Header().Set("content-type", "application/json") + if errors.Is(err, decisioncore.ErrRecordNotCommitted) { + // The consumer's declared stance applies (FLEX-DEC-2026-018). + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + 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, "access-engine:", err) + return 1 +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, "usage: access-engine [options]") + fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve, serve-readback") +} + +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 +} + +func (f keyValueFlags) StringMap() map[string]string { + out := make(map[string]string, len(f)) + for _, item := range f { + key, value, _ := strings.Cut(item, "=") + out[key] = value + } + return out +} diff --git a/cmd/access-engine/main_test.go b/cmd/access-engine/main_test.go new file mode 100644 index 0000000..1dbf5e4 --- /dev/null +++ b/cmd/access-engine/main_test.go @@ -0,0 +1,465 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/netkingdom/flex-auth/internal/callerauth" + "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 TestRunCheckOpsWarden(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{ + "check", + "--registry", opsPath("registry_snapshot.json"), + "--policy", opsPath("policy_package.md"), + "--request", opsPath("check_request_allow_adm.json"), + }, &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) + } + if decision.ID == "" { + t.Fatal("decision.ID is empty; ops-warden needs a policy_decision_id") + } +} + +func TestRunRailiancePlatformCredentialGrantContract(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"test-policy", "--file", railiancePlatformPath("policy_package.md")}, &stdout, &stderr) + if code != 0 || !strings.Contains(stdout.String(), `"valid": true`) { + t.Fatalf("test-policy code = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String()) + } + + stdout.Reset() + stderr.Reset() + code = run([]string{ + "check", + "--registry", railiancePlatformPath("registry_snapshot.json"), + "--policy", railiancePlatformPath("policy_package.md"), + "--request", railiancePlatformPath("check_request_allow.json"), + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("check code = %d, stderr = %s", code, stderr.String()) + } + var decision api.DecisionEnvelope + if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil { + t.Fatal(err) + } + if decision.Effect != api.DecisionEffectAllow || decision.Reason != "credential_grant_allowed" { + t.Fatalf("decision = %s/%s; want allow/credential_grant_allowed", decision.Effect, decision.Reason) + } + if decision.Binding == nil || decision.Binding.Context["requested_ttl_seconds"] != float64(900) { + t.Fatalf("binding = %+v; want normalized numeric TTL", decision.Binding) + } +} + +func TestServeOpsWardenCheckContract(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "decisions.jsonl") + engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), logPath) + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + server := httptest.NewServer(newServeMux(engine)) + defer server.Close() + + resp, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /healthz status = %d; want 200", resp.StatusCode) + } + + allow := postCheck(t, server.URL+"/v1/check", opsPath("check_request_allow_adm.json")) + if allow.Effect != api.DecisionEffectAllow || allow.ID == "" { + t.Fatalf("allow decision = %+v; want allow with id", allow) + } + + deny := postCheck(t, server.URL+"/v1/check", opsPath("check_request_deny_ttl_above_max.json")) + if deny.Effect != api.DecisionEffectDeny || deny.Reason != "ttl_out_of_bounds" { + t.Fatalf("deny decision = %+v; want ttl_out_of_bounds deny", deny) + } + + resp, err = http.Get(server.URL + "/v1/check") + if err != nil { + t.Fatalf("GET /v1/check: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("GET /v1/check status = %d; want 405", resp.StatusCode) + } + + resp, err = http.Post(server.URL+"/v1/check", "application/json", strings.NewReader(`{"subject":`)) + if err != nil { + t.Fatalf("POST malformed /v1/check: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("malformed POST status = %d; want 400", resp.StatusCode) + } + + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read decision log: %v", err) + } + if !strings.Contains(string(logData), allow.ID) || !strings.Contains(string(logData), deny.ID) { + t.Fatalf("decision log does not contain both decision ids\nlog: %s\nallow: %s deny: %s", string(logData), allow.ID, deny.ID) + } +} + +type fixedTokenReviewer struct { + identity callerauth.Identity +} + +func (r fixedTokenReviewer) Review(context.Context, string) (callerauth.Identity, error) { + return r.identity, nil +} + +func TestServeCallerAuthenticationBindsSystemToPrincipal(t *testing.T) { + engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "") + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ + Username: "system:serviceaccount:ops-warden:ops-warden", + Audiences: []string{"flex-auth"}, + }}, "flex-auth", map[string]string{ + "ops-warden": "system:serviceaccount:ops-warden:ops-warden", + }, nil) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator)) + defer server.Close() + body, err := os.ReadFile(opsPath("check_request_allow_adm.json")) + if err != nil { + t.Fatal(err) + } + + request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) + request.Header.Set("content-type", "application/json") + resp, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("missing token status = %d; want 401", resp.StatusCode) + } + + request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) + request.Header.Set("content-type", "application/json") + request.Header.Set("authorization", "Bearer workload-token") + resp, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + t.Fatalf("bound caller status = %d; want 200", resp.StatusCode) + } + var envelope api.DecisionEnvelope + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + resp.Body.Close() + t.Fatalf("decode decision: %v", err) + } + resp.Body.Close() + if envelope.Provenance.Caller == nil || envelope.Provenance.Caller.Mode != "enforce" { + t.Fatalf("caller = %+v; want enforce", envelope.Provenance.Caller) + } + if envelope.Provenance.Caller.Principal != "system:serviceaccount:ops-warden:ops-warden" { + t.Fatalf("principal = %q", envelope.Provenance.Caller.Principal) + } + if envelope.Provenance.Caller.Audience != "flex-auth" { + t.Fatalf("audience = %q", envelope.Provenance.Caller.Audience) + } + + wrong, _ := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ + Username: "system:serviceaccount:another:caller", + Audiences: []string{"flex-auth"}, + }}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil) + wrongServer := httptest.NewServer(newServeMuxWithCallerAuth(engine, wrong)) + defer wrongServer.Close() + request, _ = http.NewRequest(http.MethodPost, wrongServer.URL+"/v1/check", bytes.NewReader(body)) + request.Header.Set("content-type", "application/json") + request.Header.Set("authorization", "Bearer workload-token") + resp, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("mismatched caller status = %d; want 403", resp.StatusCode) + } +} + +func TestRunLoadRegistryOpsWardenProduction(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + + var result map[string]any + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("unmarshal load-registry output: %v; stdout = %s", err, stdout.String()) + } + if result["subjects"] != float64(4) || result["relationships"] != float64(4) || result["resource_manifests"] != float64(1) { + t.Fatalf("load-registry result = %+v; want production actor registry counts", result) + } +} + +func TestOpsWardenProductionRegistryActors(t *testing.T) { + engine, err := buildEngine(context.Background(), opsPath("production_registry_snapshot.json"), opsPath("policy_package.md"), "") + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + + cases := []struct { + name string + subjectID string + actor string + actorType string + principal string + ttlHours float64 + wantEffect api.DecisionEffect + wantReason string + }{ + { + name: "state hub bridge agent advisory while zone is unknown", + subjectID: "agt-state-hub-bridge", + actor: "agt-state-hub-bridge", + actorType: "agt", + principal: "agt-task-bridge", + ttlHours: 1, + wantEffect: api.DecisionEffectAuditOnly, + wantReason: "advisory_would_signing_policy_matched", + }, + { + name: "state hub bridge IAM subject advisory while zone is unknown", + subjectID: "iam:agt-state-hub-bridge", + actor: "agt-state-hub-bridge", + actorType: "agt", + principal: "agt-task-bridge", + ttlHours: 1, + wantEffect: api.DecisionEffectAuditOnly, + wantReason: "advisory_would_signing_policy_matched", + }, + { + name: "codex interhub bootstrap advisory while zone is unknown", + subjectID: "agt-codex-interhub-bootstrap", + actor: "agt-codex-interhub-bootstrap", + actorType: "agt", + principal: "agt-interhub-bootstrap", + ttlHours: 1, + wantEffect: api.DecisionEffectAuditOnly, + wantReason: "advisory_would_signing_policy_matched", + }, + { + name: "admin actor allow", + subjectID: "adm-example", + actor: "adm-example", + actorType: "adm", + principal: "adm-full", + ttlHours: 4, + wantEffect: api.DecisionEffectAllow, + }, + { + name: "automation actor advisory while zone is unknown", + subjectID: "atm-backup-daily", + actor: "atm-backup-daily", + actorType: "atm", + principal: "atm-backup-daily", + ttlHours: 1, + wantEffect: api.DecisionEffectAuditOnly, + wantReason: "advisory_would_signing_policy_matched", + }, + { + name: "ttl above production max is advisory while zone is unknown", + subjectID: "agt-state-hub-bridge", + actor: "agt-state-hub-bridge", + actorType: "agt", + principal: "agt-task-bridge", + ttlHours: 999, + wantEffect: api.DecisionEffectAuditOnly, + wantReason: "advisory_would_ttl_out_of_bounds", + }, + { + name: "unregistered production actor is advisory unknown", + subjectID: "agt-missing", + actor: "agt-missing", + actorType: "agt", + principal: "agt-missing", + ttlHours: 1, + wantEffect: api.DecisionEffectAuditOnly, + wantReason: "advisory_would_unknown_actor_resource", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + decision, err := engine.Check(context.Background(), opsWardenProductionSignRequest(tt.subjectID, tt.actor, tt.actorType, tt.principal, tt.ttlHours)) + if err != nil { + t.Fatalf("Check: %v", err) + } + if decision.Effect != tt.wantEffect { + t.Fatalf("decision.Effect = %q; want %q; decision: %+v", decision.Effect, tt.wantEffect, decision) + } + if tt.wantReason != "" && decision.Reason != tt.wantReason { + t.Fatalf("decision.Reason = %q; want %q; decision: %+v", decision.Reason, tt.wantReason, decision) + } + if (tt.wantEffect == api.DecisionEffectAllow || tt.wantEffect == api.DecisionEffectAuditOnly) && decision.ID == "" { + t.Fatal("proceeding decision ID is empty") + } + }) + } +} + +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) +} + +func opsPath(name string) string { + return filepath.Join("..", "..", "examples", "ops-warden", name) +} + +func railiancePlatformPath(name string) string { + return filepath.Join("..", "..", "examples", "railiance-platform", name) +} + +func opsWardenProductionSignRequest(subjectID, actor, actorType, principal string, ttlHours float64) api.CheckRequest { + return api.CheckRequest{ + ID: "check:ops-warden-production-" + actor, + Tenant: "tenant:platform", + Subject: api.SubjectRef{ + ID: subjectID, + Type: api.SubjectType(actorType), + }, + Action: "sign", + Resource: api.ResourceRef{ + ID: "ssh-cert:actor/" + actor, + Type: "ssh-certificate", + System: "ops-warden", + }, + Context: map[string]any{ + "principals": []string{principal}, + "actor_type": actorType, + "ttl_hours": ttlHours, + "pubkey_fingerprint": "SHA256:example-production-fingerprint", + }, + } +} + +func postCheck(t *testing.T, url, path string) api.DecisionEnvelope { + t.Helper() + + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST %s status = %d; want 200", path, resp.StatusCode) + } + var decision api.DecisionEnvelope + if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil { + t.Fatalf("decode %s response: %v", path, err) + } + return decision +} diff --git a/cmd/flex-auth/ops_warden_fetch_test.go b/cmd/access-engine/ops_warden_fetch_test.go similarity index 100% rename from cmd/flex-auth/ops_warden_fetch_test.go rename to cmd/access-engine/ops_warden_fetch_test.go diff --git a/cmd/flex-auth/readback.go b/cmd/access-engine/readback.go similarity index 98% rename from cmd/flex-auth/readback.go rename to cmd/access-engine/readback.go index cabfe31..00139c9 100644 --- a/cmd/flex-auth/readback.go +++ b/cmd/access-engine/readback.go @@ -79,7 +79,7 @@ func runServeReadback(args []string, _, stderr io.Writer) int { return fail(stderr, err) } - fmt.Fprintf(stderr, "flex-auth readback serving on http://%s\n", *addr) + fmt.Fprintf(stderr, "access-engine readback serving on http://%s\n", *addr) if err := http.ListenAndServe(*addr, newReadbackMux(service, authenticator)); err != nil { return fail(stderr, err) } diff --git a/cmd/flex-auth/readback_pin_test.go b/cmd/access-engine/readback_pin_test.go similarity index 100% rename from cmd/flex-auth/readback_pin_test.go rename to cmd/access-engine/readback_pin_test.go diff --git a/cmd/flex-auth/readback_test.go b/cmd/access-engine/readback_test.go similarity index 100% rename from cmd/flex-auth/readback_test.go rename to cmd/access-engine/readback_test.go diff --git a/cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml b/cmd/access-engine/testdata/readback/demo-app/policy_fixtures.yaml similarity index 100% rename from cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml rename to cmd/access-engine/testdata/readback/demo-app/policy_fixtures.yaml diff --git a/cmd/flex-auth/testdata/readback/demo-app/policy_package.md b/cmd/access-engine/testdata/readback/demo-app/policy_package.md similarity index 100% rename from cmd/flex-auth/testdata/readback/demo-app/policy_package.md rename to cmd/access-engine/testdata/readback/demo-app/policy_package.md diff --git a/cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json b/cmd/access-engine/testdata/readback/demo-app/registry_snapshot.json similarity index 100% rename from cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json rename to cmd/access-engine/testdata/readback/demo-app/registry_snapshot.json diff --git a/cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml b/cmd/access-engine/testdata/readback/other-app/policy_fixtures.yaml similarity index 100% rename from cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml rename to cmd/access-engine/testdata/readback/other-app/policy_fixtures.yaml diff --git a/cmd/flex-auth/testdata/readback/other-app/policy_package.md b/cmd/access-engine/testdata/readback/other-app/policy_package.md similarity index 100% rename from cmd/flex-auth/testdata/readback/other-app/policy_package.md rename to cmd/access-engine/testdata/readback/other-app/policy_package.md diff --git a/cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json b/cmd/access-engine/testdata/readback/other-app/registry_snapshot.json similarity index 100% rename from cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json rename to cmd/access-engine/testdata/readback/other-app/registry_snapshot.json diff --git a/cmd/flex-auth/main.go b/cmd/flex-auth/main.go index ea90a06..d3ee065 100644 --- a/cmd/flex-auth/main.go +++ b/cmd/flex-auth/main.go @@ -1,671 +1,19 @@ -// Command flex-auth is the CLI entry point for the flex-auth authorization -// registry and control plane. +// Command flex-auth is retired. The binary is now access-engine +// (FLEX-DEC-2026-020, FLEX-WP-0036). This stub exists only so that a stale +// invocation fails with the new name instead of a missing-file error. package main import ( - "context" - "encoding/json" - "errors" - "flag" "fmt" - "io" - "net/http" "os" - "strings" - "time" - - "gopkg.in/yaml.v3" - - "github.com/netkingdom/flex-auth/internal/audit" - "github.com/netkingdom/flex-auth/internal/callerauth" - decisioncore "github.com/netkingdom/flex-auth/internal/decision" - "github.com/netkingdom/flex-auth/internal/emission" - "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" +const renameMessage = `flex-auth: this binary has been renamed to access-engine (FLEX-DEC-2026-020). +Use the same subcommands and flags with the new name: + go run ./cmd/access-engine [options] + access-engine [options]` 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 "serve-readback": - return runServeReadback(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") - outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log") - emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine") - auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats") - auditTokenFile := fs.String("audit-token-file", "", "mounted sender token file, never a token value") - callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce") - callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience") - callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview") - callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential") - callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle") - var callerBindings keyValueFlags - fs.Var(&callerBindings, "caller-binding", "resource system=authenticated Kubernetes principal (repeatable)") - if err := fs.Parse(args); err != nil { - return 64 - } - if *registryPath == "" || *policyPath == "" { - fmt.Fprintln(stderr, "serve requires --registry and --policy") - return 64 - } - - if *outboxDir != "" && (*logPath != "" || *emissionSource == "") { - fmt.Fprintln(stderr, "serve --outbox-dir requires --emission-source and excludes --log") - return 64 - } - delivery := emission.DeliveryConfig{URL: *auditURL, TokenFile: *auditTokenFile} - if *auditURL != "" || *auditTokenFile != "" { - if *outboxDir == "" { - fmt.Fprintln(stderr, "audit delivery requires --outbox-dir") - return 64 - } - if err := delivery.Validate(); err != nil { - fmt.Fprintln(stderr, err) - return 64 - } - } - - engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath) - if err != nil { - return fail(stderr, err) - } - var outbox *emission.Outbox - if *outboxDir != "" { - outbox, err = emission.Open(*outboxDir, *emissionSource) - if err != nil { - return fail(stderr, err) - } - defer outbox.Close() - engine.SetDecisionLog(outbox) - } - - authenticator, err := buildCallerAuthenticator( - callerauth.Mode(*callerAuthMode), - *callerAudience, - *callerKubernetesURL, - *callerReviewerTokenFile, - *callerCAFile, - callerBindings.StringMap(), - stderr, - ) - if err != nil { - return fail(stderr, err) - } - - mux := newServeMuxWithCallerAuth(engine, authenticator) - if outbox != nil { - mountEmission(mux, outbox) - } - if *auditURL != "" { - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - runEmissionDelivery(ctx, outbox, delivery, stderr) - }() - defer func() { cancel(); <-done }() - } - - 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 runEmissionDelivery(ctx context.Context, outbox *emission.Outbox, config emission.DeliveryConfig, stderr io.Writer) { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - if ctx.Err() != nil { - return - } - if err := outbox.AppendDueHeartbeats(); err != nil { - fmt.Fprintf(stderr, "emission heartbeat commit: %v\n", err) - } - n, err := outbox.Drain(ctx, config, 100) - if err != nil && ctx.Err() == nil { - fmt.Fprintf(stderr, "emission drain: %v\n", err) - } - if err == nil && n == 100 { - continue // Drain the next bounded batch without imposing a rate cap. - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -func newServeMux(engine *decisioncore.Engine) *http.ServeMux { - return newServeMuxWithCallerAuth(engine, callerauth.Disabled()) -} - -func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *callerauth.Authenticator) *http.ServeMux { - 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 - } - if request.Purpose != "" { - // Only the readback pin issues purpose-bearing decisions - // (FLEX-DEC-2026-019); an enforcement pin never does. - http.Error(w, "purpose is not accepted on /v1/check", http.StatusBadRequest) - return - } - identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System}) - if err != nil { - writeCallerAuthError(w, err) - return - } - decision, err := engine.Check(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), 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 - } - systems := make([]string, 0, len(request.Resources)) - for _, resource := range request.Resources { - systems = append(systems, resource.System) - } - identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems) - if err != nil { - writeCallerAuthError(w, err) - return - } - decisions, err := engine.BatchCheck(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request) - writeHTTP(w, decisions, err) - }) - return mux -} - -func buildCallerAuthenticator(mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string, stderr io.Writer) (*callerauth.Authenticator, error) { - if mode == callerauth.ModeDisabled { - return callerauth.New(mode, nil, "", nil, nil) - } - reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile) - if err != nil { - return nil, err - } - return callerauth.New(mode, reviewer, audience, bindings, func(format string, args ...any) { - fmt.Fprintf(stderr, format+"\n", args...) - }) -} - -func writeCallerAuthError(w http.ResponseWriter, err error) { - switch { - case errors.Is(err, callerauth.ErrUnauthenticated): - http.Error(w, "unauthenticated", http.StatusUnauthorized) - case errors.Is(err, callerauth.ErrForbidden): - http.Error(w, "forbidden", http.StatusForbidden) - default: - http.Error(w, "caller authentication unavailable", http.StatusServiceUnavailable) - } -} - -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 -} - -// mountEmission exposes the local half of reconciliation: committed and -// released-uncommitted counts per class. Counts only, never records. -func mountEmission(mux *http.ServeMux, outbox *emission.Outbox) { - mux.HandleFunc("/v1/emission", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - writeHTTP(w, outbox.Status(), nil) - }) -} - -func writeHTTP(w http.ResponseWriter, value any, err error) { - w.Header().Set("content-type", "application/json") - if errors.Is(err, decisioncore.ErrRecordNotCommitted) { - // The consumer's declared stance applies (FLEX-DEC-2026-018). - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - 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, serve-readback") -} - -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 -} - -func (f keyValueFlags) StringMap() map[string]string { - out := make(map[string]string, len(f)) - for _, item := range f { - key, value, _ := strings.Cut(item, "=") - out[key] = value - } - return out + fmt.Fprintln(os.Stderr, renameMessage) + os.Exit(2) } diff --git a/cmd/flex-auth/main_test.go b/cmd/flex-auth/main_test.go index 1dbf5e4..fcd0d06 100644 --- a/cmd/flex-auth/main_test.go +++ b/cmd/flex-auth/main_test.go @@ -1,465 +1,26 @@ package main import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" "os" - "path/filepath" + "os/exec" "strings" "testing" - - "github.com/netkingdom/flex-auth/internal/callerauth" - "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()) +// The retired binary must fail and name its replacement, whatever it is asked. +func TestRetiredBinaryNamesReplacement(t *testing.T) { + if os.Getenv("FLEX_AUTH_STUB_CHILD") == "1" { + main() + return } - if strings.TrimSpace(stdout.String()) == "" { - t.Fatal("version output is empty") + cmd := exec.Command(os.Args[0], "-test.run=TestRetiredBinaryNamesReplacement", "serve", "--addr", "x") + cmd.Env = append(os.Environ(), "FLEX_AUTH_STUB_CHILD=1") + out, err := cmd.CombinedOutput() + exit, ok := err.(*exec.ExitError) + if !ok || exit.ExitCode() != 2 { + t.Fatalf("want exit 2, got %v", err) + } + if !strings.Contains(string(out), "renamed to access-engine") { + t.Fatalf("missing rename message: %s", out) } } - -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 TestRunCheckOpsWarden(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run([]string{ - "check", - "--registry", opsPath("registry_snapshot.json"), - "--policy", opsPath("policy_package.md"), - "--request", opsPath("check_request_allow_adm.json"), - }, &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) - } - if decision.ID == "" { - t.Fatal("decision.ID is empty; ops-warden needs a policy_decision_id") - } -} - -func TestRunRailiancePlatformCredentialGrantContract(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run([]string{"test-policy", "--file", railiancePlatformPath("policy_package.md")}, &stdout, &stderr) - if code != 0 || !strings.Contains(stdout.String(), `"valid": true`) { - t.Fatalf("test-policy code = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String()) - } - - stdout.Reset() - stderr.Reset() - code = run([]string{ - "check", - "--registry", railiancePlatformPath("registry_snapshot.json"), - "--policy", railiancePlatformPath("policy_package.md"), - "--request", railiancePlatformPath("check_request_allow.json"), - }, &stdout, &stderr) - if code != 0 { - t.Fatalf("check code = %d, stderr = %s", code, stderr.String()) - } - var decision api.DecisionEnvelope - if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil { - t.Fatal(err) - } - if decision.Effect != api.DecisionEffectAllow || decision.Reason != "credential_grant_allowed" { - t.Fatalf("decision = %s/%s; want allow/credential_grant_allowed", decision.Effect, decision.Reason) - } - if decision.Binding == nil || decision.Binding.Context["requested_ttl_seconds"] != float64(900) { - t.Fatalf("binding = %+v; want normalized numeric TTL", decision.Binding) - } -} - -func TestServeOpsWardenCheckContract(t *testing.T) { - logPath := filepath.Join(t.TempDir(), "decisions.jsonl") - engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), logPath) - if err != nil { - t.Fatalf("buildEngine: %v", err) - } - server := httptest.NewServer(newServeMux(engine)) - defer server.Close() - - resp, err := http.Get(server.URL + "/healthz") - if err != nil { - t.Fatalf("GET /healthz: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("GET /healthz status = %d; want 200", resp.StatusCode) - } - - allow := postCheck(t, server.URL+"/v1/check", opsPath("check_request_allow_adm.json")) - if allow.Effect != api.DecisionEffectAllow || allow.ID == "" { - t.Fatalf("allow decision = %+v; want allow with id", allow) - } - - deny := postCheck(t, server.URL+"/v1/check", opsPath("check_request_deny_ttl_above_max.json")) - if deny.Effect != api.DecisionEffectDeny || deny.Reason != "ttl_out_of_bounds" { - t.Fatalf("deny decision = %+v; want ttl_out_of_bounds deny", deny) - } - - resp, err = http.Get(server.URL + "/v1/check") - if err != nil { - t.Fatalf("GET /v1/check: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusMethodNotAllowed { - t.Fatalf("GET /v1/check status = %d; want 405", resp.StatusCode) - } - - resp, err = http.Post(server.URL+"/v1/check", "application/json", strings.NewReader(`{"subject":`)) - if err != nil { - t.Fatalf("POST malformed /v1/check: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("malformed POST status = %d; want 400", resp.StatusCode) - } - - logData, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("read decision log: %v", err) - } - if !strings.Contains(string(logData), allow.ID) || !strings.Contains(string(logData), deny.ID) { - t.Fatalf("decision log does not contain both decision ids\nlog: %s\nallow: %s deny: %s", string(logData), allow.ID, deny.ID) - } -} - -type fixedTokenReviewer struct { - identity callerauth.Identity -} - -func (r fixedTokenReviewer) Review(context.Context, string) (callerauth.Identity, error) { - return r.identity, nil -} - -func TestServeCallerAuthenticationBindsSystemToPrincipal(t *testing.T) { - engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "") - if err != nil { - t.Fatalf("buildEngine: %v", err) - } - authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ - Username: "system:serviceaccount:ops-warden:ops-warden", - Audiences: []string{"flex-auth"}, - }}, "flex-auth", map[string]string{ - "ops-warden": "system:serviceaccount:ops-warden:ops-warden", - }, nil) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator)) - defer server.Close() - body, err := os.ReadFile(opsPath("check_request_allow_adm.json")) - if err != nil { - t.Fatal(err) - } - - request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) - request.Header.Set("content-type", "application/json") - resp, err := http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("missing token status = %d; want 401", resp.StatusCode) - } - - request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) - request.Header.Set("content-type", "application/json") - request.Header.Set("authorization", "Bearer workload-token") - resp, err = http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - t.Fatalf("bound caller status = %d; want 200", resp.StatusCode) - } - var envelope api.DecisionEnvelope - if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { - resp.Body.Close() - t.Fatalf("decode decision: %v", err) - } - resp.Body.Close() - if envelope.Provenance.Caller == nil || envelope.Provenance.Caller.Mode != "enforce" { - t.Fatalf("caller = %+v; want enforce", envelope.Provenance.Caller) - } - if envelope.Provenance.Caller.Principal != "system:serviceaccount:ops-warden:ops-warden" { - t.Fatalf("principal = %q", envelope.Provenance.Caller.Principal) - } - if envelope.Provenance.Caller.Audience != "flex-auth" { - t.Fatalf("audience = %q", envelope.Provenance.Caller.Audience) - } - - wrong, _ := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ - Username: "system:serviceaccount:another:caller", - Audiences: []string{"flex-auth"}, - }}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil) - wrongServer := httptest.NewServer(newServeMuxWithCallerAuth(engine, wrong)) - defer wrongServer.Close() - request, _ = http.NewRequest(http.MethodPost, wrongServer.URL+"/v1/check", bytes.NewReader(body)) - request.Header.Set("content-type", "application/json") - request.Header.Set("authorization", "Bearer workload-token") - resp, err = http.DefaultClient.Do(request) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusForbidden { - t.Fatalf("mismatched caller status = %d; want 403", resp.StatusCode) - } -} - -func TestRunLoadRegistryOpsWardenProduction(t *testing.T) { - var stdout, stderr bytes.Buffer - code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr) - if code != 0 { - t.Fatalf("code = %d, stderr = %s", code, stderr.String()) - } - - var result map[string]any - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatalf("unmarshal load-registry output: %v; stdout = %s", err, stdout.String()) - } - if result["subjects"] != float64(4) || result["relationships"] != float64(4) || result["resource_manifests"] != float64(1) { - t.Fatalf("load-registry result = %+v; want production actor registry counts", result) - } -} - -func TestOpsWardenProductionRegistryActors(t *testing.T) { - engine, err := buildEngine(context.Background(), opsPath("production_registry_snapshot.json"), opsPath("policy_package.md"), "") - if err != nil { - t.Fatalf("buildEngine: %v", err) - } - - cases := []struct { - name string - subjectID string - actor string - actorType string - principal string - ttlHours float64 - wantEffect api.DecisionEffect - wantReason string - }{ - { - name: "state hub bridge agent advisory while zone is unknown", - subjectID: "agt-state-hub-bridge", - actor: "agt-state-hub-bridge", - actorType: "agt", - principal: "agt-task-bridge", - ttlHours: 1, - wantEffect: api.DecisionEffectAuditOnly, - wantReason: "advisory_would_signing_policy_matched", - }, - { - name: "state hub bridge IAM subject advisory while zone is unknown", - subjectID: "iam:agt-state-hub-bridge", - actor: "agt-state-hub-bridge", - actorType: "agt", - principal: "agt-task-bridge", - ttlHours: 1, - wantEffect: api.DecisionEffectAuditOnly, - wantReason: "advisory_would_signing_policy_matched", - }, - { - name: "codex interhub bootstrap advisory while zone is unknown", - subjectID: "agt-codex-interhub-bootstrap", - actor: "agt-codex-interhub-bootstrap", - actorType: "agt", - principal: "agt-interhub-bootstrap", - ttlHours: 1, - wantEffect: api.DecisionEffectAuditOnly, - wantReason: "advisory_would_signing_policy_matched", - }, - { - name: "admin actor allow", - subjectID: "adm-example", - actor: "adm-example", - actorType: "adm", - principal: "adm-full", - ttlHours: 4, - wantEffect: api.DecisionEffectAllow, - }, - { - name: "automation actor advisory while zone is unknown", - subjectID: "atm-backup-daily", - actor: "atm-backup-daily", - actorType: "atm", - principal: "atm-backup-daily", - ttlHours: 1, - wantEffect: api.DecisionEffectAuditOnly, - wantReason: "advisory_would_signing_policy_matched", - }, - { - name: "ttl above production max is advisory while zone is unknown", - subjectID: "agt-state-hub-bridge", - actor: "agt-state-hub-bridge", - actorType: "agt", - principal: "agt-task-bridge", - ttlHours: 999, - wantEffect: api.DecisionEffectAuditOnly, - wantReason: "advisory_would_ttl_out_of_bounds", - }, - { - name: "unregistered production actor is advisory unknown", - subjectID: "agt-missing", - actor: "agt-missing", - actorType: "agt", - principal: "agt-missing", - ttlHours: 1, - wantEffect: api.DecisionEffectAuditOnly, - wantReason: "advisory_would_unknown_actor_resource", - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - decision, err := engine.Check(context.Background(), opsWardenProductionSignRequest(tt.subjectID, tt.actor, tt.actorType, tt.principal, tt.ttlHours)) - if err != nil { - t.Fatalf("Check: %v", err) - } - if decision.Effect != tt.wantEffect { - t.Fatalf("decision.Effect = %q; want %q; decision: %+v", decision.Effect, tt.wantEffect, decision) - } - if tt.wantReason != "" && decision.Reason != tt.wantReason { - t.Fatalf("decision.Reason = %q; want %q; decision: %+v", decision.Reason, tt.wantReason, decision) - } - if (tt.wantEffect == api.DecisionEffectAllow || tt.wantEffect == api.DecisionEffectAuditOnly) && decision.ID == "" { - t.Fatal("proceeding decision ID is empty") - } - }) - } -} - -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) -} - -func opsPath(name string) string { - return filepath.Join("..", "..", "examples", "ops-warden", name) -} - -func railiancePlatformPath(name string) string { - return filepath.Join("..", "..", "examples", "railiance-platform", name) -} - -func opsWardenProductionSignRequest(subjectID, actor, actorType, principal string, ttlHours float64) api.CheckRequest { - return api.CheckRequest{ - ID: "check:ops-warden-production-" + actor, - Tenant: "tenant:platform", - Subject: api.SubjectRef{ - ID: subjectID, - Type: api.SubjectType(actorType), - }, - Action: "sign", - Resource: api.ResourceRef{ - ID: "ssh-cert:actor/" + actor, - Type: "ssh-certificate", - System: "ops-warden", - }, - Context: map[string]any{ - "principals": []string{principal}, - "actor_type": actorType, - "ttl_hours": ttlHours, - "pubkey_fingerprint": "SHA256:example-production-fingerprint", - }, - } -} - -func postCheck(t *testing.T, url, path string) api.DecisionEnvelope { - t.Helper() - - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - resp, err := http.Post(url, "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", url, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("POST %s status = %d; want 200", path, resp.StatusCode) - } - var decision api.DecisionEnvelope - if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil { - t.Fatalf("decode %s response: %v", path, err) - } - return decision -} diff --git a/decisions/decisions.md b/decisions/decisions.md index 207c037..ee9b93e 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -2114,3 +2114,34 @@ without letting registry content decide an allow? were issued for user-engine's client, so an `application.access` rule that reads audience, authorized party or client-scoped roles answers for the wrong client. This is a package-review checklist item. + +--- + +## FLEX-DEC-2026-020 — The binary is `access-engine`; `flex-auth` is a stub that names the rename + +**Date:** 2026-10-10 +**Status:** accepted (founder, 2026-10-10) +**Workplan:** `FLEX-WP-0036-T01` (binary question), implemented in `T04` +**Supersedes:** the "binary/CLI" row of `FLEX-DEC-2026-013`'s retain table + +**Question.** Should the binary be renamed outright, or ship as `access-engine` +with a working `flex-auth` alias for one release? + +**Ruling.** Rename it outright. The CLI is `cmd/access-engine` and builds +`access-engine`. `cmd/flex-auth` remains only as a stub: whatever it is called +with, it prints that the binary was renamed to `access-engine` and exits 2. +The image ships both, with the entrypoint set to `access-engine`. A working +alias would keep the old name in use, and the rename exists to end that. + +**Consequences.** + +- Live pins are unaffected until their next roll. The chart passes only + `args`, so the new entrypoint takes the same subcommands. +- A stale caller fails loudly with the new name instead of running. + Known callers to tell: `secrets-engine` (`tools/exercise_approval_identity.py` + builds `./cmd/flex-auth`), `net-kingdom` (protected-system runbook and + tutorial), and `informed-decision` (`docs/protected-browser-review.md`). +- Runtime names are not covered by this ruling: the Go module path, the + `/opt/flex-auth` image paths, the caller audience, Kubernetes names and the + image package. They stay with `FLEX-WP-0036` T03 to T05 and their own order + (`FLEX-DEC-2026-015`). diff --git a/docs/application-access-readback.md b/docs/application-access-readback.md index 3c72e57..afc02c0 100644 --- a/docs/application-access-readback.md +++ b/docs/application-access-readback.md @@ -232,7 +232,7 @@ or revoke surface. ```bash # --registry and --package repeat once per participating system; # system ids must not repeat across registries. -flex-auth serve-readback \ +access-engine serve-readback \ --registry examples//registry_snapshot.json \ --package =examples//policy_package.md \ --caller-auth-mode enforce \ @@ -243,4 +243,4 @@ The pin mounts `/healthz`, `POST /v1/readback/application_access` and `GET /v1/readback/systems`, and no enforcement route. `--readback-caller` is bound under the key `application-readback`. That key is not a protected system, so the principal can never represent a system on any pin's -`/v1/check`. Tests: `cmd/flex-auth/readback_test.go`. +`/v1/check`. Tests: `cmd/access-engine/readback_test.go`. diff --git a/docs/decision-input-freshness.md b/docs/decision-input-freshness.md index 6da7ed3..107d4e9 100644 --- a/docs/decision-input-freshness.md +++ b/docs/decision-input-freshness.md @@ -35,7 +35,7 @@ flex-auth does not store or mutate the approval object (`approval-engine`). | | | | --- | --- | | What is joined | Protected systems, resources, subjects, groups, teams, tenants, relationship facts | -| Mechanism | In-memory `registry.Store` loaded at process start and by `flex-auth load-registry`. There is no background watch. | +| Mechanism | In-memory `registry.Store` loaded at process start and by `access-engine load-registry`. There is no background watch. | | Deadline | A registry-borne revocation is visible after the operator reloads the snapshot **or** when the current allow TTL ends, whichever is first. On a running process that has not reloaded, the snapshot is otherwise unchanged. | | Provenance | `registry_snapshot_digest` — SHA-256 of canonical snapshot JSON | diff --git a/docs/delegated-mode-operations.md b/docs/delegated-mode-operations.md index 5e2c5b2..35c122a 100644 --- a/docs/delegated-mode-operations.md +++ b/docs/delegated-mode-operations.md @@ -6,7 +6,7 @@ Status: implemented for FLEX-WP-0004 P4.6. Delegated mode lets flex-auth coordinate external authorization systems without changing the protected-system-facing API. Protected systems keep -using flex-auth check, batch, list, explanation, and audit contracts. +using access-engine check, batch, list, explanation, and audit contracts. Topaz, relationship PDPs, rule PDPs, Keycloak Authorization Services, and directory resolvers stay behind adapter boundaries. diff --git a/docs/ops-warden-policy-gate-handoff.md b/docs/ops-warden-policy-gate-handoff.md index a3a11b0..a5c25bc 100644 --- a/docs/ops-warden-policy-gate-handoff.md +++ b/docs/ops-warden-policy-gate-handoff.md @@ -16,7 +16,7 @@ Ops-warden unblocker: WARDEN-WP-0009 T01 ## Local service command - flex-auth serve --addr 127.0.0.1:8080 --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /tmp/flex-auth-ops-warden-decisions.jsonl + access-engine serve --addr 127.0.0.1:8080 --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /tmp/flex-auth-ops-warden-decisions.jsonl Ops-warden can point policy.flex_auth_url at that base URL for local smoke. Production should keep policy.fail_closed true unless an explicit break-glass @@ -91,7 +91,7 @@ Additional published assets: Production runtime command: - flex-auth serve --addr 0.0.0.0:8080 --registry examples/ops-warden/production_registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /var/log/flex-auth/ops-warden-decisions.jsonl + access-engine serve --addr 0.0.0.0:8080 --registry examples/ops-warden/production_registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /var/log/flex-auth/ops-warden-decisions.jsonl Use http://flex-auth.flex-auth.svc.cluster.local:8080 when cluster DNS is reachable from warden workstations. Otherwise use the approved operator tunnel diff --git a/docs/ops-warden-registry-sync.md b/docs/ops-warden-registry-sync.md index ee42ee5..5404f56 100644 --- a/docs/ops-warden-registry-sync.md +++ b/docs/ops-warden-registry-sync.md @@ -26,7 +26,7 @@ from the same workstation: Start the runtime with the production registry snapshot and the ops-warden policy package: - flex-auth serve --addr 0.0.0.0:8080 --registry examples/ops-warden/production_registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /var/log/flex-auth/ops-warden-decisions.jsonl + access-engine serve --addr 0.0.0.0:8080 --registry examples/ops-warden/production_registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /var/log/flex-auth/ops-warden-decisions.jsonl The checked-in production snapshot is a non-secret fixture and initial load target. Regenerate it from ops-warden inventory whenever actors, principals, or @@ -68,7 +68,7 @@ Service if flex-auth should run inside the cluster. 3. Validate the generated file before handoff: - flex-auth load-registry --file registry/flex-auth/production_registry_snapshot.json + access-engine load-registry --file registry/flex-auth/production_registry_snapshot.json 4. Copy or promote the snapshot to the flex-auth runtime. For repo-level drift coverage, update examples/ops-warden/production_registry_snapshot.json when diff --git a/docs/request-enrichment.md b/docs/request-enrichment.md index 46311fb..48fafd3 100644 --- a/docs/request-enrichment.md +++ b/docs/request-enrichment.md @@ -157,11 +157,11 @@ be inferred from the key name: precedence) Package changes wait until T03 makes "read a declared registry key" a -`flex-auth validate` finding rather than a review note. +`access-engine validate` finding rather than a review note. ## Validate flags undeclared attribute reads (FLEX-WP-0025-T03) -`flex-auth validate --kind policy` now emits +`access-engine validate --kind policy` now emits `POLICY-ATTRIBUTE-UNDECLARED` (error) for every `input.{resource,subject}.attributes.` the package reads that no sibling `registry_snapshot.json`, `production_registry_snapshot.json`, diff --git a/examples/ops-warden-fetch/README.md b/examples/ops-warden-fetch/README.md index 736109c..df715f6 100644 --- a/examples/ops-warden-fetch/README.md +++ b/examples/ops-warden-fetch/README.md @@ -56,7 +56,7 @@ The readable set is every ops-warden catalog entry with `status: active`, ```bash python3 tools/gen_ops_warden_fetch.py # rewrites the three generated files python3 tools/gen_ops_warden_fetch.py --check # exit 1 on drift (tests/stage1.sh runs it) -go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md +go run ./cmd/access-engine test-policy --file examples/ops-warden-fetch/policy_package.md ``` `--catalog PATH` overrides the default `~/ops-warden/registry/routing/catalog.yaml`. @@ -67,9 +67,9 @@ baked into the image, so it needs a new image too (`deploy/README.md`). ## Verify ```bash -go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md # 6 Rego tests, 15 fixtures -go run ./cmd/flex-auth load-registry -file examples/ops-warden-fetch/registry_snapshot.json -go test ./cmd/flex-auth -run OpsWardenFetch +go run ./cmd/access-engine test-policy --file examples/ops-warden-fetch/policy_package.md # 6 Rego tests, 15 fixtures +go run ./cmd/access-engine load-registry -file examples/ops-warden-fetch/registry_snapshot.json +go test ./cmd/access-engine -run OpsWardenFetch ``` ## Not yet deployed diff --git a/examples/ops-warden/README.md b/examples/ops-warden/README.md index 6d2d383..e46e84a 100644 --- a/examples/ops-warden/README.md +++ b/examples/ops-warden/README.md @@ -22,13 +22,13 @@ Files: Run locally: ```bash -flex-auth validate --kind protected-system --file examples/ops-warden/protected_system_manifest.yaml -flex-auth validate --kind resource-manifest --file examples/ops-warden/resource_manifest.yaml -flex-auth validate --kind subject-manifest --file examples/ops-warden/subject_manifest.yaml -flex-auth load-registry --file examples/ops-warden/registry_snapshot.json -flex-auth test-policy --file examples/ops-warden/policy_package.md -flex-auth check --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --request examples/ops-warden/check_request_allow_adm.json -flex-auth check --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --request examples/ops-warden/check_request_deny_wrong_tenant.json +access-engine validate --kind protected-system --file examples/ops-warden/protected_system_manifest.yaml +access-engine validate --kind resource-manifest --file examples/ops-warden/resource_manifest.yaml +access-engine validate --kind subject-manifest --file examples/ops-warden/subject_manifest.yaml +access-engine load-registry --file examples/ops-warden/registry_snapshot.json +access-engine test-policy --file examples/ops-warden/policy_package.md +access-engine check --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --request examples/ops-warden/check_request_allow_adm.json +access-engine check --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --request examples/ops-warden/check_request_deny_wrong_tenant.json ``` The fixture public-key fingerprints are examples only. Do not put real keys, @@ -44,7 +44,7 @@ changes. Validate both registries locally: - flex-auth load-registry --file examples/ops-warden/registry_snapshot.json - flex-auth load-registry --file examples/ops-warden/production_registry_snapshot.json + access-engine load-registry --file examples/ops-warden/registry_snapshot.json + access-engine load-registry --file examples/ops-warden/production_registry_snapshot.json The production sync contract is documented in docs/ops-warden-registry-sync.md. diff --git a/examples/qonto-assistant/README.md b/examples/qonto-assistant/README.md index 3292184..d632d08 100644 --- a/examples/qonto-assistant/README.md +++ b/examples/qonto-assistant/README.md @@ -14,8 +14,8 @@ separate; see `policy_package.md`'s scope note). | `subject_manifest.yaml` | Registered callers: an agent-harness session identity and the founder's human identity, both in `group:qonto-assistant-readers` | | `policy_package.md` | Rego rules + embedded tests gating the read action | | `policy_fixtures.yaml` | Allow/deny request/decision pairs, referenced by `policy_package.md`'s frontmatter | -| `registry_snapshot.json` | Merged `systems`/`subjects`/`groups` snapshot assembled from the two manifests above, loadable by `flex-auth serve`/`check`/`load-registry` | -| `check_request_allow_read.json`, `check_request_deny_wrong_tenant.json` | Standalone example requests for `flex-auth check` | +| `registry_snapshot.json` | Merged `systems`/`subjects`/`groups` snapshot assembled from the two manifests above, loadable by `access-engine serve`/`check`/`load-registry` | +| `check_request_allow_read.json`, `check_request_deny_wrong_tenant.json` | Standalone example requests for `access-engine check` | **No `resource_manifest.yaml`** — like tenant-engine's tenants, `qonto-assistant` doesn't have a fixed enumerable resource inventory to register; the single @@ -26,26 +26,26 @@ gate. ## Verified ```bash -go build -o bin/flex-auth ./cmd/flex-auth +go build -o bin/access-engine ./cmd/access-engine # Rego rules + embedded tests + fixtures, all pass: -bin/flex-auth test-policy -file examples/qonto-assistant/policy_package.md +bin/access-engine test-policy -file examples/qonto-assistant/policy_package.md # Registry loads cleanly: -bin/flex-auth load-registry -file examples/qonto-assistant/registry_snapshot.json +bin/access-engine load-registry -file examples/qonto-assistant/registry_snapshot.json # Individual requests via the CLI: -bin/flex-auth check \ +bin/access-engine check \ -registry examples/qonto-assistant/registry_snapshot.json \ -policy examples/qonto-assistant/policy_package.md \ -request examples/qonto-assistant/check_request_allow_read.json -# End-to-end over real HTTP: a live `flex-auth serve` loaded with this exact +# End-to-end over real HTTP: a live `access-engine serve` loaded with this exact # registry+policy, hit by qonto-assistant's actual FlexAuthCheckClient (not # a mock) -- see qonto-assistant/tests/test_flex_auth_client.py and # tests/test_live_authorization_gate.py, and the manual curl walkthrough # below: -bin/flex-auth serve -addr 127.0.0.1:9099 \ +bin/access-engine serve -addr 127.0.0.1:9099 \ -registry examples/qonto-assistant/registry_snapshot.json \ -policy examples/qonto-assistant/policy_package.md # from another shell: diff --git a/examples/railiance-platform/README.md b/examples/railiance-platform/README.md index 05d0dd1..50f0592 100644 --- a/examples/railiance-platform/README.md +++ b/examples/railiance-platform/README.md @@ -10,9 +10,9 @@ integration shapes must emit `requested_ttl_seconds` as a positive number; duration strings such as `15m` are rejected rather than guessed. ```bash -go run ./cmd/flex-auth load-registry --file examples/railiance-platform/registry_snapshot.json -go run ./cmd/flex-auth test-policy --file examples/railiance-platform/policy_package.md -go run ./cmd/flex-auth check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_allow.json -go run ./cmd/flex-auth check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_deny_unknown.json -go run ./cmd/flex-auth check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_deny_wrong_tenant.json +go run ./cmd/access-engine load-registry --file examples/railiance-platform/registry_snapshot.json +go run ./cmd/access-engine test-policy --file examples/railiance-platform/policy_package.md +go run ./cmd/access-engine check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_allow.json +go run ./cmd/access-engine check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_deny_unknown.json +go run ./cmd/access-engine check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_deny_wrong_tenant.json ``` diff --git a/examples/secrets-engine/README.md b/examples/secrets-engine/README.md index ef541b0..27e170b 100644 --- a/examples/secrets-engine/README.md +++ b/examples/secrets-engine/README.md @@ -21,8 +21,8 @@ Read that before changing any action string here. ## Verify ```bash -go run ./cmd/flex-auth validate -kind policy -file examples/secrets-engine/policy_package.md -go run ./cmd/flex-auth load-registry -file examples/secrets-engine/registry_snapshot.json +go run ./cmd/access-engine validate -kind policy -file examples/secrets-engine/policy_package.md +go run ./cmd/access-engine load-registry -file examples/secrets-engine/registry_snapshot.json ``` 28 Rego tests and 32 fixtures. diff --git a/examples/secrets-engine/replay/README.md b/examples/secrets-engine/replay/README.md index df4f499..b781a2e 100644 --- a/examples/secrets-engine/replay/README.md +++ b/examples/secrets-engine/replay/README.md @@ -15,7 +15,7 @@ to verify its digest join (`627810b`) unchanged. `FLEX-WP-0021-T03`. Regenerate either with: ```bash -go run ./cmd/flex-auth check \ +go run ./cmd/access-engine check \ -policy examples/secrets-engine/policy_package.md \ -registry examples/secrets-engine/registry_snapshot.json \ -request examples/secrets-engine/check_request_allow_rotate.json diff --git a/examples/tenant-engine/README.md b/examples/tenant-engine/README.md index 7eb9a75..7d3b45a 100644 --- a/examples/tenant-engine/README.md +++ b/examples/tenant-engine/README.md @@ -12,8 +12,8 @@ protected-system consumer, gating its own write API | `subject_manifest.yaml` | Two registered callers: `tenant-engine` (all nine actions) and `flex-auth` (read-only on `tenant.guardrail.read`) | | `policy_package.md` | Rego rules + embedded tests gating the write API. v3 states the tenant relation (`TEN-DEC-2026-002`, `FLEX-DEC-2026-016`): `tenant` is the target record and must equal `resource.id` (`tenant_not_target` otherwise); the scope is cross-tenant by design | | `policy_fixtures.yaml` | Allow/deny request/decision pairs, referenced by `policy_package.md`'s frontmatter. `tenant` varies across four tenants so the suite reports on the field | -| `registry_snapshot.json` | Merged `systems`/`subjects`/`groups` snapshot assembled from the two manifests above, loadable by `flex-auth serve`/`check`/`load-registry` | -| `check_request_allow_create.json`, `check_request_deny_unknown_subject.json`, `check_request_allow_retire.json`, `check_request_deny_misspelled_lifecycle.json`, `check_request_allow_guardrail_read.json`, `check_request_allow_guardrail_set.json`, `check_request_deny_guardrail_set_as_reader.json`, `check_request_deny_misspelled_guardrail.json`, `check_request_deny_tenant_not_target.json` | Standalone example requests for `flex-auth check` | +| `registry_snapshot.json` | Merged `systems`/`subjects`/`groups` snapshot assembled from the two manifests above, loadable by `access-engine serve`/`check`/`load-registry` | +| `check_request_allow_create.json`, `check_request_deny_unknown_subject.json`, `check_request_allow_retire.json`, `check_request_deny_misspelled_lifecycle.json`, `check_request_allow_guardrail_read.json`, `check_request_allow_guardrail_set.json`, `check_request_deny_guardrail_set_as_reader.json`, `check_request_deny_misspelled_guardrail.json`, `check_request_deny_tenant_not_target.json` | Standalone example requests for `access-engine check` | **No `resource_manifest.yaml`** — unlike ops-warden's fixed SSH-certificate inventory, `tenant-engine`'s resources (tenants) are created dynamically. @@ -23,26 +23,26 @@ omission, not an oversight. ## Verified ```bash -go build -o bin/flex-auth ./cmd/flex-auth +go build -o bin/access-engine ./cmd/access-engine # Rego rules + embedded tests + fixtures, all pass: -bin/flex-auth test-policy -file examples/tenant-engine/policy_package.md +bin/access-engine test-policy -file examples/tenant-engine/policy_package.md # Registry loads cleanly: -bin/flex-auth load-registry -file examples/tenant-engine/registry_snapshot.json +bin/access-engine load-registry -file examples/tenant-engine/registry_snapshot.json # Individual requests via the CLI: -bin/flex-auth check \ +bin/access-engine check \ -registry examples/tenant-engine/registry_snapshot.json \ -policy examples/tenant-engine/policy_package.md \ -request examples/tenant-engine/check_request_allow_create.json -# End-to-end over real HTTP: a live `flex-auth serve` loaded with this +# End-to-end over real HTTP: a live `access-engine serve` loaded with this # exact registry+policy, hit by tenant-engine's actual # FlexAuthCheckClient/FlexAuthWriteAuthorizer (not a mock) -- # POST /tenants with actor="ops" -> 403 (unknown_subject); # actor="tenant-engine" -> 201 (write_api_policy_matched). -bin/flex-auth serve -addr 127.0.0.1:9098 \ +bin/access-engine serve -addr 127.0.0.1:9098 \ -registry examples/tenant-engine/registry_snapshot.json \ -policy examples/tenant-engine/policy_package.md # (from tenant-engine's own checkout) @@ -54,7 +54,7 @@ TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098 make run `test-policy` reports **11/11 Rego tests and 16/16 fixtures passing**, the registry loads, and `go test ./...` / `gofmt` / `go vet` are clean. -End-to-end over real HTTP: a live `flex-auth serve` on `127.0.0.1:9098` +End-to-end over real HTTP: a live `access-engine serve` on `127.0.0.1:9098` loaded with this registry and policy, and a real `tenant-engine` (`TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098`) driven through its unmodified `FlexAuthWriteAuthorizer` with `Idempotency-Key` and an `If-Match` @@ -96,7 +96,7 @@ Rollback target is `sha256:c25fc34a…` (four-action). registry loads (2 subjects, 2 groups), and `go test ./...` / `gofmt` / `go vet` are clean. -End-to-end over real HTTP: a live `flex-auth serve` on `127.0.0.1:9098` +End-to-end over real HTTP: a live `access-engine serve` on `127.0.0.1:9098` loaded with this registry and policy, and a real `tenant-engine` (`TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098`) driven through its unmodified `FlexAuthWriteAuthorizer`: diff --git a/examples/user-engine/README.md b/examples/user-engine/README.md index 878ea2f..0a5685d 100644 --- a/examples/user-engine/README.md +++ b/examples/user-engine/README.md @@ -8,8 +8,8 @@ tenant administrators and self-service users may not. Validate with: ```bash -flex-auth test-policy -file examples/user-engine/policy_package.md -flex-auth load-registry -file examples/user-engine/registry_snapshot.json +access-engine test-policy -file examples/user-engine/policy_package.md +access-engine load-registry -file examples/user-engine/registry_snapshot.json ``` ## Live production (FLEX-WP-0009-T04, 2026-08-16) diff --git a/tenancy.yaml b/tenancy.yaml index 438d50c..4df3e90 100644 --- a/tenancy.yaml +++ b/tenancy.yaml @@ -124,7 +124,7 @@ evidence: A2: - "internal/callerauth/auth.go" - "internal/callerauth/auth_test.go" - - "cmd/flex-auth/main_test.go" + - "cmd/access-engine/main_test.go" - "charts/flex-auth/templates/rbac.yaml" - "deploy/caller-auth-rbac.yaml" - "docs/adr/0004-inbound-caller-authentication.md" diff --git a/tools/verify-posture.sh b/tools/verify-posture.sh index cd81b6a..04c77cc 100644 --- a/tools/verify-posture.sh +++ b/tools/verify-posture.sh @@ -3,7 +3,7 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" declaration="$root/tenancy.yaml" -main="$root/cmd/flex-auth/main.go" +main="$root/cmd/access-engine/main.go" axis_value() { local block="$1" axis="$2" diff --git a/workplans/FLEX-WP-0036-runtime-rename-access-engine.md b/workplans/FLEX-WP-0036-runtime-rename-access-engine.md index db2d2b1..fd3cb3d 100644 --- a/workplans/FLEX-WP-0036-runtime-rename-access-engine.md +++ b/workplans/FLEX-WP-0036-runtime-rename-access-engine.md @@ -4,7 +4,7 @@ type: workplan title: "Runtime rename: flex-auth to access-engine, run side by side, consumers switch one at a time" domain: infotech repo: access-engine -status: proposed +status: active owner: claude topic_slug: netkingdom created: "2026-10-10" @@ -65,7 +65,7 @@ written. ```task id: FLEX-WP-0036-T01 -status: todo +status: progress priority: high state_hub_task_id: "4f9a796d-832f-5936-8a34-eb980f6e1b57" ``` @@ -78,6 +78,10 @@ ship `access-engine` with a `flex-auth` alias for one release. Gate: decision accepted by the founder. +2026-10-10: binary settled by `FLEX-DEC-2026-020` (founder): renamed outright, +`flex-auth` is a stub that prints the rename and exits 2. The other target +names in the table still need the founder's acceptance. + ## Inventory every consumer reference and open handoffs ```task @@ -123,7 +127,7 @@ founder's go. ```task id: FLEX-WP-0036-T04 -status: todo +status: progress priority: medium depends_on: [FLEX-WP-0036-T01] state_hub_task_id: "ff7fd369-326f-5044-aaa4-96c5f45c80e9" @@ -137,6 +141,14 @@ until T07 is done, so the old pins can still roll back. No runtime change. Gate: build, tests and stage-1 render green; CI publishes both packages, and both resolve to the same digest. +2026-10-10 binary part done ahead of the rest (`FLEX-DEC-2026-020`). The CLI is +now `cmd/access-engine` and the image entrypoint is +`/usr/local/bin/access-engine`. The image also ships the `flex-auth` stub. +Updated: Makefile, CI, `tools/verify-posture.sh`, `tenancy.yaml` and the live +docs. Workplans, decisions and ADRs are left as written. Still to do: the Go +module, `FLEX_AUTH_*` env vars, the chart, the image package and the +`/opt/flex-auth` paths. + ## Stand up the access-engine namespace next to flex-auth ```task