2026-08-18 15:22:52 +02:00
|
|
|
// Package callerauth authenticates protected systems before flex-auth evaluates
|
|
|
|
|
// the authorization request they submit.
|
|
|
|
|
package callerauth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
2026-09-14 04:44:07 +02:00
|
|
|
"time"
|
2026-08-18 15:22:52 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-14 04:44:07 +02:00
|
|
|
NotAfter time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Record is the caller provenance stamped onto a decision envelope.
|
|
|
|
|
// Mode is always set. Principal, audience and expiry are present only when a
|
|
|
|
|
// token was actually reviewed.
|
|
|
|
|
type Record struct {
|
|
|
|
|
Mode Mode
|
|
|
|
|
Principal string
|
|
|
|
|
Audience string
|
|
|
|
|
NotAfter time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type recordContextKey struct{}
|
|
|
|
|
|
|
|
|
|
func WithRecord(ctx context.Context, record Record) context.Context {
|
|
|
|
|
return context.WithValue(ctx, recordContextKey{}, record)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func RecordFromContext(ctx context.Context) (Record, bool) {
|
|
|
|
|
record, ok := ctx.Value(recordContextKey{}).(Record)
|
|
|
|
|
return record, ok
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2026-09-14 04:44:07 +02:00
|
|
|
// The returned identity is populated whenever a token was reviewed, including
|
|
|
|
|
// warn-mode binding failures, so provenance can name an observed principal.
|
|
|
|
|
func (a *Authenticator) Authorize(ctx context.Context, authorization string, systems []string) (Identity, error) {
|
2026-08-18 15:22:52 +02:00
|
|
|
if a == nil || a.mode == ModeDisabled {
|
2026-09-14 04:44:07 +02:00
|
|
|
return Identity{}, nil
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
2026-09-14 04:44:07 +02:00
|
|
|
identity, err := a.authorize(ctx, authorization, systems)
|
2026-08-18 15:22:52 +02:00
|
|
|
if err != nil && a.mode == ModeWarn {
|
|
|
|
|
if a.warnf != nil {
|
|
|
|
|
a.warnf("caller authentication warning: %v", err)
|
|
|
|
|
}
|
2026-09-14 04:44:07 +02:00
|
|
|
return identity, nil
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
2026-09-14 04:44:07 +02:00
|
|
|
return identity, err
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-14 04:44:07 +02:00
|
|
|
// Record returns the provenance object for a reviewed identity. Disabled mode
|
|
|
|
|
// is stated as {"mode":"disabled"} with no principal.
|
|
|
|
|
func (a *Authenticator) Record(identity Identity) Record {
|
|
|
|
|
mode := ModeDisabled
|
|
|
|
|
audience := ""
|
|
|
|
|
if a != nil {
|
|
|
|
|
mode = a.mode
|
|
|
|
|
audience = a.audience
|
|
|
|
|
}
|
|
|
|
|
record := Record{Mode: mode}
|
|
|
|
|
if mode == ModeDisabled || strings.TrimSpace(identity.Username) == "" {
|
|
|
|
|
return record
|
|
|
|
|
}
|
|
|
|
|
record.Principal = identity.Username
|
|
|
|
|
record.Audience = audience
|
|
|
|
|
record.NotAfter = identity.NotAfter
|
|
|
|
|
return record
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *Authenticator) authorize(ctx context.Context, authorization string, systems []string) (Identity, error) {
|
2026-08-18 15:22:52 +02:00
|
|
|
token, ok := strings.CutPrefix(authorization, "Bearer ")
|
|
|
|
|
if !ok || strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), " \t\r\n") {
|
2026-09-14 04:44:07 +02:00
|
|
|
return Identity{}, ErrUnauthenticated
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
2026-09-14 04:44:07 +02:00
|
|
|
token = strings.TrimSpace(token)
|
|
|
|
|
identity, err := a.reviewer.Review(ctx, token)
|
2026-08-18 15:22:52 +02:00
|
|
|
if err != nil {
|
2026-08-23 13:18:26 +02:00
|
|
|
if errors.Is(err, ErrUnauthenticated) {
|
2026-09-14 04:44:07 +02:00
|
|
|
return Identity{}, err
|
|
|
|
|
}
|
|
|
|
|
return Identity{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
|
|
|
}
|
|
|
|
|
if identity.NotAfter.IsZero() {
|
|
|
|
|
if exp, ok := tokenExpiry(token); ok {
|
|
|
|
|
identity.NotAfter = exp
|
2026-08-23 13:18:26 +02:00
|
|
|
}
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) {
|
2026-09-14 04:44:07 +02:00
|
|
|
return Identity{}, ErrUnauthenticated
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
if len(systems) == 0 {
|
2026-09-14 04:44:07 +02:00
|
|
|
return identity, fmt.Errorf("%w: request has no resources", ErrForbidden)
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
for _, system := range systems {
|
|
|
|
|
expected, found := a.bindings[system]
|
|
|
|
|
if !found || expected != identity.Username {
|
2026-09-14 04:44:07 +02:00
|
|
|
return identity, fmt.Errorf("%w: principal %q cannot represent system %q", ErrForbidden, identity.Username, system)
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-14 04:44:07 +02:00
|
|
|
return identity, nil
|
2026-08-18 15:22:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func contains(values []string, wanted string) bool {
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
if value == wanted {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|