FLEX-WP-0036: binary renamed to access-engine; flex-auth is a stub naming the rename (FLEX-DEC-2026-020)
- cmd/flex-auth -> cmd/access-engine; usage and serving lines say access-engine - cmd/flex-auth: prints the rename and exits 2, with a test - image: entrypoint /usr/local/bin/access-engine, flex-auth stub alongside; live pins pass only args, so their next roll is unaffected - Makefile, CI, verify-posture, tenancy.yaml, live docs updated; workplans, decisions and ADRs left as written - runtime names (module, audience, k8s, image package, /opt paths) unchanged Co-Authored-By: Claude Opus 5.5 <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:
parent
c284344925
commit
0ced2995ee
36 changed files with 1268 additions and 1178 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -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)
|
||||||
|
|
|
||||||
2
SCOPE.md
2
SCOPE.md
|
|
@ -99,7 +99,7 @@ can be coordinated behind a stable flex-auth API.
|
||||||
The standalone core is implemented and **all seven baseline workplans
|
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
671
cmd/access-engine/main.go
Normal 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
|
||||||
|
}
|
||||||
465
cmd/access-engine/main_test.go
Normal file
465
cmd/access-engine/main_test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue