Implement inbound caller authentication (ADR 0004); close T03 and T05
TokenReview-based caller identity with audience-scoped tokens and exact resource.system to ServiceAccount bindings, per ops-warden's recommendation. Deletes the unwired tenant-engine live-roles adapter (T03) and adds make verify-posture (T05). Source implements A2; running digest is still A0 until promotion, so tenancy.current.A stays 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6d82ef7f14
commit
1e1e077b27
18 changed files with 768 additions and 357 deletions
|
|
@ -5,6 +5,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
"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/policy"
|
||||
"github.com/netkingdom/flex-auth/internal/registry"
|
||||
|
|
@ -324,6 +326,13 @@ func runServe(args []string, stdout, stderr io.Writer) int {
|
|||
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")
|
||||
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
|
||||
}
|
||||
|
|
@ -337,7 +346,20 @@ func runServe(args []string, stdout, stderr io.Writer) int {
|
|||
return fail(stderr, err)
|
||||
}
|
||||
|
||||
mux := newServeMux(engine)
|
||||
authenticator, err := buildCallerAuthenticator(
|
||||
callerauth.Mode(*callerAuthMode),
|
||||
*callerAudience,
|
||||
*callerKubernetesURL,
|
||||
*callerReviewerTokenFile,
|
||||
*callerCAFile,
|
||||
callerBindings.StringMap(),
|
||||
stderr,
|
||||
)
|
||||
if err != nil {
|
||||
return fail(stderr, err)
|
||||
}
|
||||
|
||||
mux := newServeMuxWithCallerAuth(engine, authenticator)
|
||||
|
||||
fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
|
|
@ -347,6 +369,10 @@ func runServe(args []string, stdout, stderr io.Writer) int {
|
|||
}
|
||||
|
||||
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")
|
||||
|
|
@ -362,6 +388,10 @@ func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
|
|||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System}); err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decision, err := engine.Check(r.Context(), request)
|
||||
writeHTTP(w, decision, err)
|
||||
})
|
||||
|
|
@ -375,12 +405,44 @@ func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
|
|||
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)
|
||||
}
|
||||
if err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), systems); err != nil {
|
||||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decisions, err := engine.BatchCheck(r.Context(), 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 {
|
||||
|
|
@ -503,3 +565,12 @@ func (f keyValueFlags) Map() map[string]any {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
|
|
@ -157,6 +158,77 @@ func TestServeOpsWardenCheckContract(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("bound caller status = %d; want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue