iop/apps/edge/internal/authprojection/cache.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

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)