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
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
|
||||
}
|
||||
219
cmd/access-engine/ops_warden_fetch_test.go
Normal file
219
cmd/access-engine/ops_warden_fetch_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// The ops-warden secret-read pin (CUST-WP-0075, bug b792c5d0): warden's
|
||||
// check_fetch_policy sends action read on resource type secret with
|
||||
// resource.system = the lane's owner_repo.
|
||||
|
||||
const opsWardenPrincipal = "system:serviceaccount:ops-warden:ops-warden"
|
||||
|
||||
func fetchPath(name string) string {
|
||||
return filepath.Join("..", "..", "examples", "ops-warden-fetch", name)
|
||||
}
|
||||
|
||||
func loadFetchRequest(t *testing.T, name string) api.CheckRequest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(fetchPath(name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request api.CheckRequest
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func TestOpsWardenFetchDecisions(t *testing.T) {
|
||||
engine, err := buildEngine(context.Background(), fetchPath("registry_snapshot.json"), fetchPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
file string
|
||||
effect api.DecisionEffect
|
||||
reason string
|
||||
}{
|
||||
{"check_request_allow_forgejo_admin_api_token.json", api.DecisionEffectAllow, "secret_read_lane_matched"},
|
||||
{"check_request_allow_openrouter_llm_connect.json", api.DecisionEffectAllow, "secret_read_lane_matched"},
|
||||
{"check_request_deny_unknown_need.json", api.DecisionEffectDeny, "unknown_need"},
|
||||
{"check_request_deny_wrong_system.json", api.DecisionEffectDeny, "wrong_system"},
|
||||
{"check_request_deny_non_secret_type.json", api.DecisionEffectDeny, "wrong_resource_type"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
decision, err := engine.Check(context.Background(), loadFetchRequest(t, tc.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Effect != tc.effect || decision.Reason != tc.reason {
|
||||
t.Fatalf("decision = %s/%s; want %s/%s", decision.Effect, decision.Reason, tc.effect, tc.reason)
|
||||
}
|
||||
if decision.ID == "" {
|
||||
t.Fatal("decision.ID is empty; warden needs a policy_decision_id")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fetchBindings reads the caller bindings exactly as the values file declares
|
||||
// them, so the test exercises the configuration that would be deployed.
|
||||
func fetchBindings(t *testing.T) (string, map[string]string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "values", "ops-warden-fetch.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var values struct {
|
||||
CallerAuth struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Binding string `yaml:"binding"`
|
||||
ExtraBindings []string `yaml:"extraBindings"`
|
||||
} `yaml:"callerAuth"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindings := map[string]string{}
|
||||
for _, pair := range append([]string{values.CallerAuth.Binding}, values.CallerAuth.ExtraBindings...) {
|
||||
system, principal, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
t.Fatalf("malformed binding %q", pair)
|
||||
}
|
||||
bindings[system] = principal
|
||||
}
|
||||
return values.CallerAuth.Mode, bindings
|
||||
}
|
||||
|
||||
func TestOpsWardenFetchCallerBindings(t *testing.T) {
|
||||
mode, bindings := fetchBindings(t)
|
||||
if mode != "enforce" {
|
||||
t.Fatalf("callerAuth.mode = %q; the fetch pin must enforce", mode)
|
||||
}
|
||||
for system, principal := range bindings {
|
||||
if principal != opsWardenPrincipal {
|
||||
t.Fatalf("system %q bound to %q; only ops-warden may ask", system, principal)
|
||||
}
|
||||
}
|
||||
engine, err := buildEngine(context.Background(), fetchPath("registry_snapshot.json"), fetchPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
serve := func(username string) *httptest.Server {
|
||||
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||
Username: username,
|
||||
Audiences: []string{"flex-auth"},
|
||||
}}, "flex-auth", bindings, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator))
|
||||
}
|
||||
post := func(server *httptest.Server, request api.CheckRequest, token bool) (int, api.DecisionEnvelope) {
|
||||
body, _ := json.Marshal(request)
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body))
|
||||
req.Header.Set("content-type", "application/json")
|
||||
if token {
|
||||
req.Header.Set("authorization", "Bearer workload-token")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var envelope api.DecisionEnvelope
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, envelope
|
||||
}
|
||||
|
||||
warden := serve(opsWardenPrincipal)
|
||||
defer warden.Close()
|
||||
allow := loadFetchRequest(t, "check_request_allow_forgejo_admin_api_token.json")
|
||||
|
||||
if code, envelope := post(warden, allow, true); code != http.StatusOK || envelope.Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("ops-warden forgejo read = %d %s/%s; want 200 allow", code, envelope.Effect, envelope.Reason)
|
||||
}
|
||||
if code, _ := post(warden, allow, false); code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous = %d; want 401", code)
|
||||
}
|
||||
unbound := allow
|
||||
unbound.Resource.System = "secrets-engine"
|
||||
unbound.Context = map[string]any{"need_id": "forgejo-admin-api-token", "owner_repo": "secrets-engine"}
|
||||
if code, _ := post(warden, unbound, true); code != http.StatusForbidden {
|
||||
t.Fatalf("ops-warden on unbound system = %d; want 403", code)
|
||||
}
|
||||
|
||||
other := serve("system:serviceaccount:another:caller")
|
||||
defer other.Close()
|
||||
if code, _ := post(other, allow, true); code != http.StatusForbidden {
|
||||
t.Fatalf("wrong caller = %d; want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpsWardenFetchRegistryMatchesPolicy keeps the two generated artifacts in
|
||||
// step: every catalog lane in the package is a registry resource of its owner
|
||||
// system, and nothing else is. Drift against the catalog itself is
|
||||
// tools/gen_ops_warden_fetch.py --check (tests/stage1.sh).
|
||||
func TestOpsWardenFetchRegistryMatchesPolicy(t *testing.T) {
|
||||
raw, err := os.ReadFile(fetchPath("policy_package.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block := regexp.MustCompile(`(?s)catalog_lanes := \{(.*?)\}`).FindSubmatch(raw)
|
||||
if block == nil {
|
||||
t.Fatal("catalog_lanes block not found")
|
||||
}
|
||||
var fromPolicy []string
|
||||
for _, m := range regexp.MustCompile(`"([^"]+)": "([^"]+)"`).FindAllSubmatch(block[1], -1) {
|
||||
fromPolicy = append(fromPolicy, string(m[2])+"/secret:"+string(m[1]))
|
||||
}
|
||||
|
||||
snapRaw, err := os.ReadFile(fetchPath("registry_snapshot.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snap struct {
|
||||
ResourceManifests []struct {
|
||||
System string `json:"system"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"resources"`
|
||||
} `json:"resource_manifests"`
|
||||
}
|
||||
if err := json.Unmarshal(snapRaw, &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fromRegistry []string
|
||||
for _, manifest := range snap.ResourceManifests {
|
||||
for _, resource := range manifest.Resources {
|
||||
fromRegistry = append(fromRegistry, manifest.System+"/"+resource.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(fromPolicy)
|
||||
sort.Strings(fromRegistry)
|
||||
if len(fromPolicy) == 0 || strings.Join(fromPolicy, ",") != strings.Join(fromRegistry, ",") {
|
||||
t.Fatalf("policy lanes %v\nregistry %v", fromPolicy, fromRegistry)
|
||||
}
|
||||
}
|
||||
167
cmd/access-engine/readback.go
Normal file
167
cmd/access-engine/readback.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/audit"
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
"github.com/netkingdom/flex-auth/internal/policy"
|
||||
"github.com/netkingdom/flex-auth/internal/readback"
|
||||
"github.com/netkingdom/flex-auth/internal/registry"
|
||||
)
|
||||
|
||||
// readbackBinding is the caller-binding key the readback routes authorize
|
||||
// against. It is not a protected system, so a principal bound to it can never
|
||||
// represent a system on any pin's /v1/check (FLEX-WP-0035-T02).
|
||||
const readbackBinding = "application-readback"
|
||||
|
||||
type repeatedFlag []string
|
||||
|
||||
func (f *repeatedFlag) String() string { return strings.Join(*f, ",") }
|
||||
|
||||
func (f *repeatedFlag) Set(value string) error {
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runServeReadback serves the advisory application-access readback. It mounts
|
||||
// no /v1/check or /v1/batch_check: this pin cannot produce an enforcement
|
||||
// decision (FLEX-DEC-2026-019).
|
||||
func runServeReadback(args []string, _, stderr io.Writer) int {
|
||||
fs := newFlagSet("serve-readback", stderr)
|
||||
addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address")
|
||||
logPath := fs.String("log", "", "optional JSONL decision log path")
|
||||
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")
|
||||
readbackCaller := fs.String("readback-caller", "", "authenticated Kubernetes principal admitted to the readback routes")
|
||||
var registries repeatedFlag
|
||||
fs.Var(®istries, "registry", "registry snapshot JSON file (repeatable; system ids must not repeat)")
|
||||
var packages keyValueFlags
|
||||
fs.Var(&packages, "package", "system=policy package Markdown file (repeatable)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 64
|
||||
}
|
||||
if len(registries) == 0 || len(packages) == 0 {
|
||||
fmt.Fprintln(stderr, "serve-readback requires --registry and --package")
|
||||
return 64
|
||||
}
|
||||
bindings := map[string]string{}
|
||||
if *readbackCaller != "" {
|
||||
bindings[readbackBinding] = *readbackCaller
|
||||
}
|
||||
|
||||
service, err := buildReadback(context.Background(), registries, packages.StringMap())
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
if *logPath != "" {
|
||||
service.SetDecisionLog(audit.NewJSONLDecisionLog(*logPath))
|
||||
}
|
||||
authenticator, err := buildCallerAuthenticator(
|
||||
callerauth.Mode(*callerAuthMode),
|
||||
*callerAudience,
|
||||
*callerKubernetesURL,
|
||||
*callerReviewerTokenFile,
|
||||
*callerCAFile,
|
||||
bindings,
|
||||
stderr,
|
||||
)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "access-engine readback serving on http://%s\n", *addr)
|
||||
if err := http.ListenAndServe(*addr, newReadbackMux(service, authenticator)); err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildReadback(ctx context.Context, registryPaths []string, packagePaths map[string]string) (*readback.Service, error) {
|
||||
merged := registry.NewStore()
|
||||
seen := map[string]string{}
|
||||
for _, path := range registryPaths {
|
||||
store, err := registry.LoadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot := store.Snapshot()
|
||||
for _, system := range snapshot.Systems {
|
||||
if first, dup := seen[system.ID]; dup {
|
||||
return nil, fmt.Errorf("system %q appears in both %s and %s", system.ID, first, path)
|
||||
}
|
||||
seen[system.ID] = path
|
||||
}
|
||||
if err := merged.ApplySnapshot(snapshot); err != nil {
|
||||
return nil, fmt.Errorf("merge %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
loaded := make(map[string]*policy.Package, len(packagePaths))
|
||||
for system, path := range packagePaths {
|
||||
pkg, err := policy.LoadAndValidateFile(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded[system] = pkg
|
||||
}
|
||||
return readback.New(merged, loaded)
|
||||
}
|
||||
|
||||
func newReadbackMux(service *readback.Service, 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"})
|
||||
})
|
||||
authorize := func(w http.ResponseWriter, r *http.Request) (context.Context, bool) {
|
||||
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{readbackBinding})
|
||||
if err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return nil, false
|
||||
}
|
||||
return callerauth.WithRecord(r.Context(), authenticator.Record(identity)), true
|
||||
}
|
||||
mux.HandleFunc("/v1/readback/application_access", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var request readback.Request
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields() // no action, resource or purpose can be smuggled in
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, ok := authorize(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.Evaluate(ctx, request)
|
||||
if errors.Is(err, readback.ErrInvalidRequest) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeHTTP(w, response, err)
|
||||
})
|
||||
mux.HandleFunc("/v1/readback/systems", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if _, ok := authorize(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeHTTP(w, service.Systems(), nil)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
72
cmd/access-engine/readback_pin_test.go
Normal file
72
cmd/access-engine/readback_pin_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type pinValues struct {
|
||||
Image struct {
|
||||
Digest string `yaml:"digest"`
|
||||
} `yaml:"image"`
|
||||
Args []string `yaml:"args"`
|
||||
}
|
||||
|
||||
func loadPinValues(t *testing.T, name string) pinValues {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "values", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v pinValues
|
||||
if err := yaml.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func argAfter(args []string, flag string) []string {
|
||||
var out []string
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
out = append(out, args[i+1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FLEX-WP-0035-T04, contract §4: the readback pin runs each participant's
|
||||
// package at the same digest its own pin runs. The package is baked into the
|
||||
// image, so same image digest and same path is the same package digest.
|
||||
func TestReadbackPinMatchesParticipantPins(t *testing.T) {
|
||||
readback := loadPinValues(t, "application-readback.yaml")
|
||||
if len(readback.Args) == 0 || readback.Args[0] != "serve-readback" {
|
||||
t.Fatal("readback pin must run serve-readback, which mounts no /v1/check")
|
||||
}
|
||||
participants := map[string]string{"informed-decision": "informed-decision-lifecycle.yaml"}
|
||||
packages := argAfter(readback.Args, "--package")
|
||||
if len(packages) != len(participants) {
|
||||
t.Fatalf("readback packages %v, want one per participant %v", packages, participants)
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
system, path, _ := strings.Cut(pkg, "=")
|
||||
file, ok := participants[system]
|
||||
if !ok {
|
||||
t.Fatalf("readback package for %q has no participant pin", system)
|
||||
}
|
||||
own := loadPinValues(t, file)
|
||||
if own.Image.Digest != readback.Image.Digest {
|
||||
t.Errorf("%s: readback image %s, own pin %s", system, readback.Image.Digest, own.Image.Digest)
|
||||
}
|
||||
if policies := argAfter(own.Args, "--policy"); len(policies) != 1 || policies[0] != path {
|
||||
t.Errorf("%s: readback package %s, own pin %v", system, path, policies)
|
||||
}
|
||||
if !strings.Contains(strings.Join(argAfter(readback.Args, "--registry"), " "), strings.Join(argAfter(own.Args, "--registry"), " ")) {
|
||||
t.Errorf("%s: readback pin does not load its registry", system)
|
||||
}
|
||||
}
|
||||
}
|
||||
345
cmd/access-engine/readback_test.go
Normal file
345
cmd/access-engine/readback_test.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/audit"
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
"github.com/netkingdom/flex-auth/internal/readback"
|
||||
"github.com/netkingdom/flex-auth/internal/schemaguard"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// FLEX-WP-0035: application access readback.
|
||||
|
||||
const readbackPrincipal = "system:serviceaccount:user-engine:user-engine"
|
||||
|
||||
func readbackPath(parts ...string) string {
|
||||
return filepath.Join(append([]string{"testdata", "readback"}, parts...)...)
|
||||
}
|
||||
|
||||
func newTestReadback(t *testing.T) *readback.Service {
|
||||
t.Helper()
|
||||
service, err := buildReadback(context.Background(),
|
||||
[]string{readbackPath("demo-app", "registry_snapshot.json"), readbackPath("other-app", "registry_snapshot.json")},
|
||||
map[string]string{
|
||||
"demo-app": readbackPath("demo-app", "policy_package.md"),
|
||||
"other-app": readbackPath("other-app", "policy_package.md"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildReadback: %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func readbackAuthenticator(t *testing.T, principal string) *callerauth.Authenticator {
|
||||
t.Helper()
|
||||
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||
Username: principal,
|
||||
Audiences: []string{"flex-auth"},
|
||||
}}, "flex-auth", map[string]string{readbackBinding: readbackPrincipal}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return authenticator
|
||||
}
|
||||
|
||||
func subjectRequest(id string, mfa bool, systems ...string) readback.Request {
|
||||
return readback.Request{
|
||||
Tenant: "tenant:platform",
|
||||
Subject: api.SubjectRef{ID: id, Type: "user", Attributes: map[string]any{
|
||||
"groups": []any{"app-users"},
|
||||
"assurance": map[string]any{"level": "aal2", "mfa": mfa},
|
||||
}},
|
||||
Systems: systems,
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadbackRowsInRequestOrderAndNeverOmitted(t *testing.T) {
|
||||
service := newTestReadback(t)
|
||||
logPath := filepath.Join(t.TempDir(), "decisions.jsonl")
|
||||
service.SetDecisionLog(audit.NewJSONLDecisionLog(logPath))
|
||||
|
||||
response, err := service.Evaluate(context.Background(), subjectRequest("user:alice", true, "other-app", "demo-app", "unknown-app"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.RegistrySnapshotDigest == "" || len(response.Results) != 3 {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
other, demo, unknown := response.Results[0], response.Results[1], response.Results[2]
|
||||
// other-app's rule would allow; the package does not declare the action.
|
||||
if other.System != "other-app" || other.Effect != api.DecisionEffectNotApplicable || other.Reason != readback.ReasonNotOffered || other.DecisionID != "" {
|
||||
t.Fatalf("undeclared row = %+v", other)
|
||||
}
|
||||
if unknown.System != "unknown-app" || unknown.Effect != api.DecisionEffectNotApplicable {
|
||||
t.Fatalf("unknown row = %+v", unknown)
|
||||
}
|
||||
if demo.Effect != api.DecisionEffectAllow || demo.Lifetime == nil || demo.Lifetime.ExpiresAt == "" || demo.PolicyPackageDigest == "" || demo.RevocationVisibility["claims"] != "immediate" {
|
||||
t.Fatalf("allow row = %+v", demo)
|
||||
}
|
||||
|
||||
// The recorded decision carries purpose readback inside its digests.
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var recorded api.DecisionEnvelope
|
||||
if err := json.Unmarshal(bytes.TrimSpace(raw), &recorded); err != nil {
|
||||
t.Fatalf("decision log: %v", err)
|
||||
}
|
||||
if recorded.ID != demo.DecisionID || recorded.Binding.Purpose != api.PurposeReadback || recorded.Binding.Action != readback.ApplicationAccess {
|
||||
t.Fatalf("recorded binding = %+v", recorded.Binding)
|
||||
}
|
||||
enforcement := api.CheckRequest{Subject: recorded.Binding.Subject, Action: recorded.Binding.Action, Resource: recorded.Binding.Resource, Tenant: recorded.Binding.Tenant}
|
||||
if api.RequestDigest(enforcement) == recorded.Binding.RequestDigest {
|
||||
t.Fatal("readback request_digest equals the enforcement digest; a PEP could accept it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadbackDeniesWithoutMFA(t *testing.T) {
|
||||
response, err := newTestReadback(t).Evaluate(context.Background(), subjectRequest("user:bob", false, "demo-app"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := response.Results[0]; got.Effect != api.DecisionEffectDeny || got.Lifetime != nil && got.Lifetime.ExpiresAt != "" {
|
||||
t.Fatalf("row = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadbackSystemsProjection(t *testing.T) {
|
||||
systems := newTestReadback(t).Systems()
|
||||
byID := map[string]readback.System{}
|
||||
for _, system := range systems.Systems {
|
||||
byID[system.ID] = system
|
||||
}
|
||||
demo, other := byID["demo-app"], byID["other-app"]
|
||||
if !demo.Readback || demo.ClientBinding == nil || demo.ClientBinding.ClientID != "demo-app" || demo.Name != "Demo App" {
|
||||
t.Fatalf("demo-app = %+v", demo)
|
||||
}
|
||||
if other.Readback || other.ClientBinding != nil {
|
||||
t.Fatalf("other-app = %+v", other)
|
||||
}
|
||||
if systems.RegistrySnapshotDigest == "" {
|
||||
t.Fatal("projection has no snapshot digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadbackRefusesDuplicateSystemAcrossRegistries(t *testing.T) {
|
||||
path := readbackPath("demo-app", "registry_snapshot.json")
|
||||
_, err := buildReadback(context.Background(), []string{path, path}, map[string]string{"demo-app": readbackPath("demo-app", "policy_package.md")})
|
||||
if err == nil || !strings.Contains(err.Error(), "appears in both") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func postReadback(t *testing.T, url string, body string) *http.Response {
|
||||
t.Helper()
|
||||
request, _ := http.NewRequest(http.MethodPost, url+"/v1/readback/application_access", strings.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer token")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
return response
|
||||
}
|
||||
|
||||
func TestReadbackHTTPCallerAndRequestShape(t *testing.T) {
|
||||
service := newTestReadback(t)
|
||||
server := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, readbackPrincipal)))
|
||||
defer server.Close()
|
||||
valid, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app"))
|
||||
|
||||
if got := postReadback(t, server.URL, string(valid)).StatusCode; got != http.StatusOK {
|
||||
t.Fatalf("admitted caller status = %d", got)
|
||||
}
|
||||
tooMany := make([]string, readback.MaxSystems+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("app-%d", i)
|
||||
}
|
||||
oversized, _ := json.Marshal(subjectRequest("user:alice", true, tooMany...))
|
||||
duplicated, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app", "demo-app"))
|
||||
for name, body := range map[string]string{
|
||||
"smuggled action": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"action":"admin"}`,
|
||||
"smuggled purpose": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"purpose":""}`,
|
||||
"no subject": `{"systems":["demo-app"]}`,
|
||||
"oversized": string(oversized),
|
||||
"duplicated": string(duplicated),
|
||||
} {
|
||||
if got := postReadback(t, server.URL, body).StatusCode; got != http.StatusBadRequest {
|
||||
t.Fatalf("%s: status = %d, want 400", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The readback pin mounts no enforcement route.
|
||||
for _, route := range []string{"/v1/check", "/v1/batch_check"} {
|
||||
response, err := http.Post(server.URL+route, "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("%s on readback pin = %d, want 404", route, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
wrong := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, "system:serviceaccount:other:other")))
|
||||
defer wrong.Close()
|
||||
if got := postReadback(t, wrong.URL, string(valid)).StatusCode; got != http.StatusForbidden {
|
||||
t.Fatalf("unbound caller status = %d, want 403", got)
|
||||
}
|
||||
}
|
||||
|
||||
// T02 gate: the readback principal gets 403 on an enforcement pin's /v1/check,
|
||||
// and an enforcement pin refuses a purpose-bearing request.
|
||||
func TestReadbackPrincipalCannotCheckOnEnforcementPin(t *testing.T) {
|
||||
engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||
Username: readbackPrincipal, 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("Authorization", "Bearer token")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("readback principal on /v1/check = %d, want 403", response.StatusCode)
|
||||
}
|
||||
|
||||
var withPurpose map[string]any
|
||||
if err := json.Unmarshal(body, &withPurpose); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withPurpose["purpose"] = api.PurposeReadback
|
||||
marked, _ := json.Marshal(withPurpose)
|
||||
open := httptest.NewServer(newServeMux(engine))
|
||||
defer open.Close()
|
||||
response, err = http.Post(open.URL+"/v1/check", "application/json", bytes.NewReader(marked))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("purpose on /v1/check = %d, want 400", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRecorder struct{}
|
||||
|
||||
func (failingRecorder) Append(api.DecisionEnvelope) error { return errors.New("disk full") }
|
||||
|
||||
// FLEX-DEC-2026-018 holds on the readback: an allow whose record fails to
|
||||
// commit is withheld (503, rendered "could not be checked"), never released.
|
||||
func TestReadbackWithholdsUncommittedAllow(t *testing.T) {
|
||||
service := newTestReadback(t)
|
||||
service.SetDecisionLog(failingRecorder{})
|
||||
server := httptest.NewServer(newReadbackMux(service, callerauth.Disabled()))
|
||||
defer server.Close()
|
||||
|
||||
allow, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app"))
|
||||
if got := postReadback(t, server.URL, string(allow)).StatusCode; got != http.StatusServiceUnavailable {
|
||||
t.Fatalf("uncommitted allow = %d, want 503", got)
|
||||
}
|
||||
deny, _ := json.Marshal(subjectRequest("user:bob", false, "demo-app"))
|
||||
if got := postReadback(t, server.URL, string(deny)).StatusCode; got != http.StatusOK {
|
||||
t.Fatalf("uncommitted deny = %d, want 200 (a restriction is never withheld)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// T03 gate: the wire shapes match the published schemas.
|
||||
func TestReadbackShapesMatchSchemas(t *testing.T) {
|
||||
service := newTestReadback(t)
|
||||
request := subjectRequest("user:alice", true, "demo-app", "other-app")
|
||||
response, err := service.Evaluate(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for schema, value := range map[string]any{
|
||||
"readback_request.schema.json": request,
|
||||
"readback_response.schema.json": response,
|
||||
"readback_systems.schema.json": service.Systems(),
|
||||
} {
|
||||
s, err := schemaguard.Load(filepath.Join("..", "..", "schemas", schema))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
var doc any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := s.Validate(doc); len(problems) > 0 {
|
||||
t.Fatalf("%s: %v\n%s", schema, problems, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FLEX-WP-0035-T05: the shipped catalogue and informed-decision lifecycle v2
|
||||
// answer the readback the way user-engine will ask it.
|
||||
func TestReadbackInformedDecisionCatalogue(t *testing.T) {
|
||||
examples := filepath.Join("..", "..", "examples")
|
||||
service, err := buildReadback(context.Background(),
|
||||
[]string{
|
||||
filepath.Join(examples, "application-readback", "registry_snapshot.json"),
|
||||
filepath.Join(examples, "informed-decision-lifecycle", "registry.json"),
|
||||
},
|
||||
map[string]string{"informed-decision": filepath.Join(examples, "informed-decision-lifecycle", "policy.md")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subject := func(groups ...any) readback.Request {
|
||||
return readback.Request{
|
||||
Tenant: "tenant:platform",
|
||||
Subject: api.SubjectRef{ID: "human-one", Type: "human", Tenant: "tenant:platform", Attributes: map[string]any{
|
||||
"issuer": "https://kc.coulomb.social", "roles": []any{}, "tenant_roles": []any{}, "scopes": []any{}, "groups": groups,
|
||||
"assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "methods": []any{"pwd", "otp"}, "at": time.Now().Unix()},
|
||||
}},
|
||||
Systems: []string{"informed-decision"},
|
||||
}
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
request readback.Request
|
||||
effect api.DecisionEffect
|
||||
}{
|
||||
"admin": {subject("net-kingdom-admins"), api.DecisionEffectAllow},
|
||||
"non-admin": {subject("staff"), api.DecisionEffectDeny},
|
||||
} {
|
||||
response, err := service.Evaluate(context.Background(), tc.request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := response.Results[0]; got.Effect != tc.effect || got.PolicyPackage != "informed-decision.lifecycle" {
|
||||
t.Fatalf("%s: %+v", name, got)
|
||||
}
|
||||
}
|
||||
systems := service.Systems().Systems
|
||||
if len(systems) != 1 || !systems[0].Readback || systems[0].ClientBinding == nil || systems[0].ClientBinding.ClientID != "informed-decision-approver" {
|
||||
t.Fatalf("catalogue = %+v", systems)
|
||||
}
|
||||
}
|
||||
24
cmd/access-engine/testdata/readback/demo-app/policy_fixtures.yaml
vendored
Normal file
24
cmd/access-engine/testdata/readback/demo-app/policy_fixtures.yaml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
- id: fixture:demo-app-access-allow
|
||||
request:
|
||||
subject:
|
||||
id: user:alice
|
||||
attributes:
|
||||
groups: [app-users]
|
||||
assurance: {level: aal2, mfa: true}
|
||||
action: application.access
|
||||
resource: {id: demo-app, type: application, system: demo-app}
|
||||
expect:
|
||||
effect: allow
|
||||
reason: app_user
|
||||
- id: fixture:demo-app-access-deny-no-mfa
|
||||
request:
|
||||
subject:
|
||||
id: user:bob
|
||||
attributes:
|
||||
groups: [app-users]
|
||||
assurance: {level: aal1, mfa: false}
|
||||
action: application.access
|
||||
resource: {id: demo-app, type: application, system: demo-app}
|
||||
expect:
|
||||
effect: deny
|
||||
reason: no_matching_rule
|
||||
53
cmd/access-engine/testdata/readback/demo-app/policy_package.md
vendored
Normal file
53
cmd/access-engine/testdata/readback/demo-app/policy_package.md
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
id: testdata.demo-app
|
||||
name: readback test package demo-app
|
||||
namespace: testdata:demo-app
|
||||
version: v1
|
||||
status: fixture
|
||||
package: flexauth.testdata.demo_app
|
||||
actions:
|
||||
- application.access
|
||||
- read
|
||||
owner: team:platform-security
|
||||
fixtures:
|
||||
- policy_fixtures.yaml
|
||||
caring:
|
||||
profile: caring-0.4.0-rc2
|
||||
enforce: false
|
||||
canonical_roles: [Operator]
|
||||
organization_relations: [ServiceProvider]
|
||||
scopes:
|
||||
- {level: Platform, id: platform:testdata, tenant: tenant:platform}
|
||||
planes: [Identity]
|
||||
capabilities: [Use]
|
||||
exposure_modes: [Metadata]
|
||||
conditions: [Logged]
|
||||
restrictions: [PrivilegeEscalationBlocked]
|
||||
---
|
||||
|
||||
# Readback test package (FLEX-WP-0035)
|
||||
|
||||
Test-only. Do not copy.
|
||||
|
||||
```rego
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
|
||||
default decision := {"effect": "deny", "reason": "no_matching_rule"}
|
||||
|
||||
decision := {"effect": "allow", "reason": "app_user"} if {
|
||||
input.action == "application.access"
|
||||
"app-users" in input.subject.attributes.groups
|
||||
input.subject.attributes.assurance.mfa == true
|
||||
}
|
||||
```
|
||||
|
||||
```rego test
|
||||
package flexauth.testdata.demo_app_test
|
||||
import future.keywords.if
|
||||
import data.flexauth.testdata.demo_app
|
||||
|
||||
test_default_deny if {
|
||||
demo_app.decision.effect == "deny" with input as {"action": "read", "subject": {"attributes": {}}}
|
||||
}
|
||||
```
|
||||
28
cmd/access-engine/testdata/readback/demo-app/registry_snapshot.json
vendored
Normal file
28
cmd/access-engine/testdata/readback/demo-app/registry_snapshot.json
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"systems": [
|
||||
{
|
||||
"id": "demo-app",
|
||||
"name": "Demo App",
|
||||
"client_binding": {
|
||||
"issuer": "https://keycape.example/realms/netkingdom",
|
||||
"client_id": "demo-app"
|
||||
}
|
||||
}
|
||||
],
|
||||
"subjects": [
|
||||
{
|
||||
"id": "demo-app-profile",
|
||||
"type": "Human",
|
||||
"display_name": "Readback contract profile (declares caller-supplied keys)",
|
||||
"groups": [
|
||||
"app-users"
|
||||
],
|
||||
"claims": {
|
||||
"assurance": {
|
||||
"level": "aal2",
|
||||
"mfa": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
cmd/access-engine/testdata/readback/other-app/policy_fixtures.yaml
vendored
Normal file
14
cmd/access-engine/testdata/readback/other-app/policy_fixtures.yaml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# The rule would allow application.access, but this package does not declare
|
||||
# the action. The readback must answer not_applicable regardless (T01 §2).
|
||||
- id: fixture:other-app-undeclared-rule-would-allow
|
||||
request:
|
||||
subject:
|
||||
id: user:alice
|
||||
attributes:
|
||||
groups: [app-users]
|
||||
assurance: {level: aal2, mfa: true}
|
||||
action: application.access
|
||||
resource: {id: other-app, type: application, system: other-app}
|
||||
expect:
|
||||
effect: allow
|
||||
reason: app_user
|
||||
52
cmd/access-engine/testdata/readback/other-app/policy_package.md
vendored
Normal file
52
cmd/access-engine/testdata/readback/other-app/policy_package.md
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
id: testdata.other-app
|
||||
name: readback test package other-app
|
||||
namespace: testdata:other-app
|
||||
version: v1
|
||||
status: fixture
|
||||
package: flexauth.testdata.other_app
|
||||
actions:
|
||||
- read
|
||||
owner: team:platform-security
|
||||
fixtures:
|
||||
- policy_fixtures.yaml
|
||||
caring:
|
||||
profile: caring-0.4.0-rc2
|
||||
enforce: false
|
||||
canonical_roles: [Operator]
|
||||
organization_relations: [ServiceProvider]
|
||||
scopes:
|
||||
- {level: Platform, id: platform:testdata, tenant: tenant:platform}
|
||||
planes: [Identity]
|
||||
capabilities: [Use]
|
||||
exposure_modes: [Metadata]
|
||||
conditions: [Logged]
|
||||
restrictions: [PrivilegeEscalationBlocked]
|
||||
---
|
||||
|
||||
# Readback test package (FLEX-WP-0035)
|
||||
|
||||
Test-only. Do not copy.
|
||||
|
||||
```rego
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
|
||||
default decision := {"effect": "deny", "reason": "no_matching_rule"}
|
||||
|
||||
decision := {"effect": "allow", "reason": "app_user"} if {
|
||||
input.action == "application.access"
|
||||
"app-users" in input.subject.attributes.groups
|
||||
input.subject.attributes.assurance.mfa == true
|
||||
}
|
||||
```
|
||||
|
||||
```rego test
|
||||
package flexauth.testdata.other_app_test
|
||||
import future.keywords.if
|
||||
import data.flexauth.testdata.other_app
|
||||
|
||||
test_default_deny if {
|
||||
other_app.decision.effect == "deny" with input as {"action": "read", "subject": {"attributes": {}}}
|
||||
}
|
||||
```
|
||||
24
cmd/access-engine/testdata/readback/other-app/registry_snapshot.json
vendored
Normal file
24
cmd/access-engine/testdata/readback/other-app/registry_snapshot.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"systems": [
|
||||
{
|
||||
"id": "other-app",
|
||||
"name": "Other App"
|
||||
}
|
||||
],
|
||||
"subjects": [
|
||||
{
|
||||
"id": "other-app-profile",
|
||||
"type": "Human",
|
||||
"display_name": "Readback contract profile (declares caller-supplied keys)",
|
||||
"groups": [
|
||||
"app-users"
|
||||
],
|
||||
"claims": {
|
||||
"assurance": {
|
||||
"level": "aal2",
|
||||
"mfa": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue