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
|
|
@ -1,671 +1,19 @@
|
|||
// Command flex-auth is the CLI entry point for the flex-auth authorization
|
||||
// registry and control plane.
|
||||
// Command flex-auth is retired. The binary is now access-engine
|
||||
// (FLEX-DEC-2026-020, FLEX-WP-0036). This stub exists only so that a stale
|
||||
// invocation fails with the new name instead of a missing-file error.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/audit"
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
decisioncore "github.com/netkingdom/flex-auth/internal/decision"
|
||||
"github.com/netkingdom/flex-auth/internal/emission"
|
||||
"github.com/netkingdom/flex-auth/internal/policy"
|
||||
"github.com/netkingdom/flex-auth/internal/registry"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// version is set at build time via -ldflags "-X main.version=...".
|
||||
var version = "0.0.0-dev"
|
||||
const renameMessage = `flex-auth: this binary has been renamed to access-engine (FLEX-DEC-2026-020).
|
||||
Use the same subcommands and flags with the new name:
|
||||
go run ./cmd/access-engine <command> [options]
|
||||
access-engine <command> [options]`
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printUsage(stderr)
|
||||
return 64
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "version", "--version", "-version":
|
||||
fmt.Fprintln(stdout, version)
|
||||
return 0
|
||||
case "validate":
|
||||
return runValidate(args[1:], stdout, stderr)
|
||||
case "load-registry":
|
||||
return runLoadRegistry(args[1:], stdout, stderr)
|
||||
case "test-policy":
|
||||
return runTestPolicy(args[1:], stdout, stderr)
|
||||
case "check":
|
||||
return runCheck(args[1:], stdout, stderr)
|
||||
case "batch-check":
|
||||
return runBatchCheck(args[1:], stdout, stderr)
|
||||
case "list-allowed":
|
||||
return runListAllowed(args[1:], stdout, stderr)
|
||||
case "explain":
|
||||
return runExplain(args[1:], stdout, stderr)
|
||||
case "serve":
|
||||
return runServe(args[1:], stdout, stderr)
|
||||
case "serve-readback":
|
||||
return runServeReadback(args[1:], stdout, stderr)
|
||||
case "help", "-h", "--help":
|
||||
printUsage(stdout)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "flex-auth: unknown subcommand %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
return 64
|
||||
}
|
||||
}
|
||||
|
||||
func runValidate(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("validate", stderr)
|
||||
kind := fs.String("kind", "", "resource-manifest, subject-manifest, protected-system, relationship, access-descriptor, or policy")
|
||||
file := fs.String("file", "", "YAML/JSON file to validate")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *kind == "" || *file == "" {
|
||||
fmt.Fprintln(stderr, "validate requires --kind and --file")
|
||||
return 64
|
||||
}
|
||||
|
||||
switch *kind {
|
||||
case "resource-manifest":
|
||||
var manifest api.ResourceManifest
|
||||
if err := readYAML(*file, &manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().ImportResourceManifest(manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "subject-manifest":
|
||||
var manifest api.SubjectManifest
|
||||
if err := readYAML(*file, &manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().ImportSubjectManifest(manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "protected-system":
|
||||
var manifest api.ProtectedSystemManifest
|
||||
if err := readYAML(*file, &manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().PutProtectedSystem(manifest); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "relationship":
|
||||
var fact api.RelationshipFact
|
||||
if err := readYAML(*file, &fact); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := registry.NewStore().PutRelationship(fact); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "access-descriptor":
|
||||
var descriptor api.CaringAccessDescriptor
|
||||
if err := readYAML(*file, &descriptor); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := validateDescriptor(descriptor); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
case "policy", "policy-package":
|
||||
pkg, err := policy.LoadAndValidateFile(context.Background(), *file)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, pkg.Validation); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if !pkg.Valid {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unsupported validate kind %q\n", *kind)
|
||||
return 64
|
||||
}
|
||||
|
||||
return writeStatus(stdout, "valid", map[string]any{"kind": *kind, "file": *file})
|
||||
}
|
||||
|
||||
func runLoadRegistry(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("load-registry", stderr)
|
||||
file := fs.String("file", "", "registry snapshot JSON file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *file == "" {
|
||||
fmt.Fprintln(stderr, "load-registry requires --file")
|
||||
return 64
|
||||
}
|
||||
|
||||
store, err := registry.LoadFile(*file)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
snapshot := store.Snapshot()
|
||||
return writeStatus(stdout, "loaded", map[string]any{
|
||||
"file": *file,
|
||||
"systems": len(snapshot.Systems),
|
||||
"resource_manifests": len(snapshot.ResourceManifests),
|
||||
"subjects": len(snapshot.Subjects),
|
||||
"groups": len(snapshot.Groups),
|
||||
"teams": len(snapshot.Teams),
|
||||
"tenants": len(snapshot.Tenants),
|
||||
"relationships": len(snapshot.Relationships),
|
||||
})
|
||||
}
|
||||
|
||||
func runTestPolicy(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("test-policy", stderr)
|
||||
file := fs.String("file", "", "policy package Markdown file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *file == "" {
|
||||
fmt.Fprintln(stderr, "test-policy requires --file")
|
||||
return 64
|
||||
}
|
||||
|
||||
pkg, err := policy.LoadAndValidateFile(context.Background(), *file)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, pkg.Validation); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if !pkg.Valid {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runCheck(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("check", stderr)
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
requestPath := fs.String("request", "", "check request YAML/JSON file")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" || *requestPath == "" {
|
||||
fmt.Fprintln(stderr, "check requires --registry, --policy, and --request")
|
||||
return 64
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var request api.CheckRequest
|
||||
if err := readYAML(*requestPath, &request); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
decision, err := engine.Check(context.Background(), request)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, decision); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runBatchCheck(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("batch-check", stderr)
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
requestPath := fs.String("request", "", "batch check request YAML/JSON file")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" || *requestPath == "" {
|
||||
fmt.Fprintln(stderr, "batch-check requires --registry, --policy, and --request")
|
||||
return 64
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var request api.BatchCheckRequest
|
||||
if err := readYAML(*requestPath, &request); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
decisions, err := engine.BatchCheck(context.Background(), request)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, decisions); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runListAllowed(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("list-allowed", stderr)
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
subject := fs.String("subject", "", "subject id")
|
||||
action := fs.String("action", "", "action name")
|
||||
system := fs.String("system", "", "protected system id")
|
||||
resourceType := fs.String("resource-type", "", "resource type")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
var filters keyValueFlags
|
||||
fs.Var(&filters, "filter", "resource filter as key=value; may be repeated")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" || *subject == "" || *action == "" {
|
||||
fmt.Fprintln(stderr, "list-allowed requires --registry, --policy, --subject, and --action")
|
||||
return 64
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
decisions, err := engine.ListAllowed(context.Background(), decisioncore.ListAllowedRequest{
|
||||
Subject: api.SubjectRef{ID: *subject},
|
||||
Action: *action,
|
||||
System: *system,
|
||||
ResourceType: *resourceType,
|
||||
Filters: filters.Map(),
|
||||
})
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if err := writeJSON(stdout, decisions); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runExplain(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("explain", stderr)
|
||||
logPath := fs.String("decision-log", "", "JSONL decision log path")
|
||||
decisionID := fs.String("decision-id", "", "decision id to explain")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *logPath == "" || *decisionID == "" {
|
||||
fmt.Fprintln(stderr, "explain requires --decision-log and --decision-id")
|
||||
return 64
|
||||
}
|
||||
|
||||
log := audit.NewJSONLDecisionLog(*logPath)
|
||||
envelope, ok, err := log.Find(*decisionID)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if !ok {
|
||||
return fail(stderr, fmt.Errorf("decision %q not found", *decisionID))
|
||||
}
|
||||
if err := writeJSON(stdout, decisioncore.ExplainEnvelope(envelope)); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runServe(args []string, stdout, stderr io.Writer) int {
|
||||
fs := newFlagSet("serve", stderr)
|
||||
addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address")
|
||||
registryPath := fs.String("registry", "", "registry snapshot JSON file")
|
||||
policyPath := fs.String("policy", "", "policy package Markdown file")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log")
|
||||
emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine")
|
||||
auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats")
|
||||
auditTokenFile := fs.String("audit-token-file", "", "mounted sender token file, never a token value")
|
||||
callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce")
|
||||
callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience")
|
||||
callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview")
|
||||
callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential")
|
||||
callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle")
|
||||
var callerBindings keyValueFlags
|
||||
fs.Var(&callerBindings, "caller-binding", "resource system=authenticated Kubernetes principal (repeatable)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if *registryPath == "" || *policyPath == "" {
|
||||
fmt.Fprintln(stderr, "serve requires --registry and --policy")
|
||||
return 64
|
||||
}
|
||||
|
||||
if *outboxDir != "" && (*logPath != "" || *emissionSource == "") {
|
||||
fmt.Fprintln(stderr, "serve --outbox-dir requires --emission-source and excludes --log")
|
||||
return 64
|
||||
}
|
||||
delivery := emission.DeliveryConfig{URL: *auditURL, TokenFile: *auditTokenFile}
|
||||
if *auditURL != "" || *auditTokenFile != "" {
|
||||
if *outboxDir == "" {
|
||||
fmt.Fprintln(stderr, "audit delivery requires --outbox-dir")
|
||||
return 64
|
||||
}
|
||||
if err := delivery.Validate(); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 64
|
||||
}
|
||||
}
|
||||
|
||||
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
var outbox *emission.Outbox
|
||||
if *outboxDir != "" {
|
||||
outbox, err = emission.Open(*outboxDir, *emissionSource)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
defer outbox.Close()
|
||||
engine.SetDecisionLog(outbox)
|
||||
}
|
||||
|
||||
authenticator, err := buildCallerAuthenticator(
|
||||
callerauth.Mode(*callerAuthMode),
|
||||
*callerAudience,
|
||||
*callerKubernetesURL,
|
||||
*callerReviewerTokenFile,
|
||||
*callerCAFile,
|
||||
callerBindings.StringMap(),
|
||||
stderr,
|
||||
)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
|
||||
mux := newServeMuxWithCallerAuth(engine, authenticator)
|
||||
if outbox != nil {
|
||||
mountEmission(mux, outbox)
|
||||
}
|
||||
if *auditURL != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
runEmissionDelivery(ctx, outbox, delivery, stderr)
|
||||
}()
|
||||
defer func() { cancel(); <-done }()
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runEmissionDelivery(ctx context.Context, outbox *emission.Outbox, config emission.DeliveryConfig, stderr io.Writer) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := outbox.AppendDueHeartbeats(); err != nil {
|
||||
fmt.Fprintf(stderr, "emission heartbeat commit: %v\n", err)
|
||||
}
|
||||
n, err := outbox.Drain(ctx, config, 100)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
fmt.Fprintf(stderr, "emission drain: %v\n", err)
|
||||
}
|
||||
if err == nil && n == 100 {
|
||||
continue // Drain the next bounded batch without imposing a rate cap.
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
|
||||
return newServeMuxWithCallerAuth(engine, callerauth.Disabled())
|
||||
}
|
||||
|
||||
func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *callerauth.Authenticator) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var request api.CheckRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if request.Purpose != "" {
|
||||
// Only the readback pin issues purpose-bearing decisions
|
||||
// (FLEX-DEC-2026-019); an enforcement pin never does.
|
||||
http.Error(w, "purpose is not accepted on /v1/check", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System})
|
||||
if err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decision, err := engine.Check(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request)
|
||||
writeHTTP(w, decision, err)
|
||||
})
|
||||
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var request api.BatchCheckRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
systems := make([]string, 0, len(request.Resources))
|
||||
for _, resource := range request.Resources {
|
||||
systems = append(systems, resource.System)
|
||||
}
|
||||
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems)
|
||||
if err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decisions, err := engine.BatchCheck(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request)
|
||||
writeHTTP(w, decisions, err)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func buildCallerAuthenticator(mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string, stderr io.Writer) (*callerauth.Authenticator, error) {
|
||||
if mode == callerauth.ModeDisabled {
|
||||
return callerauth.New(mode, nil, "", nil, nil)
|
||||
}
|
||||
reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return callerauth.New(mode, reviewer, audience, bindings, func(format string, args ...any) {
|
||||
fmt.Fprintf(stderr, format+"\n", args...)
|
||||
})
|
||||
}
|
||||
|
||||
func writeCallerAuthError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, callerauth.ErrUnauthenticated):
|
||||
http.Error(w, "unauthenticated", http.StatusUnauthorized)
|
||||
case errors.Is(err, callerauth.ErrForbidden):
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, "caller authentication unavailable", http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) {
|
||||
store, err := registry.LoadFile(registryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg, err := policy.LoadAndValidateFile(ctx, policyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engine, err := decisioncore.NewEngine(store, pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logPath != "" {
|
||||
engine.SetDecisionLog(audit.NewJSONLDecisionLog(logPath))
|
||||
}
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func validateDescriptor(descriptor api.CaringAccessDescriptor) error {
|
||||
if descriptor.Profile != api.CaringProfileCaring040RC2 {
|
||||
return fmt.Errorf("unsupported CARING profile %q", descriptor.Profile)
|
||||
}
|
||||
if descriptor.SubjectType == "" {
|
||||
return fmt.Errorf("subject_type is required")
|
||||
}
|
||||
if descriptor.OrganizationRelation == "" {
|
||||
return fmt.Errorf("organization_relation is required")
|
||||
}
|
||||
if descriptor.CanonicalRole == "" {
|
||||
return fmt.Errorf("canonical_role is required")
|
||||
}
|
||||
if descriptor.Scope.Level == "" || descriptor.Scope.ID == "" {
|
||||
return fmt.Errorf("scope.level and scope.id are required")
|
||||
}
|
||||
if len(descriptor.Planes) == 0 {
|
||||
return fmt.Errorf("at least one plane is required")
|
||||
}
|
||||
if len(descriptor.Capabilities) == 0 {
|
||||
return fmt.Errorf("at least one capability is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readYAML(path string, out any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("unmarshal %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStatus(w io.Writer, status string, extra map[string]any) int {
|
||||
out := map[string]any{"status": status}
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
if err := writeJSON(w, out); err != nil {
|
||||
return fail(w, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mountEmission exposes the local half of reconciliation: committed and
|
||||
// released-uncommitted counts per class. Counts only, never records.
|
||||
func mountEmission(mux *http.ServeMux, outbox *emission.Outbox) {
|
||||
mux.HandleFunc("/v1/emission", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeHTTP(w, outbox.Status(), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func writeHTTP(w http.ResponseWriter, value any, err error) {
|
||||
w.Header().Set("content-type", "application/json")
|
||||
if errors.Is(err, decisioncore.ErrRecordNotCommitted) {
|
||||
// The consumer's declared stance applies (FLEX-DEC-2026-018).
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func newFlagSet(name string, stderr io.Writer) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
return fs
|
||||
}
|
||||
|
||||
func fail(stderr io.Writer, err error) int {
|
||||
fmt.Fprintln(stderr, "flex-auth:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage: flex-auth <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
|
||||
fmt.Fprintln(os.Stderr, renameMessage)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue