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
|
|
@ -1,42 +0,0 @@
|
|||
package tenantengine_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/adapters/tenantengine"
|
||||
)
|
||||
|
||||
func TestAttachToContextSetsRolesAndAvailability(t *testing.T) {
|
||||
ctx := tenantengine.AttachToContext(nil, tenantengine.LiveRolesResult{
|
||||
Roles: []string{"CUS"},
|
||||
Available: true,
|
||||
})
|
||||
|
||||
if ctx["tenant_roles_available"] != true {
|
||||
t.Fatalf("tenant_roles_available = %v, want true", ctx["tenant_roles_available"])
|
||||
}
|
||||
roles, ok := ctx["tenant_roles"].([]string)
|
||||
if !ok || len(roles) != 1 || roles[0] != "CUS" {
|
||||
t.Fatalf("tenant_roles = %v", ctx["tenant_roles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachToContextMarksUnavailableOnFailure(t *testing.T) {
|
||||
ctx := tenantengine.AttachToContext(map[string]any{"existing": "field"}, tenantengine.LiveRolesResult{
|
||||
Available: false,
|
||||
})
|
||||
|
||||
if ctx["tenant_roles_available"] != false {
|
||||
t.Fatalf("tenant_roles_available = %v, want false", ctx["tenant_roles_available"])
|
||||
}
|
||||
if ctx["existing"] != "field" {
|
||||
t.Fatal("AttachToContext must not clobber unrelated context fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachToContextHandlesNilContext(t *testing.T) {
|
||||
ctx := tenantengine.AttachToContext(nil, tenantengine.LiveRolesResult{Available: true, Roles: []string{}})
|
||||
if ctx == nil {
|
||||
t.Fatal("expected a non-nil map")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package tenantengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient calls tenant-engine's live-lookup endpoint
|
||||
// (GET /tenants/{id}/roles/live).
|
||||
type HTTPClient struct {
|
||||
BaseURL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient creates an HTTP-backed tenant-engine client.
|
||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("tenant-engine base URL is required")
|
||||
}
|
||||
return &HTTPClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Client: &http.Client{Timeout: 3 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LiveRoles calls GET /tenants/{tenantID}/roles/live.
|
||||
//
|
||||
// Fail-closed by construction: any transport error, non-200 response, or
|
||||
// malformed body returns LiveRolesResult{Available: false} alongside a
|
||||
// non-nil error. Nothing is inferred as "zero roles" from a failure --
|
||||
// callers must check Available, not just the length of Roles.
|
||||
func (c *HTTPClient) LiveRoles(ctx context.Context, tenantID string) (LiveRolesResult, error) {
|
||||
url := fmt.Sprintf("%s/tenants/%s/roles/live", c.BaseURL, tenantID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err)
|
||||
}
|
||||
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return LiveRolesResult{Available: false}, NewBackendError(
|
||||
FailureUnavailable, "live_roles", fmt.Errorf("status %d", resp.StatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return LiveRolesResult{Available: false}, NewBackendError(FailureInvalidResponse, "live_roles", err)
|
||||
}
|
||||
|
||||
return LiveRolesResult{Roles: body.Roles, Available: true}, nil
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package tenantengine_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/adapters/tenantengine"
|
||||
)
|
||||
|
||||
func TestLiveRolesReturnsRolesOnSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tenants/t-1/roles/live" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"tenant_id":"t-1","roles":["CUS","VEN"]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := tenantengine.NewHTTPClient(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPClient: %v", err)
|
||||
}
|
||||
|
||||
result, err := client.LiveRoles(context.Background(), "t-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LiveRoles: %v", err)
|
||||
}
|
||||
if !result.Available {
|
||||
t.Fatal("expected Available = true")
|
||||
}
|
||||
if len(result.Roles) != 2 || result.Roles[0] != "CUS" || result.Roles[1] != "VEN" {
|
||||
t.Fatalf("unexpected roles: %v", result.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveRolesReturnsUnavailableOnNon200(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := tenantengine.NewHTTPClient(server.URL)
|
||||
result, err := client.LiveRoles(context.Background(), "t-1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("expected Available = false on a 503, not indistinguishable from zero roles")
|
||||
}
|
||||
if result.Roles != nil {
|
||||
t.Fatalf("expected nil roles on failure, got %v", result.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveRolesReturnsUnavailableOnMalformedBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("not json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := tenantengine.NewHTTPClient(server.URL)
|
||||
result, err := client.LiveRoles(context.Background(), "t-1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("expected Available = false on malformed body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveRolesReturnsUnavailableOnConnectionFailure(t *testing.T) {
|
||||
client, _ := tenantengine.NewHTTPClient("http://127.0.0.1:1")
|
||||
|
||||
result, err := client.LiveRoles(context.Background(), "t-1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("expected Available = false on connection failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveRolesRespectsContextTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"tenant_id":"t-1","roles":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, _ := tenantengine.NewHTTPClient(server.URL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
result, err := client.LiveRoles(ctx, "t-1")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected a timeout error")
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("expected Available = false on timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHTTPClientRequiresBaseURL(t *testing.T) {
|
||||
if _, err := tenantengine.NewHTTPClient(""); err == nil {
|
||||
t.Fatal("expected an error for empty base URL")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
// Package tenantengine provides a context-enrichment adapter for
|
||||
// tenant-engine's live-lookup endpoint (FLEX-WP-0008-T03).
|
||||
//
|
||||
// Unlike the topaz/relationship/rule adapters, this is not a delegated
|
||||
// policy decision point -- Rego evaluation is stateless and cannot make an
|
||||
// HTTP call mid-evaluation. This adapter is a request-preparation helper:
|
||||
// whichever protected system's policy needs a tenant's capability roles
|
||||
// (PLTF/IAM/VEN/CUS, ADR-0014) calls LiveRoles before building its
|
||||
// CheckRequest, then attaches the result to request.Context via
|
||||
// AttachToContext. tenant-engine's own write-API policy
|
||||
// (examples/tenant-engine/policy_package.md) does NOT use this adapter --
|
||||
// it authorizes by operator/service identity, a different question from a
|
||||
// tenant's own capability roles.
|
||||
package tenantengine
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FailureKind classifies fail-closed tenant-engine lookup failures.
|
||||
type FailureKind string
|
||||
|
||||
const (
|
||||
FailureUnavailable FailureKind = "unavailable"
|
||||
FailureInvalidResponse FailureKind = "invalid_response"
|
||||
)
|
||||
|
||||
// BackendError wraps transport and backend failures with adapter semantics.
|
||||
type BackendError struct {
|
||||
Kind FailureKind
|
||||
Op string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *BackendError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Err == nil {
|
||||
return fmt.Sprintf("tenant-engine %s failed: %s", e.Op, e.Kind)
|
||||
}
|
||||
return fmt.Sprintf("tenant-engine %s failed: %s: %v", e.Op, e.Kind, e.Err)
|
||||
}
|
||||
|
||||
func (e *BackendError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewBackendError classifies an adapter backend error.
|
||||
func NewBackendError(kind FailureKind, op string, err error) error {
|
||||
return &BackendError{Kind: kind, Op: op, Err: err}
|
||||
}
|
||||
|
||||
// LiveRolesResult is the outcome of a live-lookup call.
|
||||
//
|
||||
// Available is the load-bearing field: false means the lookup could not be
|
||||
// completed for any reason (transport failure, non-200, malformed body) and
|
||||
// MUST be treated as deny by any consuming policy -- never conflated with
|
||||
// Available: true, Roles: [] (a tenant that legitimately holds no roles).
|
||||
// This mirrors the exact rule tenant-engine's own read endpoints already
|
||||
// enforce (GET /tenants/{id}/roles/live never returns 200 + [] on an
|
||||
// outage) -- this adapter does not weaken it on the consuming side.
|
||||
type LiveRolesResult struct {
|
||||
Roles []string
|
||||
Available bool
|
||||
}
|
||||
|
||||
// AttachToContext writes the live-lookup result into a CheckRequest's
|
||||
// Context map under the "tenant_roles" / "tenant_roles_available" keys.
|
||||
// Any Rego policy consuming tenant capability roles MUST check
|
||||
// tenant_roles_available == true before trusting tenant_roles -- see
|
||||
// examples/tenant-engine/README.md for the required Rego pattern.
|
||||
func AttachToContext(context map[string]any, result LiveRolesResult) map[string]any {
|
||||
if context == nil {
|
||||
context = map[string]any{}
|
||||
}
|
||||
roles := result.Roles
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
context["tenant_roles"] = roles
|
||||
context["tenant_roles_available"] = result.Available
|
||||
return context
|
||||
}
|
||||
125
internal/callerauth/auth.go
Normal file
125
internal/callerauth/auth.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Package callerauth authenticates protected systems before flex-auth evaluates
|
||||
// the authorization request they submit.
|
||||
package callerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeDisabled Mode = "disabled"
|
||||
ModeWarn Mode = "warn"
|
||||
ModeEnforce Mode = "enforce"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("caller is not authenticated")
|
||||
ErrForbidden = errors.New("caller is not allowed to represent the requested system")
|
||||
ErrUnavailable = errors.New("caller identity service is unavailable")
|
||||
)
|
||||
|
||||
type Identity struct {
|
||||
Username string
|
||||
Audiences []string
|
||||
}
|
||||
|
||||
type TokenReviewer interface {
|
||||
Review(context.Context, string) (Identity, error)
|
||||
}
|
||||
|
||||
type WarningFunc func(string, ...any)
|
||||
|
||||
type Authenticator struct {
|
||||
mode Mode
|
||||
reviewer TokenReviewer
|
||||
audience string
|
||||
bindings map[string]string
|
||||
warnf WarningFunc
|
||||
}
|
||||
|
||||
func New(mode Mode, reviewer TokenReviewer, audience string, bindings map[string]string, warnf WarningFunc) (*Authenticator, error) {
|
||||
switch mode {
|
||||
case ModeDisabled:
|
||||
return &Authenticator{mode: mode}, nil
|
||||
case ModeWarn, ModeEnforce:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported caller-auth mode %q", mode)
|
||||
}
|
||||
if reviewer == nil {
|
||||
return nil, fmt.Errorf("token reviewer is required in %s mode", mode)
|
||||
}
|
||||
if strings.TrimSpace(audience) == "" {
|
||||
return nil, fmt.Errorf("caller audience is required in %s mode", mode)
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil, fmt.Errorf("at least one caller binding is required in %s mode", mode)
|
||||
}
|
||||
copyBindings := make(map[string]string, len(bindings))
|
||||
for system, principal := range bindings {
|
||||
if strings.TrimSpace(system) == "" || strings.TrimSpace(principal) == "" {
|
||||
return nil, fmt.Errorf("caller bindings require non-empty system and principal")
|
||||
}
|
||||
copyBindings[system] = principal
|
||||
}
|
||||
return &Authenticator{mode: mode, reviewer: reviewer, audience: audience, bindings: copyBindings, warnf: warnf}, nil
|
||||
}
|
||||
|
||||
func Disabled() *Authenticator {
|
||||
authenticator, _ := New(ModeDisabled, nil, "", nil, nil)
|
||||
return authenticator
|
||||
}
|
||||
|
||||
// Authorize verifies the bearer token and binds every resource.system value to
|
||||
// the authenticated workload principal. Warn mode records the same failures but
|
||||
// permits the request so callers can be migrated before enforcement is enabled.
|
||||
func (a *Authenticator) Authorize(ctx context.Context, authorization string, systems []string) error {
|
||||
if a == nil || a.mode == ModeDisabled {
|
||||
return nil
|
||||
}
|
||||
err := a.authorize(ctx, authorization, systems)
|
||||
if err != nil && a.mode == ModeWarn {
|
||||
if a.warnf != nil {
|
||||
a.warnf("caller authentication warning: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Authenticator) authorize(ctx context.Context, authorization string, systems []string) error {
|
||||
token, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||
if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), " \t\r\n") {
|
||||
return ErrUnauthenticated
|
||||
}
|
||||
identity, err := a.reviewer.Review(ctx, strings.TrimSpace(token))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) {
|
||||
return ErrUnauthenticated
|
||||
}
|
||||
if len(systems) == 0 {
|
||||
return fmt.Errorf("%w: request has no resources", ErrForbidden)
|
||||
}
|
||||
for _, system := range systems {
|
||||
expected, found := a.bindings[system]
|
||||
if !found || expected != identity.Username {
|
||||
return fmt.Errorf("%w: principal %q cannot represent system %q", ErrForbidden, identity.Username, system)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
72
internal/callerauth/auth_test.go
Normal file
72
internal/callerauth/auth_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package callerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeReviewer struct {
|
||||
identity Identity
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeReviewer) Review(context.Context, string) (Identity, error) {
|
||||
return f.identity, f.err
|
||||
}
|
||||
|
||||
func TestAuthenticatorEnforcesAudienceAndSystemBinding(t *testing.T) {
|
||||
authenticator, err := New(ModeEnforce, fakeReviewer{identity: Identity{
|
||||
Username: "system:serviceaccount:tenant-engine:tenant-engine",
|
||||
Audiences: []string{"flex-auth"},
|
||||
}}, "flex-auth", map[string]string{
|
||||
"tenant-engine": "system:serviceaccount:tenant-engine:tenant-engine",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := authenticator.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"}); err != nil {
|
||||
t.Fatalf("Authorize: %v", err)
|
||||
}
|
||||
if err := authenticator.Authorize(context.Background(), "Bearer caller-token", []string{"user-engine"}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("system mismatch error = %v; want forbidden", err)
|
||||
}
|
||||
|
||||
wrongAudience, _ := New(ModeEnforce, fakeReviewer{identity: Identity{
|
||||
Username: "system:serviceaccount:tenant-engine:tenant-engine",
|
||||
Audiences: []string{"kubernetes"},
|
||||
}}, "flex-auth", map[string]string{"tenant-engine": "system:serviceaccount:tenant-engine:tenant-engine"}, nil)
|
||||
if err := wrongAudience.Authorize(context.Background(), "Bearer caller-token", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("audience error = %v; want unauthenticated", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorRejectsMissingTokenAndReviewerFailure(t *testing.T) {
|
||||
bindings := map[string]string{"tenant-engine": "principal"}
|
||||
authenticator, _ := New(ModeEnforce, fakeReviewer{identity: Identity{Username: "principal", Audiences: []string{"flex-auth"}}}, "flex-auth", bindings, nil)
|
||||
if err := authenticator.Authorize(context.Background(), "", []string{"tenant-engine"}); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("missing token error = %v; want unauthenticated", err)
|
||||
}
|
||||
|
||||
unavailable, _ := New(ModeEnforce, fakeReviewer{err: errors.New("apiserver down")}, "flex-auth", bindings, nil)
|
||||
if err := unavailable.Authorize(context.Background(), "Bearer token", []string{"tenant-engine"}); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("reviewer error = %v; want unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatorWarnModePermitsButRecordsFailure(t *testing.T) {
|
||||
var warning string
|
||||
authenticator, err := New(ModeWarn, fakeReviewer{}, "flex-auth", map[string]string{"tenant-engine": "principal"}, func(format string, _ ...any) {
|
||||
warning = format
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := authenticator.Authorize(context.Background(), "", []string{"tenant-engine"}); err != nil {
|
||||
t.Fatalf("warn mode returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(warning, "warning") {
|
||||
t.Fatalf("warning = %q", warning)
|
||||
}
|
||||
}
|
||||
110
internal/callerauth/tokenreview.go
Normal file
110
internal/callerauth/tokenreview.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package callerauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KubernetesTokenReviewer struct {
|
||||
endpoint string
|
||||
audience string
|
||||
reviewerTokenFile string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewKubernetesTokenReviewer(endpoint, audience, reviewerTokenFile, caFile string) (*KubernetesTokenReviewer, error) {
|
||||
ca, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Kubernetes CA: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(ca) {
|
||||
return nil, fmt.Errorf("Kubernetes CA file contains no certificates")
|
||||
}
|
||||
return &KubernetesTokenReviewer{
|
||||
endpoint: strings.TrimRight(endpoint, "/") + "/apis/authentication.k8s.io/v1/tokenreviews",
|
||||
audience: audience,
|
||||
reviewerTokenFile: reviewerTokenFile,
|
||||
client: &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: pool,
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type tokenReview struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Spec tokenReviewSpec `json:"spec"`
|
||||
Status tokenReviewStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type tokenReviewSpec struct {
|
||||
Token string `json:"token"`
|
||||
Audiences []string `json:"audiences"`
|
||||
}
|
||||
|
||||
type tokenReviewStatus struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Audiences []string `json:"audiences"`
|
||||
Error string `json:"error"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string) (Identity, error) {
|
||||
reviewerToken, err := os.ReadFile(r.reviewerTokenFile)
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read reviewer credential: %w", err)
|
||||
}
|
||||
payload, err := json.Marshal(tokenReview{
|
||||
APIVersion: "authentication.k8s.io/v1",
|
||||
Kind: "TokenReview",
|
||||
Spec: tokenReviewSpec{
|
||||
Token: callerToken,
|
||||
Audiences: []string{r.audience},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(reviewerToken)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return Identity{}, fmt.Errorf("TokenReview returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var review tokenReview
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&review); err != nil {
|
||||
return Identity{}, fmt.Errorf("decode TokenReview: %w", err)
|
||||
}
|
||||
if review.Status.Error != "" {
|
||||
return Identity{}, fmt.Errorf("TokenReview: %s", review.Status.Error)
|
||||
}
|
||||
if !review.Status.Authenticated {
|
||||
return Identity{}, nil
|
||||
}
|
||||
return Identity{Username: review.Status.User.Username, Audiences: review.Status.Audiences}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue