사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
711 lines
23 KiB
Go
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)
|
|
}
|
|
}
|