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,465 +1,26 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
func TestRunVersion(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"version"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
// The retired binary must fail and name its replacement, whatever it is asked.
|
||||
func TestRetiredBinaryNamesReplacement(t *testing.T) {
|
||||
if os.Getenv("FLEX_AUTH_STUB_CHILD") == "1" {
|
||||
main()
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(stdout.String()) == "" {
|
||||
t.Fatal("version output is empty")
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestRetiredBinaryNamesReplacement", "serve", "--addr", "x")
|
||||
cmd.Env = append(os.Environ(), "FLEX_AUTH_STUB_CHILD=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
exit, ok := err.(*exec.ExitError)
|
||||
if !ok || exit.ExitCode() != 2 {
|
||||
t.Fatalf("want exit 2, got %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "renamed to access-engine") {
|
||||
t.Fatalf("missing rename message: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTestPolicy(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"test-policy", "--file", examplePath("policy_package.md")}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"valid": true`) {
|
||||
t.Fatalf("stdout = %s; want valid policy result", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheck(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{
|
||||
"check",
|
||||
"--registry", examplePath("registry_snapshot.json"),
|
||||
"--policy", examplePath("policy_package.md"),
|
||||
"--request", examplePath("check_request.yaml"),
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
|
||||
var decision api.DecisionEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil {
|
||||
t.Fatalf("unmarshal decision: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decision.Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("decision.Effect = %q; want allow", decision.Effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchCheck(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{
|
||||
"batch-check",
|
||||
"--registry", examplePath("registry_snapshot.json"),
|
||||
"--policy", examplePath("policy_package.md"),
|
||||
"--request", examplePath("batch_check_request.yaml"),
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
|
||||
var decisions []api.DecisionEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decisions); err != nil {
|
||||
t.Fatalf("unmarshal decisions: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(decisions) != 2 || decisions[0].Effect != api.DecisionEffectAllow || decisions[1].Effect != api.DecisionEffectDeny {
|
||||
t.Fatalf("decisions = %+v; want allow then deny", decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheckOpsWarden(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{
|
||||
"check",
|
||||
"--registry", opsPath("registry_snapshot.json"),
|
||||
"--policy", opsPath("policy_package.md"),
|
||||
"--request", opsPath("check_request_allow_adm.json"),
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
|
||||
var decision api.DecisionEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil {
|
||||
t.Fatalf("unmarshal decision: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if decision.Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("decision.Effect = %q; want allow", decision.Effect)
|
||||
}
|
||||
if decision.ID == "" {
|
||||
t.Fatal("decision.ID is empty; ops-warden needs a policy_decision_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRailiancePlatformCredentialGrantContract(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"test-policy", "--file", railiancePlatformPath("policy_package.md")}, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stdout.String(), `"valid": true`) {
|
||||
t.Fatalf("test-policy code = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = run([]string{
|
||||
"check",
|
||||
"--registry", railiancePlatformPath("registry_snapshot.json"),
|
||||
"--policy", railiancePlatformPath("policy_package.md"),
|
||||
"--request", railiancePlatformPath("check_request_allow.json"),
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("check code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
var decision api.DecisionEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Effect != api.DecisionEffectAllow || decision.Reason != "credential_grant_allowed" {
|
||||
t.Fatalf("decision = %s/%s; want allow/credential_grant_allowed", decision.Effect, decision.Reason)
|
||||
}
|
||||
if decision.Binding == nil || decision.Binding.Context["requested_ttl_seconds"] != float64(900) {
|
||||
t.Fatalf("binding = %+v; want normalized numeric TTL", decision.Binding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeOpsWardenCheckContract(t *testing.T) {
|
||||
logPath := filepath.Join(t.TempDir(), "decisions.jsonl")
|
||||
engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
server := httptest.NewServer(newServeMux(engine))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /healthz: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /healthz status = %d; want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
allow := postCheck(t, server.URL+"/v1/check", opsPath("check_request_allow_adm.json"))
|
||||
if allow.Effect != api.DecisionEffectAllow || allow.ID == "" {
|
||||
t.Fatalf("allow decision = %+v; want allow with id", allow)
|
||||
}
|
||||
|
||||
deny := postCheck(t, server.URL+"/v1/check", opsPath("check_request_deny_ttl_above_max.json"))
|
||||
if deny.Effect != api.DecisionEffectDeny || deny.Reason != "ttl_out_of_bounds" {
|
||||
t.Fatalf("deny decision = %+v; want ttl_out_of_bounds deny", deny)
|
||||
}
|
||||
|
||||
resp, err = http.Get(server.URL + "/v1/check")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /v1/check: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET /v1/check status = %d; want 405", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, err = http.Post(server.URL+"/v1/check", "application/json", strings.NewReader(`{"subject":`))
|
||||
if err != nil {
|
||||
t.Fatalf("POST malformed /v1/check: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("malformed POST status = %d; want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read decision log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), allow.ID) || !strings.Contains(string(logData), deny.ID) {
|
||||
t.Fatalf("decision log does not contain both decision ids\nlog: %s\nallow: %s deny: %s", string(logData), allow.ID, deny.ID)
|
||||
}
|
||||
}
|
||||
|
||||
type fixedTokenReviewer struct {
|
||||
identity callerauth.Identity
|
||||
}
|
||||
|
||||
func (r fixedTokenReviewer) Review(context.Context, string) (callerauth.Identity, error) {
|
||||
return r.identity, nil
|
||||
}
|
||||
|
||||
func TestServeCallerAuthenticationBindsSystemToPrincipal(t *testing.T) {
|
||||
engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||
Username: "system:serviceaccount:ops-warden:ops-warden",
|
||||
Audiences: []string{"flex-auth"},
|
||||
}}, "flex-auth", map[string]string{
|
||||
"ops-warden": "system:serviceaccount:ops-warden:ops-warden",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator))
|
||||
defer server.Close()
|
||||
body, err := os.ReadFile(opsPath("check_request_allow_adm.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body))
|
||||
request.Header.Set("content-type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("missing token status = %d; want 401", resp.StatusCode)
|
||||
}
|
||||
|
||||
request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body))
|
||||
request.Header.Set("content-type", "application/json")
|
||||
request.Header.Set("authorization", "Bearer workload-token")
|
||||
resp, err = http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("bound caller status = %d; want 200", resp.StatusCode)
|
||||
}
|
||||
var envelope api.DecisionEnvelope
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("decode decision: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if envelope.Provenance.Caller == nil || envelope.Provenance.Caller.Mode != "enforce" {
|
||||
t.Fatalf("caller = %+v; want enforce", envelope.Provenance.Caller)
|
||||
}
|
||||
if envelope.Provenance.Caller.Principal != "system:serviceaccount:ops-warden:ops-warden" {
|
||||
t.Fatalf("principal = %q", envelope.Provenance.Caller.Principal)
|
||||
}
|
||||
if envelope.Provenance.Caller.Audience != "flex-auth" {
|
||||
t.Fatalf("audience = %q", envelope.Provenance.Caller.Audience)
|
||||
}
|
||||
|
||||
wrong, _ := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||
Username: "system:serviceaccount:another:caller",
|
||||
Audiences: []string{"flex-auth"},
|
||||
}}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil)
|
||||
wrongServer := httptest.NewServer(newServeMuxWithCallerAuth(engine, wrong))
|
||||
defer wrongServer.Close()
|
||||
request, _ = http.NewRequest(http.MethodPost, wrongServer.URL+"/v1/check", bytes.NewReader(body))
|
||||
request.Header.Set("content-type", "application/json")
|
||||
request.Header.Set("authorization", "Bearer workload-token")
|
||||
resp, err = http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("mismatched caller status = %d; want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoadRegistryOpsWardenProduction(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("unmarshal load-registry output: %v; stdout = %s", err, stdout.String())
|
||||
}
|
||||
if result["subjects"] != float64(4) || result["relationships"] != float64(4) || result["resource_manifests"] != float64(1) {
|
||||
t.Fatalf("load-registry result = %+v; want production actor registry counts", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsWardenProductionRegistryActors(t *testing.T) {
|
||||
engine, err := buildEngine(context.Background(), opsPath("production_registry_snapshot.json"), opsPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subjectID string
|
||||
actor string
|
||||
actorType string
|
||||
principal string
|
||||
ttlHours float64
|
||||
wantEffect api.DecisionEffect
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "state hub bridge agent advisory while zone is unknown",
|
||||
subjectID: "agt-state-hub-bridge",
|
||||
actor: "agt-state-hub-bridge",
|
||||
actorType: "agt",
|
||||
principal: "agt-task-bridge",
|
||||
ttlHours: 1,
|
||||
wantEffect: api.DecisionEffectAuditOnly,
|
||||
wantReason: "advisory_would_signing_policy_matched",
|
||||
},
|
||||
{
|
||||
name: "state hub bridge IAM subject advisory while zone is unknown",
|
||||
subjectID: "iam:agt-state-hub-bridge",
|
||||
actor: "agt-state-hub-bridge",
|
||||
actorType: "agt",
|
||||
principal: "agt-task-bridge",
|
||||
ttlHours: 1,
|
||||
wantEffect: api.DecisionEffectAuditOnly,
|
||||
wantReason: "advisory_would_signing_policy_matched",
|
||||
},
|
||||
{
|
||||
name: "codex interhub bootstrap advisory while zone is unknown",
|
||||
subjectID: "agt-codex-interhub-bootstrap",
|
||||
actor: "agt-codex-interhub-bootstrap",
|
||||
actorType: "agt",
|
||||
principal: "agt-interhub-bootstrap",
|
||||
ttlHours: 1,
|
||||
wantEffect: api.DecisionEffectAuditOnly,
|
||||
wantReason: "advisory_would_signing_policy_matched",
|
||||
},
|
||||
{
|
||||
name: "admin actor allow",
|
||||
subjectID: "adm-example",
|
||||
actor: "adm-example",
|
||||
actorType: "adm",
|
||||
principal: "adm-full",
|
||||
ttlHours: 4,
|
||||
wantEffect: api.DecisionEffectAllow,
|
||||
},
|
||||
{
|
||||
name: "automation actor advisory while zone is unknown",
|
||||
subjectID: "atm-backup-daily",
|
||||
actor: "atm-backup-daily",
|
||||
actorType: "atm",
|
||||
principal: "atm-backup-daily",
|
||||
ttlHours: 1,
|
||||
wantEffect: api.DecisionEffectAuditOnly,
|
||||
wantReason: "advisory_would_signing_policy_matched",
|
||||
},
|
||||
{
|
||||
name: "ttl above production max is advisory while zone is unknown",
|
||||
subjectID: "agt-state-hub-bridge",
|
||||
actor: "agt-state-hub-bridge",
|
||||
actorType: "agt",
|
||||
principal: "agt-task-bridge",
|
||||
ttlHours: 999,
|
||||
wantEffect: api.DecisionEffectAuditOnly,
|
||||
wantReason: "advisory_would_ttl_out_of_bounds",
|
||||
},
|
||||
{
|
||||
name: "unregistered production actor is advisory unknown",
|
||||
subjectID: "agt-missing",
|
||||
actor: "agt-missing",
|
||||
actorType: "agt",
|
||||
principal: "agt-missing",
|
||||
ttlHours: 1,
|
||||
wantEffect: api.DecisionEffectAuditOnly,
|
||||
wantReason: "advisory_would_unknown_actor_resource",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
decision, err := engine.Check(context.Background(), opsWardenProductionSignRequest(tt.subjectID, tt.actor, tt.actorType, tt.principal, tt.ttlHours))
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if decision.Effect != tt.wantEffect {
|
||||
t.Fatalf("decision.Effect = %q; want %q; decision: %+v", decision.Effect, tt.wantEffect, decision)
|
||||
}
|
||||
if tt.wantReason != "" && decision.Reason != tt.wantReason {
|
||||
t.Fatalf("decision.Reason = %q; want %q; decision: %+v", decision.Reason, tt.wantReason, decision)
|
||||
}
|
||||
if (tt.wantEffect == api.DecisionEffectAllow || tt.wantEffect == api.DecisionEffectAuditOnly) && decision.ID == "" {
|
||||
t.Fatal("proceeding decision ID is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidateAccessDescriptor(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"validate", "--kind", "access-descriptor", "--file", examplePath("access_descriptor.yaml")}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"status": "valid"`) {
|
||||
t.Fatalf("stdout = %s; want valid status", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func examplePath(name string) string {
|
||||
return filepath.Join("..", "..", "examples", "caring", name)
|
||||
}
|
||||
|
||||
func opsPath(name string) string {
|
||||
return filepath.Join("..", "..", "examples", "ops-warden", name)
|
||||
}
|
||||
|
||||
func railiancePlatformPath(name string) string {
|
||||
return filepath.Join("..", "..", "examples", "railiance-platform", name)
|
||||
}
|
||||
|
||||
func opsWardenProductionSignRequest(subjectID, actor, actorType, principal string, ttlHours float64) api.CheckRequest {
|
||||
return api.CheckRequest{
|
||||
ID: "check:ops-warden-production-" + actor,
|
||||
Tenant: "tenant:platform",
|
||||
Subject: api.SubjectRef{
|
||||
ID: subjectID,
|
||||
Type: api.SubjectType(actorType),
|
||||
},
|
||||
Action: "sign",
|
||||
Resource: api.ResourceRef{
|
||||
ID: "ssh-cert:actor/" + actor,
|
||||
Type: "ssh-certificate",
|
||||
System: "ops-warden",
|
||||
},
|
||||
Context: map[string]any{
|
||||
"principals": []string{principal},
|
||||
"actor_type": actorType,
|
||||
"ttl_hours": ttlHours,
|
||||
"pubkey_fingerprint": "SHA256:example-production-fingerprint",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func postCheck(t *testing.T, url, path string) api.DecisionEnvelope {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("POST %s status = %d; want 200", path, resp.StatusCode)
|
||||
}
|
||||
var decision api.DecisionEnvelope
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil {
|
||||
t.Fatalf("decode %s response: %v", path, err)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue