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

711 lines
23 KiB
Go

// Package credentialops provides a principal-authenticated, in-process
// management service for credential slots and routes. It sits between the
// wire layer and the credentialstore and enforces three invariants that the
// store alone cannot guarantee:
//
// 1. Authentication — every request is bound to a principal resolved from a
// presented IOP token digest. The raw token is hashed inside the service and never passed to the store.
//
// 2. Target ownership — callers may only mutate resources that belong to
// their own principal. Cross-principal references are rejected.
//
// 3. Secret blindness — the service accepts plaintext secrets at the
// method boundary, hands an opaque encrypted envelope to the store, and
// never exposes raw secret material in responses, logs, or errors.
//
// The service is transport-neutral. It does not register listeners, declare
// protobuf messages, or depend on any network adapter. The production
// composition may supply a SecretSealer; mutating methods that touch secret
// material fail closed when the sealer is unset.
package credentialops
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"iop/apps/control-plane/internal/credentialseal"
"iop/apps/control-plane/internal/credentialstore"
)
// SecretSealer is the injected boundary for at-rest secret encryption.
// Implementations must produce an opaque ciphertext envelope that the
// credentialstore persists; the raw plaintext must never leave the method
// boundary. Mutating methods that require one return ErrSealerUnavailable
// when composition leaves it unset.
//
// The seal context is the lower-layer credentialseal.Context so this service
// depends on the crypto primitive rather than the reverse, avoiding any layer
// inversion.
type SecretSealer interface {
Seal(ctx context.Context, plaintext []byte, sealCtx credentialseal.Context) (credentialstore.SecretEnvelope, error)
}
// ErrSealerUnavailable is returned by mutating methods when no SecretSealer
// is configured on the service.
var ErrSealerUnavailable = errors.New("credentialops: sealer is not configured")
// ErrInvalidCredentialKind is returned when the caller supplies a
// credential kind that is not one of the recognized canonical values.
var ErrInvalidCredentialKind = errors.New("credentialops: invalid credential kind")
// ErrUnauthorized is returned when the presented IOP token does not resolve
// to an active principal or when the caller targets a resource that does
// not belong to the resolved principal.
var ErrUnauthorized = errors.New("credentialops: unauthorized")
// ErrNotFound is returned when a requested slot or route is not found.
var ErrNotFound = errors.New("credentialops: not found")
// ErrStaleRevision is returned when a CAS operation fails because the
// caller's revision does not match the current row revision.
var ErrStaleRevision = errors.New("credentialops: stale revision")
// ErrSecretHandlingFailed is returned when secret sealing, unsealing, or envelope validation fails.
// It ensures sensitive sealer error strings or key metadata never leak across the boundary.
var ErrSecretHandlingFailed = errors.New("credentialops: secret handling failed")
// IssuedToken pairs the token DTO with the one-time raw token string returned upon token creation.
type IssuedToken struct {
Token TokenRecord
RawToken string
}
// SlotRecord is the secret-blind DTO returned by slot queries. It exposes
// only metadata; the plaintext IOP token, provider secret, and opaque
// encrypted envelope are never present.
type SlotRecord struct {
ID string
Vendor string
CredentialKind string
Alias string
Status string
Revision int64
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt *time.Time
}
// RouteRecord is the DTO returned by route queries. It is a thin projection
// of the credentialstore.RouteRecord with no secret material.
type RouteRecord struct {
ID string
SlotID string
Alias string
ProfileID string
UpstreamModel string
ResourceSelector string
Status string
Revision int64
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt *time.Time
}
// TokenRecord is the digest-free DTO returned by token operations. It exposes
// metadata and opaque token reference; the raw token and digest are never present.
type TokenRecord struct {
ID string
TokenRef string
Status string
Revision int64
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt *time.Time
}
// CreateSlotInput holds parameters for creating a new credential slot.
type CreateSlotInput struct {
Vendor string
CredentialKind string
Alias string
ProviderSecret []byte
}
// RotateSlotInput holds parameters for rotating a slot's secret envelope.
type RotateSlotInput struct {
SlotID string
Revision int64
ProviderSecret []byte
}
// CreateRouteInput holds parameters for creating a new route binding.
type CreateRouteInput struct {
SlotID string
Alias string
ProfileID string
UpstreamModel string
ResourceSelector string
}
// UpdateRouteInput holds parameters for updating an existing route binding.
type UpdateRouteInput struct {
RouteID string
CurrentRevision int64
SlotID string
Alias string
ProfileID string
UpstreamModel string
ResourceSelector string
}
// Service is the principal-scoped credential management service. It is
// transport-neutral and does not register listeners or protobuf messages.
type Service struct {
store *credentialstore.Store
logger *zap.Logger
sealer SecretSealer
randomReader io.Reader
}
// NewService constructs a Service bound to the given store. The sealer is
// optional; mutating methods that require it return ErrSealerUnavailable
// when none is configured. The randomReader is used for internal
// randomness and may be nil (the crypto/rand default is used).
func NewService(store *credentialstore.Store, logger *zap.Logger, sealer SecretSealer, randomReader io.Reader) *Service {
return &Service{
store: store,
logger: logger,
sealer: sealer,
randomReader: randomReader,
}
}
// authenticate resolves the presented IOP token to a principal. The raw
// token bytes are hashed in process; only the SHA-256 digest is compared
// against the store. The returned principal ID and token record are the
// only values passed to downstream store calls.
func (s *Service) authenticate(ctx context.Context, rawIOPToken []byte) (string, *credentialstore.TokenRecord, error) {
if len(rawIOPToken) == 0 {
return "", nil, ErrUnauthorized
}
digest := sha256Hex(rawIOPToken)
p, t, err := s.store.LookupTokenByDigest(ctx, digest)
if err != nil {
if errors.Is(err, credentialstore.ErrTokenNotFound) {
return "", nil, ErrUnauthorized
}
return "", nil, fmt.Errorf("credentialops: authenticate: %w", err)
}
return p.ID, t, nil
}
// requireOwner verifies that the requested resource belongs to the
// authenticated principal. It returns ErrNotFound when the ownership
// check fails.
func (s *Service) requireOwner(ctx context.Context, principalID string, slotID string) error {
_, err := s.store.GetSlot(ctx, principalID, slotID)
if err != nil {
if errors.Is(err, credentialstore.ErrSlotNotFound) {
return ErrNotFound
}
return fmt.Errorf("credentialops: require owner: %w", err)
}
return nil
}
// requireOwnerRoute verifies that the requested route belongs to the
// authenticated principal.
func (s *Service) requireOwnerRoute(ctx context.Context, principalID string, routeID string) error {
_, err := s.store.GetRoute(ctx, principalID, routeID)
if err != nil {
if errors.Is(err, credentialstore.ErrRouteNotFound) {
return ErrNotFound
}
return fmt.Errorf("credentialops: require owner route: %w", err)
}
return nil
}
// CreateSlot seals the caller-supplied provider secret and creates a slot for the authenticated principal.
func (s *Service) CreateSlot(ctx context.Context, rawIOPToken []byte, input CreateSlotInput) (SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return SlotRecord{}, err
}
if s.sealer == nil {
return SlotRecord{}, ErrSealerUnavailable
}
owned := append([]byte(nil), input.ProviderSecret...)
defer clear(owned)
// Allocate the stable slot id before sealing so the create-time AAD binds to
// the exact slot that will persist the ciphertext.
slotID := uuid.NewString()
kind, err := canonicalCredentialKind(input.CredentialKind)
if err != nil {
return SlotRecord{}, err
}
sealCtx := credentialseal.Context{
PrincipalID: principalID,
SlotID: slotID,
Kind: kind,
}
sealed, err := s.sealer.Seal(ctx, owned, sealCtx)
if err != nil {
return SlotRecord{}, ErrSecretHandlingFailed
}
created, err := s.store.CreateSlot(ctx, credentialstore.CreateSlotInput{
SlotID: slotID,
PrincipalID: principalID,
Vendor: input.Vendor,
CredentialKind: kind,
Alias: input.Alias,
Envelope: sealed,
})
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
return slotRecordFromSlot(created), nil
}
// RotateSlot rotates the secret envelope on a slot. The caller presents
// the raw IOP token, input params, and provider secret.
func (s *Service) RotateSlot(ctx context.Context, rawIOPToken []byte, input RotateSlotInput) (SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return SlotRecord{}, err
}
if err := s.requireOwner(ctx, principalID, input.SlotID); err != nil {
return SlotRecord{}, err
}
if s.sealer == nil {
return SlotRecord{}, ErrSealerUnavailable
}
slot, err := s.store.GetSlot(ctx, principalID, input.SlotID)
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
sealCtx := credentialseal.Context{
PrincipalID: principalID,
SlotID: input.SlotID,
Kind: slot.CredentialKind,
}
owned := append([]byte(nil), input.ProviderSecret...)
defer clear(owned)
sealed, err := s.sealer.Seal(ctx, owned, sealCtx)
if err != nil {
return SlotRecord{}, ErrSecretHandlingFailed
}
rotated, err := s.store.RotateSlotSecret(ctx, credentialstore.RotateSlotSecretInput{
PrincipalID: principalID,
SlotID: input.SlotID,
CurrentRevision: input.Revision,
Envelope: sealed,
})
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
return slotRecordFromSlot(rotated), nil
}
// DisableSlot transitions an active slot to disabled using CAS on revision.
func (s *Service) DisableSlot(ctx context.Context, rawIOPToken []byte, slotID string, revision int64) (SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return SlotRecord{}, err
}
if err := s.requireOwner(ctx, principalID, slotID); err != nil {
return SlotRecord{}, err
}
disabled, err := s.store.DisableSlot(ctx, principalID, slotID, revision)
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
return slotRecordFromSlot(disabled), nil
}
// EnableSlot transitions a disabled slot back to active using CAS on revision.
func (s *Service) EnableSlot(ctx context.Context, rawIOPToken []byte, slotID string, revision int64) (SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return SlotRecord{}, err
}
if err := s.requireOwner(ctx, principalID, slotID); err != nil {
return SlotRecord{}, err
}
enabled, err := s.store.EnableSlot(ctx, principalID, slotID, revision)
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
return slotRecordFromSlot(enabled), nil
}
// RevokeSlot permanently revokes a slot using CAS on revision.
func (s *Service) RevokeSlot(ctx context.Context, rawIOPToken []byte, slotID string, revision int64) (SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return SlotRecord{}, err
}
if err := s.requireOwner(ctx, principalID, slotID); err != nil {
return SlotRecord{}, err
}
revoked, err := s.store.RevokeSlot(ctx, principalID, slotID, revision)
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
return slotRecordFromSlot(revoked), nil
}
// GetSlot returns a secret-blind DTO for a single slot.
func (s *Service) GetSlot(ctx context.Context, rawIOPToken []byte, slotID string) (SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return SlotRecord{}, err
}
if err := s.requireOwner(ctx, principalID, slotID); err != nil {
return SlotRecord{}, err
}
slot, err := s.store.GetSlot(ctx, principalID, slotID)
if err != nil {
return SlotRecord{}, mapStoreError(err)
}
return slotRecordFromSlot(slot), nil
}
// ListSlots returns all secret-blind slot DTOs for the authenticated principal.
func (s *Service) ListSlots(ctx context.Context, rawIOPToken []byte) ([]SlotRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return nil, err
}
slots, err := s.store.ListSlots(ctx, principalID)
if err != nil {
return nil, fmt.Errorf("credentialops: list slots: %w", err)
}
out := make([]SlotRecord, len(slots))
for i := range slots {
out[i] = slotRecordFromSlot(&slots[i])
}
return out, nil
}
// CreateRoute binds a new route to an owned slot for the authenticated principal.
func (s *Service) CreateRoute(ctx context.Context, rawIOPToken []byte, input CreateRouteInput) (RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return RouteRecord{}, err
}
if err := s.requireOwner(ctx, principalID, input.SlotID); err != nil {
return RouteRecord{}, err
}
route, err := s.store.CreateRoute(ctx, credentialstore.CreateRouteInput{
PrincipalID: principalID,
SlotID: input.SlotID,
Alias: input.Alias,
ProfileID: input.ProfileID,
UpstreamModel: input.UpstreamModel,
ResourceSelector: input.ResourceSelector,
})
if err != nil {
return RouteRecord{}, mapStoreError(err)
}
return routeRecordFromRoute(route), nil
}
// UpdateRoute updates an existing route binding under CAS on revision.
func (s *Service) UpdateRoute(ctx context.Context, rawIOPToken []byte, input UpdateRouteInput) (RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return RouteRecord{}, err
}
if err := s.requireOwnerRoute(ctx, principalID, input.RouteID); err != nil {
return RouteRecord{}, err
}
if err := s.requireOwner(ctx, principalID, input.SlotID); err != nil {
return RouteRecord{}, err
}
updated, err := s.store.UpdateRoute(ctx, credentialstore.UpdateRouteInput{
PrincipalID: principalID,
RouteID: input.RouteID,
CurrentRevision: input.CurrentRevision,
SlotID: input.SlotID,
Alias: input.Alias,
ProfileID: input.ProfileID,
UpstreamModel: input.UpstreamModel,
ResourceSelector: input.ResourceSelector,
})
if err != nil {
return RouteRecord{}, mapStoreError(err)
}
return routeRecordFromRoute(updated), nil
}
// GetRoute returns a DTO for a single route.
func (s *Service) GetRoute(ctx context.Context, rawIOPToken []byte, routeID string) (RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return RouteRecord{}, err
}
if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil {
return RouteRecord{}, err
}
route, err := s.store.GetRoute(ctx, principalID, routeID)
if err != nil {
return RouteRecord{}, mapStoreError(err)
}
return routeRecordFromRoute(route), nil
}
// ListRoutes returns all route DTOs for the authenticated principal.
func (s *Service) ListRoutes(ctx context.Context, rawIOPToken []byte) ([]RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return nil, err
}
routes, err := s.store.ListRoutes(ctx, principalID)
if err != nil {
return nil, fmt.Errorf("credentialops: list routes: %w", err)
}
out := make([]RouteRecord, len(routes))
for i := range routes {
out[i] = routeRecordFromRoute(&routes[i])
}
return out, nil
}
// DisableRoute transitions an active route to disabled using CAS on revision.
func (s *Service) DisableRoute(ctx context.Context, rawIOPToken []byte, routeID string, revision int64) (RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return RouteRecord{}, err
}
if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil {
return RouteRecord{}, err
}
disabled, err := s.store.DisableRoute(ctx, principalID, routeID, revision)
if err != nil {
return RouteRecord{}, mapStoreError(err)
}
return routeRecordFromRoute(disabled), nil
}
// EnableRoute transitions a disabled route back to active using CAS on revision.
func (s *Service) EnableRoute(ctx context.Context, rawIOPToken []byte, routeID string, revision int64) (RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return RouteRecord{}, err
}
if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil {
return RouteRecord{}, err
}
enabled, err := s.store.EnableRoute(ctx, principalID, routeID, revision)
if err != nil {
return RouteRecord{}, mapStoreError(err)
}
return routeRecordFromRoute(enabled), nil
}
// RevokeRoute permanently revokes a route using CAS on revision.
func (s *Service) RevokeRoute(ctx context.Context, rawIOPToken []byte, routeID string, revision int64) (RouteRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return RouteRecord{}, err
}
if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil {
return RouteRecord{}, err
}
revoked, err := s.store.RevokeRoute(ctx, principalID, routeID, revision)
if err != nil {
return RouteRecord{}, mapStoreError(err)
}
return routeRecordFromRoute(revoked), nil
}
// CreateToken issues a new active IOP token for the authenticated principal.
// The raw token string is returned exactly once in IssuedToken and is never persisted or retrievable.
func (s *Service) CreateToken(ctx context.Context, rawIOPToken []byte) (IssuedToken, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return IssuedToken{}, err
}
issued, err := s.store.CreateToken(ctx, principalID)
if err != nil {
return IssuedToken{}, mapStoreError(err)
}
return IssuedToken{
Token: tokenRecordFromToken(&issued.Token),
RawToken: issued.RawToken,
}, nil
}
// ListTokens returns all digest-free token records for the authenticated principal.
func (s *Service) ListTokens(ctx context.Context, rawIOPToken []byte) ([]TokenRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return nil, err
}
tokens, err := s.store.ListTokens(ctx, principalID)
if err != nil {
return nil, mapStoreError(err)
}
out := make([]TokenRecord, len(tokens))
for i := range tokens {
out[i] = tokenRecordFromToken(&tokens[i])
}
return out, nil
}
// DisableToken transitions an active token to disabled using CAS on revision.
func (s *Service) DisableToken(ctx context.Context, rawIOPToken []byte, tokenRef string, revision int64) (TokenRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return TokenRecord{}, err
}
disabled, err := s.store.DisableToken(ctx, principalID, tokenRef, revision)
if err != nil {
return TokenRecord{}, mapStoreError(err)
}
return tokenRecordFromToken(disabled), nil
}
// RevokeToken permanently revokes a token using CAS on revision.
func (s *Service) RevokeToken(ctx context.Context, rawIOPToken []byte, tokenRef string, revision int64) (TokenRecord, error) {
principalID, _, err := s.authenticate(ctx, rawIOPToken)
if err != nil {
return TokenRecord{}, err
}
revoked, err := s.store.RevokeToken(ctx, principalID, tokenRef, revision)
if err != nil {
return TokenRecord{}, mapStoreError(err)
}
return tokenRecordFromToken(revoked), nil
}
// slotRecordFromSlot converts a credentialstore.CredentialSlotRecord to a
// secret-blind SlotRecord DTO.
func slotRecordFromSlot(s *credentialstore.CredentialSlotRecord) SlotRecord {
var r SlotRecord
r.ID = s.ID
r.Vendor = s.Vendor
r.CredentialKind = s.CredentialKind
r.Alias = s.Alias
r.Status = s.Status
r.Revision = s.Revision
r.CreatedAt = s.CreatedAt
r.UpdatedAt = s.UpdatedAt
r.RevokedAt = s.RevokedAt
return r
}
// routeRecordFromRoute converts a credentialstore.RouteRecord to a DTO.
func routeRecordFromRoute(r *credentialstore.RouteRecord) RouteRecord {
return RouteRecord{
ID: r.ID,
SlotID: r.SlotID,
Alias: r.Alias,
ProfileID: r.ProfileID,
UpstreamModel: r.UpstreamModel,
ResourceSelector: r.ResourceSelector,
Status: r.Status,
Revision: r.Revision,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
RevokedAt: r.RevokedAt,
}
}
// tokenRecordFromToken converts a credentialstore.TokenRecord to a DTO.
func tokenRecordFromToken(t *credentialstore.TokenRecord) TokenRecord {
return TokenRecord{
ID: t.ID,
TokenRef: t.TokenRef,
Status: t.Status,
Revision: t.Revision,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
RevokedAt: t.RevokedAt,
}
}
// mapStoreError translates credentialstore errors into credentialops
// typed errors so callers do not depend on the store package.
func mapStoreError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, credentialstore.ErrRevisionMismatch) {
return ErrStaleRevision
}
if errors.Is(err, credentialstore.ErrSlotNotFound) || errors.Is(err, credentialstore.ErrRouteNotFound) {
return ErrNotFound
}
if errors.Is(err, credentialstore.ErrCrossPrincipalSlot) {
return ErrUnauthorized
}
if errors.Is(err, credentialstore.ErrTokenNotFound) || errors.Is(err, credentialstore.ErrPrincipalNotFound) {
return ErrNotFound
}
if errors.Is(err, credentialstore.ErrInvalidSecretEnvelope) ||
errors.Is(err, credentialstore.ErrEnvelopeKeyUnavailable) ||
errors.Is(err, credentialstore.ErrUnknownEnvelopeKey) {
return ErrSecretHandlingFailed
}
if errors.Is(err, credentialstore.ErrTokenRevoked) || errors.Is(err, credentialstore.ErrTokenNotActive) {
return fmt.Errorf("%w: %v", ErrUnauthorized, err)
}
if errors.Is(err, credentialstore.ErrSlotRevoked) || errors.Is(err, credentialstore.ErrRouteRevoked) {
return fmt.Errorf("%w: %v", ErrUnauthorized, err)
}
if errors.Is(err, credentialstore.ErrSlotNotActive) || errors.Is(err, credentialstore.ErrRouteNotActive) {
return fmt.Errorf("%w: %v", ErrUnauthorized, err)
}
return err
}
// sha256Hex returns the lowercase hex SHA-256 digest of raw.
func sha256Hex(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
// canonicalCredentialKind normalizes and validates the caller-supplied
// credential kind. It reuses the credentialstore constants so the value
// written into the seal context and persisted in the slot row are identical,
// preventing AAD divergence after restart. Unknown kinds are rejected before
// any sealer or store call.
func canonicalCredentialKind(raw string) (string, error) {
norm := strings.ToLower(strings.TrimSpace(raw))
switch norm {
case credentialstore.CredentialKindBearer, credentialstore.CredentialKindAPIKey:
return norm, nil
default:
return "", fmt.Errorf("%w: %q", ErrInvalidCredentialKind, raw)
}
}