FLEX-WP-0036: binary renamed to access-engine; flex-auth is a stub naming the rename (FLEX-DEC-2026-020)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m27s

- 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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 735967@bnt-lap001
Assistant-Session: 5971967f-77f8-4ced-9e02-c6ef67040ffc
This commit is contained in:
tegwick 2026-10-10 13:26:19 +02:00
parent c284344925
commit 0ced2995ee
36 changed files with 1268 additions and 1178 deletions

View file

@ -31,7 +31,7 @@ jobs:
- name: Build - name: Build
run: | run: |
mkdir -p bin 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: lint:
name: Lint name: Lint

View file

@ -3,11 +3,13 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 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 --from=build /out/flex-auth /usr/local/bin/flex-auth
COPY examples /opt/flex-auth/examples COPY examples /opt/flex-auth/examples
EXPOSE 8080 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"] 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"]

View file

@ -1,5 +1,5 @@
BIN_DIR ?= bin BIN_DIR ?= bin
BIN := $(BIN_DIR)/flex-auth BIN := $(BIN_DIR)/access-engine
PKG := ./... PKG := ./...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)
LDFLAGS := -X main.version=$(VERSION) LDFLAGS := -X main.version=$(VERSION)
@ -10,7 +10,7 @@ all: vet lint test build
build: build:
@mkdir -p $(BIN_DIR) @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 test: check-layer
go test -race $(PKG) go test -race $(PKG)

View file

@ -99,7 +99,7 @@ can be coordinated behind a stable flex-auth API.
The standalone core is implemented and **all seven baseline workplans The standalone core is implemented and **all seven baseline workplans
(`FLEX-WP-0001` through `FLEX-WP-0007`) are complete.** The repository carries (`FLEX-WP-0001` through `FLEX-WP-0007`) are complete.** The repository carries
the intent baseline, authorization landscape research, ADR set, and a working 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 `POST /v1/check` plus registry, policy, decision, audit, Markitect, and
delegated-adapter internals. The standalone policy-as-code core (`FLEX-WP-0002`), delegated-adapter internals. The standalone policy-as-code core (`FLEX-WP-0002`),
Markitect consumer integration (`FLEX-WP-0003`, manifest ingest, decisions, and Markitect consumer integration (`FLEX-WP-0003`, manifest ingest, decisions, and

671
cmd/access-engine/main.go Normal file
View file

@ -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 <command> [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
}

View file

@ -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
}

View file

@ -79,7 +79,7 @@ func runServeReadback(args []string, _, stderr io.Writer) int {
return fail(stderr, err) 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 { if err := http.ListenAndServe(*addr, newReadbackMux(service, authenticator)); err != nil {
return fail(stderr, err) return fail(stderr, err)
} }

View file

@ -1,671 +1,19 @@
// Command flex-auth is the CLI entry point for the flex-auth authorization // Command flex-auth is retired. The binary is now access-engine
// registry and control plane. // (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 package main
import ( import (
"context"
"encoding/json"
"errors"
"flag"
"fmt" "fmt"
"io"
"net/http"
"os" "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=...". const renameMessage = `flex-auth: this binary has been renamed to access-engine (FLEX-DEC-2026-020).
var version = "0.0.0-dev" Use the same subcommands and flags with the new name:
go run ./cmd/access-engine <command> [options]
access-engine <command> [options]`
func main() { func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) fmt.Fprintln(os.Stderr, renameMessage)
} os.Exit(2)
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 <command> [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
} }

View file

@ -1,465 +1,26 @@
package main package main
import ( import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "os/exec"
"strings" "strings"
"testing" "testing"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/pkg/api"
) )
func TestRunVersion(t *testing.T) { // The retired binary must fail and name its replacement, whatever it is asked.
var stdout, stderr bytes.Buffer func TestRetiredBinaryNamesReplacement(t *testing.T) {
code := run([]string{"version"}, &stdout, &stderr) if os.Getenv("FLEX_AUTH_STUB_CHILD") == "1" {
if code != 0 { main()
t.Fatalf("code = %d, stderr = %s", code, stderr.String()) return
} }
if strings.TrimSpace(stdout.String()) == "" { cmd := exec.Command(os.Args[0], "-test.run=TestRetiredBinaryNamesReplacement", "serve", "--addr", "x")
t.Fatal("version output is empty") 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
}

View file

@ -2114,3 +2114,34 @@ without letting registry content decide an allow?
were issued for user-engine's client, so an `application.access` rule that were issued for user-engine's client, so an `application.access` rule that
reads audience, authorized party or client-scoped roles answers for the reads audience, authorized party or client-scoped roles answers for the
wrong client. This is a package-review checklist item. 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`).

View file

@ -232,7 +232,7 @@ or revoke surface.
```bash ```bash
# --registry and --package repeat once per participating system; # --registry and --package repeat once per participating system;
# system ids must not repeat across registries. # system ids must not repeat across registries.
flex-auth serve-readback \ access-engine serve-readback \
--registry examples/<system>/registry_snapshot.json \ --registry examples/<system>/registry_snapshot.json \
--package <system>=examples/<system>/policy_package.md \ --package <system>=examples/<system>/policy_package.md \
--caller-auth-mode enforce \ --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 `GET /v1/readback/systems`, and no enforcement route. `--readback-caller` is
bound under the key `application-readback`. That key is not a protected 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 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`.

View file

@ -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 | | 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. | | 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 | | Provenance | `registry_snapshot_digest` — SHA-256 of canonical snapshot JSON |

View file

@ -6,7 +6,7 @@ Status: implemented for FLEX-WP-0004 P4.6.
Delegated mode lets flex-auth coordinate external authorization systems Delegated mode lets flex-auth coordinate external authorization systems
without changing the protected-system-facing API. Protected systems keep 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, Topaz, relationship PDPs, rule PDPs, Keycloak Authorization Services,
and directory resolvers stay behind adapter boundaries. and directory resolvers stay behind adapter boundaries.

View file

@ -16,7 +16,7 @@ Ops-warden unblocker: WARDEN-WP-0009 T01
## Local service command ## 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. 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 Production should keep policy.fail_closed true unless an explicit break-glass
@ -91,7 +91,7 @@ Additional published assets:
Production runtime command: 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 Use http://flex-auth.flex-auth.svc.cluster.local:8080 when cluster DNS is
reachable from warden workstations. Otherwise use the approved operator tunnel reachable from warden workstations. Otherwise use the approved operator tunnel

View file

@ -26,7 +26,7 @@ from the same workstation:
Start the runtime with the production registry snapshot and the ops-warden Start the runtime with the production registry snapshot and the ops-warden
policy package: 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 The checked-in production snapshot is a non-secret fixture and initial load
target. Regenerate it from ops-warden inventory whenever actors, principals, or 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: 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 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 coverage, update examples/ops-warden/production_registry_snapshot.json when

View file

@ -157,11 +157,11 @@ be inferred from the key name:
precedence) precedence)
Package changes wait until T03 makes "read a declared registry key" a 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) ## 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 `POLICY-ATTRIBUTE-UNDECLARED` (error) for every
`input.{resource,subject}.attributes.<key>` the package reads that no sibling `input.{resource,subject}.attributes.<key>` the package reads that no sibling
`registry_snapshot.json`, `production_registry_snapshot.json`, `registry_snapshot.json`, `production_registry_snapshot.json`,

View file

@ -56,7 +56,7 @@ The readable set is every ops-warden catalog entry with `status: active`,
```bash ```bash
python3 tools/gen_ops_warden_fetch.py # rewrites the three generated files 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) 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`. `--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 ## Verify
```bash ```bash
go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md # 6 Rego tests, 15 fixtures go run ./cmd/access-engine 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 run ./cmd/access-engine load-registry -file examples/ops-warden-fetch/registry_snapshot.json
go test ./cmd/flex-auth -run OpsWardenFetch go test ./cmd/access-engine -run OpsWardenFetch
``` ```
## Not yet deployed ## Not yet deployed

View file

@ -22,13 +22,13 @@ Files:
Run locally: Run locally:
```bash ```bash
flex-auth validate --kind protected-system --file examples/ops-warden/protected_system_manifest.yaml access-engine 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 access-engine validate --kind resource-manifest --file examples/ops-warden/resource_manifest.yaml
flex-auth validate --kind subject-manifest --file examples/ops-warden/subject_manifest.yaml access-engine validate --kind subject-manifest --file examples/ops-warden/subject_manifest.yaml
flex-auth load-registry --file examples/ops-warden/registry_snapshot.json access-engine load-registry --file examples/ops-warden/registry_snapshot.json
flex-auth test-policy --file examples/ops-warden/policy_package.md access-engine 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 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
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 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, The fixture public-key fingerprints are examples only. Do not put real keys,
@ -44,7 +44,7 @@ changes.
Validate both registries locally: Validate both registries locally:
flex-auth load-registry --file examples/ops-warden/registry_snapshot.json access-engine 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/production_registry_snapshot.json
The production sync contract is documented in docs/ops-warden-registry-sync.md. The production sync contract is documented in docs/ops-warden-registry-sync.md.

View file

@ -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` | | `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_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 | | `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` | | `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 `flex-auth check` | | `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` **No `resource_manifest.yaml`** — like tenant-engine's tenants, `qonto-assistant`
doesn't have a fixed enumerable resource inventory to register; the single doesn't have a fixed enumerable resource inventory to register; the single
@ -26,26 +26,26 @@ gate.
## Verified ## Verified
```bash ```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: # 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: # 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: # Individual requests via the CLI:
bin/flex-auth check \ bin/access-engine check \
-registry examples/qonto-assistant/registry_snapshot.json \ -registry examples/qonto-assistant/registry_snapshot.json \
-policy examples/qonto-assistant/policy_package.md \ -policy examples/qonto-assistant/policy_package.md \
-request examples/qonto-assistant/check_request_allow_read.json -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 # registry+policy, hit by qonto-assistant's actual FlexAuthCheckClient (not
# a mock) -- see qonto-assistant/tests/test_flex_auth_client.py and # a mock) -- see qonto-assistant/tests/test_flex_auth_client.py and
# tests/test_live_authorization_gate.py, and the manual curl walkthrough # tests/test_live_authorization_gate.py, and the manual curl walkthrough
# below: # 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 \ -registry examples/qonto-assistant/registry_snapshot.json \
-policy examples/qonto-assistant/policy_package.md -policy examples/qonto-assistant/policy_package.md
# from another shell: # from another shell:

View file

@ -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. duration strings such as `15m` are rejected rather than guessed.
```bash ```bash
go run ./cmd/flex-auth load-registry --file examples/railiance-platform/registry_snapshot.json go run ./cmd/access-engine 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/access-engine 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/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/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/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/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 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
``` ```

View file

@ -21,8 +21,8 @@ Read that before changing any action string here.
## Verify ## Verify
```bash ```bash
go run ./cmd/flex-auth validate -kind policy -file examples/secrets-engine/policy_package.md go run ./cmd/access-engine 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 load-registry -file examples/secrets-engine/registry_snapshot.json
``` ```
28 Rego tests and 32 fixtures. 28 Rego tests and 32 fixtures.

View file

@ -15,7 +15,7 @@ to verify its digest join (`627810b`) unchanged. `FLEX-WP-0021-T03`.
Regenerate either with: Regenerate either with:
```bash ```bash
go run ./cmd/flex-auth check \ go run ./cmd/access-engine check \
-policy examples/secrets-engine/policy_package.md \ -policy examples/secrets-engine/policy_package.md \
-registry examples/secrets-engine/registry_snapshot.json \ -registry examples/secrets-engine/registry_snapshot.json \
-request examples/secrets-engine/check_request_allow_rotate.json -request examples/secrets-engine/check_request_allow_rotate.json

View file

@ -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`) | | `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_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 | | `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` | | `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 `flex-auth check` | | `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 **No `resource_manifest.yaml`** — unlike ops-warden's fixed SSH-certificate
inventory, `tenant-engine`'s resources (tenants) are created dynamically. inventory, `tenant-engine`'s resources (tenants) are created dynamically.
@ -23,26 +23,26 @@ omission, not an oversight.
## Verified ## Verified
```bash ```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: # 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: # 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: # Individual requests via the CLI:
bin/flex-auth check \ bin/access-engine check \
-registry examples/tenant-engine/registry_snapshot.json \ -registry examples/tenant-engine/registry_snapshot.json \
-policy examples/tenant-engine/policy_package.md \ -policy examples/tenant-engine/policy_package.md \
-request examples/tenant-engine/check_request_allow_create.json -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 # exact registry+policy, hit by tenant-engine's actual
# FlexAuthCheckClient/FlexAuthWriteAuthorizer (not a mock) -- # FlexAuthCheckClient/FlexAuthWriteAuthorizer (not a mock) --
# POST /tenants with actor="ops" -> 403 (unknown_subject); # POST /tenants with actor="ops" -> 403 (unknown_subject);
# actor="tenant-engine" -> 201 (write_api_policy_matched). # 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 \ -registry examples/tenant-engine/registry_snapshot.json \
-policy examples/tenant-engine/policy_package.md -policy examples/tenant-engine/policy_package.md
# (from tenant-engine's own checkout) # (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 `test-policy` reports **11/11 Rego tests and 16/16 fixtures passing**, the
registry loads, and `go test ./...` / `gofmt` / `go vet` are clean. 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` 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 (`TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098`) driven through its
unmodified `FlexAuthWriteAuthorizer` with `Idempotency-Key` and an `If-Match` 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` / registry loads (2 subjects, 2 groups), and `go test ./...` / `gofmt` /
`go vet` are clean. `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` 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 (`TENANT_ENGINE_FLEX_AUTH_URL=http://127.0.0.1:9098`) driven through its
unmodified `FlexAuthWriteAuthorizer`: unmodified `FlexAuthWriteAuthorizer`:

View file

@ -8,8 +8,8 @@ tenant administrators and self-service users may not.
Validate with: Validate with:
```bash ```bash
flex-auth test-policy -file examples/user-engine/policy_package.md access-engine test-policy -file examples/user-engine/policy_package.md
flex-auth load-registry -file examples/user-engine/registry_snapshot.json access-engine load-registry -file examples/user-engine/registry_snapshot.json
``` ```
## Live production (FLEX-WP-0009-T04, 2026-08-16) ## Live production (FLEX-WP-0009-T04, 2026-08-16)

View file

@ -124,7 +124,7 @@ evidence:
A2: A2:
- "internal/callerauth/auth.go" - "internal/callerauth/auth.go"
- "internal/callerauth/auth_test.go" - "internal/callerauth/auth_test.go"
- "cmd/flex-auth/main_test.go" - "cmd/access-engine/main_test.go"
- "charts/flex-auth/templates/rbac.yaml" - "charts/flex-auth/templates/rbac.yaml"
- "deploy/caller-auth-rbac.yaml" - "deploy/caller-auth-rbac.yaml"
- "docs/adr/0004-inbound-caller-authentication.md" - "docs/adr/0004-inbound-caller-authentication.md"

View file

@ -3,7 +3,7 @@ set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
declaration="$root/tenancy.yaml" declaration="$root/tenancy.yaml"
main="$root/cmd/flex-auth/main.go" main="$root/cmd/access-engine/main.go"
axis_value() { axis_value() {
local block="$1" axis="$2" local block="$1" axis="$2"

View file

@ -4,7 +4,7 @@ type: workplan
title: "Runtime rename: flex-auth to access-engine, run side by side, consumers switch one at a time" title: "Runtime rename: flex-auth to access-engine, run side by side, consumers switch one at a time"
domain: infotech domain: infotech
repo: access-engine repo: access-engine
status: proposed status: active
owner: claude owner: claude
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-10-10" created: "2026-10-10"
@ -65,7 +65,7 @@ written.
```task ```task
id: FLEX-WP-0036-T01 id: FLEX-WP-0036-T01
status: todo status: progress
priority: high priority: high
state_hub_task_id: "4f9a796d-832f-5936-8a34-eb980f6e1b57" 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. 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 ## Inventory every consumer reference and open handoffs
```task ```task
@ -123,7 +127,7 @@ founder's go.
```task ```task
id: FLEX-WP-0036-T04 id: FLEX-WP-0036-T04
status: todo status: progress
priority: medium priority: medium
depends_on: [FLEX-WP-0036-T01] depends_on: [FLEX-WP-0036-T01]
state_hub_task_id: "ff7fd369-326f-5044-aaa4-96c5f45c80e9" 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 Gate: build, tests and stage-1 render green; CI publishes both packages, and
both resolve to the same digest. 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 ## Stand up the access-engine namespace next to flex-auth
```task ```task