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

538 lines
18 KiB
Go

package credentialstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
var (
// StatusDraft is the initial state of a credential slot. A draft cannot
// serve a route until a compatible binding validates its envelope key.
StatusDraft = "draft"
// ErrSlotNotFound is returned when a requested credential slot does not exist.
ErrSlotNotFound = errors.New("credentialstore: slot not found")
// ErrSlotAliasAlreadyExists is returned when a slot alias is already in use for a principal.
ErrSlotAliasAlreadyExists = errors.New("credentialstore: slot alias already exists for principal")
// ErrSlotRevoked is returned when attempting to modify a revoked slot.
ErrSlotRevoked = errors.New("credentialstore: revoked slot cannot be modified")
// ErrSlotNotActive is returned when an operation requires an active slot.
ErrSlotNotActive = errors.New("credentialstore: slot is not active")
)
const (
CredentialKindBearer = "bearer"
CredentialKindAPIKey = "api_key"
)
func normalizeCredentialPart(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeAlias(value string) string {
return strings.TrimSpace(value)
}
func knownCredentialKind(kind string) bool {
switch kind {
case CredentialKindBearer, CredentialKindAPIKey:
return true
default:
return false
}
}
// CreateSlotInput holds parameters for creating a new credential slot.
//
// SlotID is an additive, optional caller-generated UUID. When set it lets the
// caller bind create-time secret AAD to a stable slot id before sealing; when
// empty (the legacy zero value) the store generates the id.
type CreateSlotInput struct {
SlotID string
PrincipalID string
Vendor string
CredentialKind string
Alias string
Envelope SecretEnvelope
}
// RotateSlotSecretInput holds parameters for rotating secret envelope on an existing slot.
type RotateSlotSecretInput struct {
PrincipalID string
SlotID string
CurrentRevision int64
Envelope SecretEnvelope
}
// CredentialSlotRecord represents a persisted credential slot row.
type CredentialSlotRecord struct {
ID string
PrincipalID string
Vendor string
CredentialKind string
Alias string
Status string
Revision int64
Envelope SecretEnvelope
CreatedAt time.Time
UpdatedAt time.Time
RevokedAt *time.Time
}
// SlotRevisionRecord represents a persisted historical secret revision for a slot.
type SlotRevisionRecord struct {
ID string
SlotID string
Revision int64
Envelope SecretEnvelope
CreatedAt time.Time
}
// CreateSlot creates a principal-scoped credential slot with an initial secret revision.
func (s *Store) CreateSlot(ctx context.Context, in CreateSlotInput) (*CredentialSlotRecord, error) {
if strings.TrimSpace(in.PrincipalID) == "" {
return nil, fmt.Errorf("credentialstore: CreateSlot: principal_id is required")
}
vendor := normalizeCredentialPart(in.Vendor)
if vendor == "" {
return nil, fmt.Errorf("credentialstore: CreateSlot: vendor is required")
}
kind := normalizeCredentialPart(in.CredentialKind)
if !knownCredentialKind(kind) {
return nil, fmt.Errorf("credentialstore: CreateSlot: credential_kind is not recognized")
}
alias := normalizeAlias(in.Alias)
if err := s.validateEnvelopeKey(ctx, in.Envelope); err != nil {
return nil, err
}
slotID := strings.TrimSpace(in.SlotID)
if slotID == "" {
slotID = uuid.New().String()
} else if _, err := uuid.Parse(slotID); err != nil {
return nil, fmt.Errorf("credentialstore: CreateSlot: slot_id must be a valid UUID")
}
revisionID := uuid.New().String()
now := time.Now().UTC()
var record CredentialSlotRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
var pCount int
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM principals WHERE id=?`), in.PrincipalID).Scan(&pCount); err != nil {
return fmt.Errorf("credentialstore: check principal: %w", err)
}
if pCount == 0 {
return fmt.Errorf("credentialstore: principal not found: %s", in.PrincipalID)
}
if alias != "" {
var sCount int
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM credential_slots WHERE principal_id=? AND alias=?`), in.PrincipalID, alias).Scan(&sCount); err != nil {
return fmt.Errorf("credentialstore: check slot alias: %w", err)
}
if sCount > 0 {
return ErrSlotAliasAlreadyExists
}
}
var aliasArg any
if alias != "" {
aliasArg = alias
}
if _, err := tx.ExecContext(ctx, s.bind(`
INSERT INTO credential_slots (id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), slotID, in.PrincipalID, vendor, kind, aliasArg, StatusDraft, 0,
in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD,
now, now,
); err != nil {
return fmt.Errorf("credentialstore: insert slot: %w", err)
}
if _, err := tx.ExecContext(ctx, s.bind(`
INSERT INTO credential_slot_revisions (id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), revisionID, slotID, 0,
in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD,
now,
); err != nil {
return fmt.Errorf("credentialstore: insert slot revision: %w", err)
}
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
return err
}
record = CredentialSlotRecord{
ID: slotID,
PrincipalID: in.PrincipalID,
Vendor: vendor,
CredentialKind: kind,
Alias: alias,
Status: StatusDraft,
Revision: 0,
Envelope: in.Envelope,
CreatedAt: now,
UpdatedAt: now,
}
return nil
})
if err != nil {
return nil, err
}
return &record, nil
}
// GetSlot retrieves a credential slot by principal ID and slot ID.
func (s *Store) GetSlot(ctx context.Context, principalID, slotID string) (*CredentialSlotRecord, error) {
var r CredentialSlotRecord
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx, s.bind(`
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
FROM credential_slots
WHERE principal_id=? AND id=?
`), principalID, slotID).Scan(
&r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &alias, &r.Status, &r.Revision,
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
&createdAt, &updatedAt, &revokedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSlotNotFound
}
return nil, err
}
if alias.Valid {
r.Alias = alias.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
return &r, nil
}
// GetSlotByAlias retrieves a credential slot by principal ID and alias.
func (s *Store) GetSlotByAlias(ctx context.Context, principalID, alias string) (*CredentialSlotRecord, error) {
var r CredentialSlotRecord
var aliasValue sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx, s.bind(`
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
FROM credential_slots
WHERE principal_id=? AND alias=?
`), principalID, normalizeAlias(alias)).Scan(
&r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &aliasValue, &r.Status, &r.Revision,
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
&createdAt, &updatedAt, &revokedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSlotNotFound
}
return nil, err
}
if aliasValue.Valid {
r.Alias = aliasValue.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
return &r, nil
}
// ListSlots returns all credential slots belonging to a principal.
func (s *Store) ListSlots(ctx context.Context, principalID string) ([]CredentialSlotRecord, error) {
rows, err := s.db.QueryContext(ctx, s.bind(`
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
FROM credential_slots
WHERE principal_id=?
ORDER BY alias
`), principalID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CredentialSlotRecord
for rows.Next() {
var r CredentialSlotRecord
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
if err := rows.Scan(
&r.ID, &r.PrincipalID, &r.Vendor, &r.CredentialKind, &alias, &r.Status, &r.Revision,
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
&createdAt, &updatedAt, &revokedAt,
); err != nil {
return nil, err
}
if alias.Valid {
r.Alias = alias.String
}
r.CreatedAt, _ = parseTime(createdAt)
r.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
r.RevokedAt = &parsed
}
out = append(out, r)
}
return out, rows.Err()
}
// RotateSlotSecret replaces the secret envelope on a slot using CAS on revision.
func (s *Store) RotateSlotSecret(ctx context.Context, in RotateSlotSecretInput) (*CredentialSlotRecord, error) {
if strings.TrimSpace(in.PrincipalID) == "" || strings.TrimSpace(in.SlotID) == "" {
return nil, fmt.Errorf("credentialstore: RotateSlotSecret: principal_id and slot_id are required")
}
if err := s.validateEnvelopeKey(ctx, in.Envelope); err != nil {
return nil, err
}
var updated CredentialSlotRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
var currentStatus string
var currentRev int64
err := tx.QueryRowContext(ctx, s.bind(`
SELECT status, revision FROM credential_slots WHERE principal_id=? AND id=?
`), in.PrincipalID, in.SlotID).Scan(&currentStatus, &currentRev)
if err == sql.ErrNoRows {
return ErrSlotNotFound
}
if err != nil {
return err
}
if currentStatus == StatusRevoked {
return ErrSlotRevoked
}
if currentRev != in.CurrentRevision {
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, currentRev, in.CurrentRevision)
}
now := time.Now().UTC()
nextRev := currentRev + 1
res, err := tx.ExecContext(ctx, s.bind(`
UPDATE credential_slots
SET revision=?, algorithm=?, key_id=?, key_version=?, nonce=?, ciphertext=?, aad=?, updated_at=?
WHERE principal_id=? AND id=? AND revision=?
`), nextRev, in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now,
in.PrincipalID, in.SlotID, in.CurrentRevision,
)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("%w: failed to update slot revision", ErrRevisionMismatch)
}
revisionID := uuid.New().String()
if _, err := tx.ExecContext(ctx, s.bind(`
INSERT INTO credential_slot_revisions (id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), revisionID, in.SlotID, nextRev,
in.Envelope.Algorithm, in.Envelope.KeyID, in.Envelope.KeyVersion,
in.Envelope.Nonce, in.Envelope.Ciphertext, in.Envelope.AAD, now,
); err != nil {
return fmt.Errorf("credentialstore: insert slot revision: %w", err)
}
if err := s.loadSlotTx(ctx, tx, in.PrincipalID, in.SlotID, &updated); err != nil {
return err
}
return s.bumpProjectionGenerationTx(ctx, tx)
})
if err != nil {
return nil, err
}
return &updated, nil
}
// DisableSlot disables an active credential slot using CAS on revision.
func (s *Store) DisableSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) {
return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusActive}, StatusDisabled)
}
// EnableSlot enables a disabled credential slot using CAS on revision. The
// stored envelope key and at least one non-revoked compatible binding must
// still be valid before the slot can become active again.
func (s *Store) EnableSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) {
return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusDisabled}, StatusActive)
}
// RevokeSlot revokes an active or disabled credential slot. Revocation is permanent.
func (s *Store) RevokeSlot(ctx context.Context, principalID, slotID string, currentRevision int64) (*CredentialSlotRecord, error) {
return s.casSlotStatus(ctx, principalID, slotID, currentRevision, []string{StatusDraft, StatusActive, StatusDisabled}, StatusRevoked)
}
func (s *Store) casSlotStatus(ctx context.Context, principalID, slotID string, currentRevision int64, fromStatuses []string, toStatus string) (*CredentialSlotRecord, error) {
var updated CredentialSlotRecord
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
if toStatus == StatusActive {
var slot CredentialSlotRecord
if err := s.loadSlotTx(ctx, tx, principalID, slotID, &slot); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrSlotNotFound
}
return err
}
if err := s.validateEnvelopeKey(ctx, slot.Envelope); err != nil {
return err
}
ok, err := s.slotHasCompatibleBindingTx(ctx, tx, slot)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%w: slot has no compatible binding", ErrSlotNotActive)
}
}
now := time.Now().UTC()
placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",")
query := `UPDATE credential_slots SET status=?, revision=revision+1, updated_at=?, revoked_at=?
WHERE principal_id=? AND id=? AND revision=? AND status IN (` + placeholders + `)`
var revokedArg interface{}
if toStatus == StatusRevoked {
revokedArg = now
}
args := []any{toStatus, now, revokedArg, principalID, slotID, currentRevision}
for _, st := range fromStatuses {
args = append(args, st)
}
res, err := tx.ExecContext(ctx, s.bind(query), args...)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return s.classifySlotTransitionMiss(ctx, tx, principalID, slotID, currentRevision, fromStatuses)
}
if err := s.loadSlotTx(ctx, tx, principalID, slotID, &updated); err != nil {
return err
}
return s.bumpProjectionGenerationTx(ctx, tx)
})
if err != nil {
return nil, err
}
return &updated, nil
}
func (s *Store) classifySlotTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, slotID string, currentRevision int64, fromStatuses []string) error {
var status string
var revision int64
err := tx.QueryRowContext(ctx, s.bind(`
SELECT status, revision FROM credential_slots WHERE principal_id=? AND id=?
`), principalID, slotID).Scan(&status, &revision)
if err == sql.ErrNoRows {
return ErrSlotNotFound
}
if err != nil {
return err
}
if revision != currentRevision {
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision)
}
if status == StatusRevoked {
return ErrSlotRevoked
}
return fmt.Errorf("%w: have %s want one of %s", ErrSlotNotActive, status, strings.Join(fromStatuses, ","))
}
func (s *Store) loadSlotTx(ctx context.Context, tx *sql.Tx, principalID, slotID string, target *CredentialSlotRecord) error {
var alias sql.NullString
var createdAt, updatedAt string
var revokedAt sql.NullString
err := tx.QueryRowContext(ctx, s.bind(`
SELECT id, principal_id, vendor, credential_kind, alias, status, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at, updated_at, revoked_at
FROM credential_slots
WHERE principal_id=? AND id=?
`), principalID, slotID).Scan(
&target.ID, &target.PrincipalID, &target.Vendor, &target.CredentialKind, &alias, &target.Status, &target.Revision,
&target.Envelope.Algorithm, &target.Envelope.KeyID, &target.Envelope.KeyVersion,
&target.Envelope.Nonce, &target.Envelope.Ciphertext, &target.Envelope.AAD,
&createdAt, &updatedAt, &revokedAt,
)
if err != nil {
return err
}
if alias.Valid {
target.Alias = alias.String
}
target.CreatedAt, _ = parseTime(createdAt)
target.UpdatedAt, _ = parseTime(updatedAt)
if revokedAt.Valid {
parsed, _ := parseTime(revokedAt.String)
target.RevokedAt = &parsed
}
return nil
}
// ListSlotRevisions returns all historical secret envelope revisions for a slot.
func (s *Store) ListSlotRevisions(ctx context.Context, principalID, slotID string) ([]SlotRevisionRecord, error) {
// First verify slot ownership
if _, err := s.GetSlot(ctx, principalID, slotID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, s.bind(`
SELECT id, slot_id, revision, algorithm, key_id, key_version, nonce, ciphertext, aad, created_at
FROM credential_slot_revisions
WHERE slot_id=?
ORDER BY revision ASC
`), slotID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SlotRevisionRecord
for rows.Next() {
var r SlotRevisionRecord
var createdAt string
if err := rows.Scan(
&r.ID, &r.SlotID, &r.Revision,
&r.Envelope.Algorithm, &r.Envelope.KeyID, &r.Envelope.KeyVersion,
&r.Envelope.Nonce, &r.Envelope.Ciphertext, &r.Envelope.AAD,
&createdAt,
); err != nil {
return nil, err
}
r.CreatedAt, _ = parseTime(createdAt)
out = append(out, r)
}
return out, rows.Err()
}