사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
424 lines
14 KiB
Go
424 lines
14 KiB
Go
// Package authprojection owns the Edge-local immutable principal projection
|
|
// cache. The cache is deliberately transport-neutral: callers install a
|
|
// snapshot only after the surrounding transport has authenticated its source.
|
|
package authprojection
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidProjection = errors.New("authprojection: invalid projection")
|
|
ErrProjectionTooLarge = errors.New("authprojection: projection exceeds configured bounds")
|
|
ErrGenerationNotHigher = errors.New("authprojection: generation is not higher than the installed generation")
|
|
ErrGenerationConflict = errors.New("authprojection: equal generation changes immutable content")
|
|
ErrProjectionExpired = errors.New("authprojection: projection is expired")
|
|
ErrBindingStale = errors.New("authprojection: credential binding is stale")
|
|
)
|
|
|
|
// State distinguishes the compatibility default from managed authorization.
|
|
// Once any verified snapshot is installed, expiry never returns the cache to
|
|
// unmanaged mode; managed requests remain fail-closed.
|
|
type State uint8
|
|
|
|
const (
|
|
StateUnmanaged State = iota
|
|
StateFresh
|
|
StateExpired
|
|
)
|
|
|
|
func (s State) String() string {
|
|
switch s {
|
|
case StateUnmanaged:
|
|
return "unmanaged"
|
|
case StateFresh:
|
|
return "fresh"
|
|
case StateExpired:
|
|
return "expired"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// ValidateBinding is the immediate pre-lease and pre-send revocation fence.
|
|
// It checks one atomic snapshot and requires an exact generation and route.
|
|
func (c *Cache) ValidateBinding(binding *iop.CredentialLeaseBinding) error {
|
|
if c == nil || binding == nil {
|
|
return ErrBindingStale
|
|
}
|
|
installed := c.current.Load()
|
|
if c.stateFor(installed) != StateFresh || installed.generation != binding.GetProjectionGeneration() {
|
|
return ErrBindingStale
|
|
}
|
|
for _, route := range installed.routes {
|
|
if route.PrincipalRef == binding.GetPrincipalRef() && route.CredentialSlotRef == binding.GetCredentialSlotRef() &&
|
|
route.RouteID == binding.GetRouteId() && route.ProfileID == binding.GetProfileId() &&
|
|
route.UpstreamModel == binding.GetUpstreamTarget() && route.RouteRevision == binding.GetRouteRevision() &&
|
|
route.CredentialRevision == binding.GetCredentialRevision() {
|
|
return nil
|
|
}
|
|
}
|
|
return ErrBindingStale
|
|
}
|
|
|
|
// Limits are hard in-memory and encoded-size bounds for one snapshot.
|
|
type Limits struct {
|
|
MaxTokens int
|
|
MaxRoutes int
|
|
MaxBytes int
|
|
}
|
|
|
|
func DefaultLimits() Limits {
|
|
return Limits{
|
|
MaxTokens: 4096,
|
|
MaxRoutes: 16384,
|
|
MaxBytes: 4 << 20,
|
|
}
|
|
}
|
|
|
|
func normalizeLimits(limits Limits) Limits {
|
|
defaults := DefaultLimits()
|
|
if limits.MaxTokens <= 0 {
|
|
limits.MaxTokens = defaults.MaxTokens
|
|
}
|
|
if limits.MaxRoutes <= 0 {
|
|
limits.MaxRoutes = defaults.MaxRoutes
|
|
}
|
|
if limits.MaxBytes <= 0 {
|
|
limits.MaxBytes = defaults.MaxBytes
|
|
}
|
|
return limits
|
|
}
|
|
|
|
// Principal is safe authenticated caller metadata. It contains no raw token.
|
|
type Principal struct {
|
|
PrincipalRef string
|
|
PrincipalAlias string
|
|
TokenRef string
|
|
TokenRevision uint64
|
|
}
|
|
|
|
// Route is a secret-free immutable route binding retained for later secure
|
|
// transport activation.
|
|
type Route struct {
|
|
RouteID string
|
|
RouteAlias string
|
|
PrincipalRef string
|
|
CredentialSlotRef string
|
|
ProfileID string
|
|
UpstreamModel string
|
|
ResourceSelector string
|
|
RouteRevision uint64
|
|
CredentialRevision uint64
|
|
}
|
|
|
|
// AuthenticatedView is the secret-free, request-local result of authenticating
|
|
// a principal against one immutable projection snapshot. Routes are copied so
|
|
// callers cannot mutate cache-owned state or observe a later generation.
|
|
type AuthenticatedView struct {
|
|
Principal Principal
|
|
Routes []Route
|
|
Generation uint64
|
|
State State
|
|
}
|
|
|
|
// Reader is the narrow authentication surface consumed by HTTP ingress.
|
|
type Reader interface {
|
|
AuthenticatedView([sha256.Size]byte) (AuthenticatedView, bool)
|
|
LookupDigest([sha256.Size]byte) (Principal, State, bool)
|
|
State() State
|
|
Generation() (uint64, bool)
|
|
RoutesForPrincipal(principalRef string) ([]Route, State)
|
|
}
|
|
|
|
// AuthenticatedView performs the digest scan and principal route collection
|
|
// from one atomic snapshot load. Managed HTTP ingress must retain this value
|
|
// for the whole request rather than mixing authentication and routing reads
|
|
// across generations.
|
|
func (c *Cache) AuthenticatedView(digest [sha256.Size]byte) (AuthenticatedView, bool) {
|
|
if c == nil {
|
|
return AuthenticatedView{State: StateUnmanaged}, false
|
|
}
|
|
installed := c.current.Load()
|
|
state := c.stateFor(installed)
|
|
if state != StateFresh {
|
|
return AuthenticatedView{State: state}, false
|
|
}
|
|
matchedIndex := -1
|
|
found := 0
|
|
for index, token := range installed.tokens {
|
|
equal := subtle.ConstantTimeCompare(digest[:], token.digest[:])
|
|
selectCurrent := equal & (found ^ 1)
|
|
matchedIndex = subtle.ConstantTimeSelect(selectCurrent, index, matchedIndex)
|
|
found |= equal
|
|
}
|
|
if found != 1 {
|
|
return AuthenticatedView{State: state, Generation: installed.generation}, false
|
|
}
|
|
principal := installed.tokens[matchedIndex].principal
|
|
view := AuthenticatedView{Principal: principal, Generation: installed.generation, State: state}
|
|
for _, route := range installed.routes {
|
|
if route.PrincipalRef == principal.PrincipalRef {
|
|
view.Routes = append(view.Routes, route)
|
|
}
|
|
}
|
|
return view, true
|
|
}
|
|
|
|
type projectedToken struct {
|
|
digest [sha256.Size]byte
|
|
principal Principal
|
|
}
|
|
|
|
type snapshot struct {
|
|
generation uint64
|
|
issuedAt time.Time
|
|
expiresAt time.Time
|
|
tokens []projectedToken
|
|
routes []Route
|
|
}
|
|
|
|
// Cache performs lock-free reads and atomic whole-snapshot swaps.
|
|
type Cache struct {
|
|
limits Limits
|
|
now func() time.Time
|
|
current atomic.Pointer[snapshot]
|
|
}
|
|
|
|
func NewCache(limits Limits, clock func() time.Time) *Cache {
|
|
if clock == nil {
|
|
clock = time.Now
|
|
}
|
|
return &Cache{limits: normalizeLimits(limits), now: clock}
|
|
}
|
|
|
|
// Apply validates and normalizes an immutable candidate. Higher generations
|
|
// replace the installed snapshot. An equal generation may only advance the
|
|
// freshness window when all authorization content is identical.
|
|
func (c *Cache) Apply(projection *iop.PrincipalProjection) error {
|
|
if c == nil || projection == nil {
|
|
return fmt.Errorf("%w: projection is required", ErrInvalidProjection)
|
|
}
|
|
if len(projection.GetTokens()) > c.limits.MaxTokens {
|
|
return fmt.Errorf("%w: tokens=%d max=%d", ErrProjectionTooLarge, len(projection.GetTokens()), c.limits.MaxTokens)
|
|
}
|
|
if len(projection.GetRoutes()) > c.limits.MaxRoutes {
|
|
return fmt.Errorf("%w: routes=%d max=%d", ErrProjectionTooLarge, len(projection.GetRoutes()), c.limits.MaxRoutes)
|
|
}
|
|
if size := proto.Size(projection); size > c.limits.MaxBytes {
|
|
return fmt.Errorf("%w: bytes=%d max=%d", ErrProjectionTooLarge, size, c.limits.MaxBytes)
|
|
}
|
|
|
|
issuedAt := time.Unix(0, projection.GetIssuedAtUnixNano()).UTC()
|
|
expiresAt := time.Unix(0, projection.GetExpiresAtUnixNano()).UTC()
|
|
if projection.GetIssuedAtUnixNano() <= 0 || projection.GetExpiresAtUnixNano() <= projection.GetIssuedAtUnixNano() {
|
|
return fmt.Errorf("%w: invalid issued/expiry timestamps", ErrInvalidProjection)
|
|
}
|
|
validationNow := c.now()
|
|
if issuedAt.After(validationNow) {
|
|
return fmt.Errorf("%w: projection is not issued yet", ErrInvalidProjection)
|
|
}
|
|
if !validationNow.Before(expiresAt) {
|
|
return ErrProjectionExpired
|
|
}
|
|
|
|
candidate := &snapshot{
|
|
generation: projection.GetGeneration(),
|
|
issuedAt: issuedAt,
|
|
expiresAt: expiresAt,
|
|
tokens: make([]projectedToken, 0, len(projection.GetTokens())),
|
|
routes: make([]Route, 0, len(projection.GetRoutes())),
|
|
}
|
|
seenDigests := make(map[string]struct{}, len(projection.GetTokens()))
|
|
seenTokenRefs := make(map[string]struct{}, len(projection.GetTokens()))
|
|
for index, token := range projection.GetTokens() {
|
|
if token == nil {
|
|
return fmt.Errorf("%w: token %d is nil", ErrInvalidProjection, index)
|
|
}
|
|
digestText := strings.ToLower(strings.TrimSpace(token.GetTokenDigestSha256()))
|
|
digestBytes, err := hex.DecodeString(digestText)
|
|
if err != nil || len(digestBytes) != sha256.Size {
|
|
return fmt.Errorf("%w: token %d digest must be SHA-256 hex", ErrInvalidProjection, index)
|
|
}
|
|
if strings.TrimSpace(token.GetPrincipalRef()) == "" || strings.TrimSpace(token.GetTokenRef()) == "" {
|
|
return fmt.Errorf("%w: token %d principal_ref and token_ref are required", ErrInvalidProjection, index)
|
|
}
|
|
if _, duplicate := seenDigests[digestText]; duplicate {
|
|
return fmt.Errorf("%w: duplicate token digest", ErrInvalidProjection)
|
|
}
|
|
seenDigests[digestText] = struct{}{}
|
|
tokenRef := strings.TrimSpace(token.GetTokenRef())
|
|
if _, duplicate := seenTokenRefs[tokenRef]; duplicate {
|
|
return fmt.Errorf("%w: duplicate token reference", ErrInvalidProjection)
|
|
}
|
|
seenTokenRefs[tokenRef] = struct{}{}
|
|
var digest [sha256.Size]byte
|
|
copy(digest[:], digestBytes)
|
|
candidate.tokens = append(candidate.tokens, projectedToken{
|
|
digest: digest,
|
|
principal: Principal{
|
|
PrincipalRef: strings.TrimSpace(token.GetPrincipalRef()),
|
|
PrincipalAlias: strings.TrimSpace(token.GetPrincipalAlias()),
|
|
TokenRef: tokenRef,
|
|
TokenRevision: token.GetTokenRevision(),
|
|
},
|
|
})
|
|
}
|
|
|
|
seenRouteSelectors := make(map[string]struct{}, 2*len(projection.GetRoutes()))
|
|
for index, route := range projection.GetRoutes() {
|
|
if route == nil {
|
|
return fmt.Errorf("%w: route %d is nil", ErrInvalidProjection, index)
|
|
}
|
|
if strings.TrimSpace(route.GetRouteId()) == "" ||
|
|
strings.TrimSpace(route.GetPrincipalRef()) == "" ||
|
|
strings.TrimSpace(route.GetCredentialSlotRef()) == "" ||
|
|
strings.TrimSpace(route.GetProfileId()) == "" ||
|
|
strings.TrimSpace(route.GetUpstreamModel()) == "" ||
|
|
strings.TrimSpace(route.GetResourceSelector()) == "" {
|
|
return fmt.Errorf("%w: route %d is missing required metadata", ErrInvalidProjection, index)
|
|
}
|
|
principalRef := strings.TrimSpace(route.GetPrincipalRef())
|
|
routeID := strings.TrimSpace(route.GetRouteId())
|
|
routeAlias := strings.TrimSpace(route.GetRouteAlias())
|
|
routeKey := principalRef + "\x00" + routeID
|
|
if _, duplicate := seenRouteSelectors[routeKey]; duplicate {
|
|
return fmt.Errorf("%w: ambiguous principal route selector", ErrInvalidProjection)
|
|
}
|
|
seenRouteSelectors[routeKey] = struct{}{}
|
|
if routeAlias != "" {
|
|
aliasKey := principalRef + "\x00" + routeAlias
|
|
if _, duplicate := seenRouteSelectors[aliasKey]; duplicate {
|
|
return fmt.Errorf("%w: ambiguous principal route selector", ErrInvalidProjection)
|
|
}
|
|
seenRouteSelectors[aliasKey] = struct{}{}
|
|
}
|
|
candidate.routes = append(candidate.routes, Route{
|
|
RouteID: routeID,
|
|
RouteAlias: routeAlias,
|
|
PrincipalRef: principalRef,
|
|
CredentialSlotRef: strings.TrimSpace(route.GetCredentialSlotRef()),
|
|
ProfileID: strings.TrimSpace(route.GetProfileId()),
|
|
UpstreamModel: strings.TrimSpace(route.GetUpstreamModel()),
|
|
ResourceSelector: strings.TrimSpace(route.GetResourceSelector()),
|
|
RouteRevision: route.GetRouteRevision(),
|
|
CredentialRevision: route.GetCredentialRevision(),
|
|
})
|
|
}
|
|
|
|
for {
|
|
installed := c.current.Load()
|
|
if installed != nil && candidate.generation < installed.generation {
|
|
return fmt.Errorf("%w: candidate=%d installed=%d", ErrGenerationNotHigher, candidate.generation, installed.generation)
|
|
}
|
|
if installed != nil && candidate.generation == installed.generation {
|
|
if !sameImmutableContent(candidate, installed) {
|
|
return fmt.Errorf("%w: generation=%d", ErrGenerationConflict, candidate.generation)
|
|
}
|
|
if !candidate.issuedAt.After(installed.issuedAt) || !candidate.expiresAt.After(installed.expiresAt) {
|
|
return nil
|
|
}
|
|
}
|
|
if !c.now().Before(candidate.expiresAt) {
|
|
return ErrProjectionExpired
|
|
}
|
|
if c.current.CompareAndSwap(installed, candidate) {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func sameImmutableContent(left, right *snapshot) bool {
|
|
if left == nil || right == nil || len(left.tokens) != len(right.tokens) || len(left.routes) != len(right.routes) {
|
|
return false
|
|
}
|
|
tokens := make(map[projectedToken]int, len(left.tokens))
|
|
for _, token := range left.tokens {
|
|
tokens[token]++
|
|
}
|
|
for _, token := range right.tokens {
|
|
if tokens[token] == 0 {
|
|
return false
|
|
}
|
|
tokens[token]--
|
|
}
|
|
routes := make(map[Route]int, len(left.routes))
|
|
for _, route := range left.routes {
|
|
routes[route]++
|
|
}
|
|
for _, route := range right.routes {
|
|
if routes[route] == 0 {
|
|
return false
|
|
}
|
|
routes[route]--
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *Cache) stateFor(installed *snapshot) State {
|
|
if installed == nil {
|
|
return StateUnmanaged
|
|
}
|
|
if !c.now().Before(installed.expiresAt) {
|
|
return StateExpired
|
|
}
|
|
return StateFresh
|
|
}
|
|
|
|
func (c *Cache) State() State {
|
|
if c == nil {
|
|
return StateUnmanaged
|
|
}
|
|
return c.stateFor(c.current.Load())
|
|
}
|
|
|
|
func (c *Cache) Generation() (uint64, bool) {
|
|
if c == nil {
|
|
return 0, false
|
|
}
|
|
installed := c.current.Load()
|
|
if installed == nil {
|
|
return 0, false
|
|
}
|
|
return installed.generation, true
|
|
}
|
|
|
|
// LookupDigest compares the supplied verifier digest against every bounded
|
|
// candidate using crypto/subtle. The raw caller token never enters this cache.
|
|
func (c *Cache) LookupDigest(digest [sha256.Size]byte) (Principal, State, bool) {
|
|
view, ok := c.AuthenticatedView(digest)
|
|
return view.Principal, view.State, ok
|
|
}
|
|
|
|
// RoutesForPrincipal returns a copy of the fresh principal route projection.
|
|
// Managed expired state returns no routes and remains fail-closed.
|
|
func (c *Cache) RoutesForPrincipal(principalRef string) ([]Route, State) {
|
|
if c == nil {
|
|
return nil, StateUnmanaged
|
|
}
|
|
installed := c.current.Load()
|
|
state := c.stateFor(installed)
|
|
if state != StateFresh {
|
|
return nil, state
|
|
}
|
|
routes := make([]Route, 0)
|
|
for _, route := range installed.routes {
|
|
if route.PrincipalRef == principalRef {
|
|
routes = append(routes, route)
|
|
}
|
|
}
|
|
return routes, state
|
|
}
|
|
|
|
var _ Reader = (*Cache)(nil)
|