General API와 Coding Plan이 서로 다른 엔드포인트와 사용량 경계를 유지하도록 프로필, credential route, provider mapping, full-cycle 검증을 함께 반영한다.
664 lines
23 KiB
Go
664 lines
23 KiB
Go
package credentialstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
var (
|
|
// ErrRouteNotFound is returned when a requested route does not exist.
|
|
ErrRouteNotFound = errors.New("credentialstore: route not found")
|
|
|
|
// ErrIncompatibleProfile is returned when the protocol profile is incompatible with a slot's vendor and credential kind.
|
|
ErrIncompatibleProfile = errors.New("credentialstore: profile incompatible with slot credential")
|
|
|
|
// ErrCrossPrincipalSlot is returned when attempting to bind a route to a slot belonging to a different principal.
|
|
ErrCrossPrincipalSlot = errors.New("credentialstore: slot belongs to a different principal")
|
|
|
|
// ErrRouteAliasAlreadyExists is returned when a route alias is already in use for a principal.
|
|
ErrRouteAliasAlreadyExists = errors.New("credentialstore: route alias already exists for principal")
|
|
|
|
// ErrRouteRevoked is returned when attempting to modify a revoked route.
|
|
ErrRouteRevoked = errors.New("credentialstore: revoked route cannot be modified")
|
|
|
|
// ErrRouteNotActive is returned when an operation requires an active route.
|
|
ErrRouteNotActive = errors.New("credentialstore: route is not active")
|
|
)
|
|
|
|
// CreateRouteInput holds parameters for binding a route to a credential slot.
|
|
type CreateRouteInput struct {
|
|
PrincipalID string
|
|
SlotID string
|
|
Alias string
|
|
ProfileID string
|
|
UpstreamModel string
|
|
ResourceSelector string
|
|
}
|
|
|
|
// UpdateRouteInput replaces the mutable target fields of a route under a
|
|
// revision compare-and-swap. The route ID and principal ownership are stable.
|
|
type UpdateRouteInput struct {
|
|
PrincipalID string
|
|
RouteID string
|
|
CurrentRevision int64
|
|
SlotID string
|
|
Alias string
|
|
ProfileID string
|
|
UpstreamModel string
|
|
ResourceSelector string
|
|
}
|
|
|
|
// RouteRecord represents a persisted route binding row.
|
|
type RouteRecord struct {
|
|
ID string
|
|
PrincipalID string
|
|
SlotID string
|
|
Alias string
|
|
ProfileID string
|
|
UpstreamModel string
|
|
ResourceSelector string
|
|
Status string
|
|
Revision int64
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
RevokedAt *time.Time
|
|
}
|
|
|
|
type profileAuthRule struct {
|
|
header string
|
|
scheme string
|
|
}
|
|
|
|
type credentialProfileRule map[string]profileAuthRule
|
|
|
|
// credentialProfileRules is deliberately closed. A credential slot can only
|
|
// bind to a catalog profile when its vendor, credential kind, profile ID, and
|
|
// profile authentication declaration all agree. New provider support must add
|
|
// an explicit entry here instead of inheriting a driver-level default.
|
|
var credentialProfileRules = map[string]credentialProfileRule{
|
|
"openai/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
|
|
"gemini/bearer": {"gemini": {header: "Authorization", scheme: "Bearer"}},
|
|
"vllm/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
|
|
"sglang/bearer": {"openai": {header: "Authorization", scheme: "Bearer"}},
|
|
"glm/bearer": {
|
|
"glm": {header: "Authorization", scheme: "Bearer"},
|
|
"glm_coding": {header: "Authorization", scheme: "Bearer"},
|
|
},
|
|
"kimi/bearer": {"kimi": {header: "Authorization", scheme: "Bearer"}},
|
|
"grok/bearer": {"grok": {header: "Authorization", scheme: "Bearer"}},
|
|
"anthropic/api_key": {"anthropic": {header: "x-api-key"}},
|
|
"minimax/bearer": {"minimax_chat": {header: "Authorization", scheme: "Bearer"}, "minimax_messages": {header: "Authorization", scheme: "Bearer"}},
|
|
"mimo/bearer": {"mimo_chat": {header: "Authorization", scheme: "Bearer"}},
|
|
"mimo/api_key": {"mimo_messages": {header: "api-key"}},
|
|
"seulgi/bearer": {"seulgi_chat": {header: "Authorization", scheme: "Bearer"}},
|
|
"seulgi/api_key": {"seulgi_messages": {header: "x-api-key"}},
|
|
"seulgivibe_openai/bearer": {"seulgi_chat": {header: "Authorization", scheme: "Bearer"}},
|
|
"seulgivibe_claude/api_key": {"seulgi_messages": {header: "x-api-key"}},
|
|
}
|
|
|
|
// credentialProtocolProfiles is a process-owned catalog for credential
|
|
// routing. The exported config snapshot is intentionally mutable for legacy
|
|
// callers, so credential compatibility must not depend on it after startup.
|
|
var credentialProtocolProfiles = config.BuiltInProtocolProfileCatalog()
|
|
|
|
func compatibilityKey(vendor, credentialKind string) string {
|
|
return normalizeCredentialPart(vendor) + "/" + normalizeCredentialPart(credentialKind)
|
|
}
|
|
|
|
func checkSlotProfileCompatibility(slot CredentialSlotRecord, profile config.ConcreteProtocolProfile) error {
|
|
rule, ok := credentialProfileRules[compatibilityKey(slot.Vendor, slot.CredentialKind)]
|
|
if !ok {
|
|
return fmt.Errorf("%w: unknown vendor/credential kind %s/%s", ErrIncompatibleProfile, slot.Vendor, slot.CredentialKind)
|
|
}
|
|
auth, ok := rule[profile.ID]
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(profile.Auth.Header), auth.header) || !strings.EqualFold(strings.TrimSpace(profile.Auth.Scheme), auth.scheme) {
|
|
return fmt.Errorf("%w: %s/%s cannot use profile %s", ErrIncompatibleProfile, slot.Vendor, slot.CredentialKind, profile.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateRoute creates a route binding for a principal and slot.
|
|
func (s *Store) CreateRoute(ctx context.Context, in CreateRouteInput) (*RouteRecord, error) {
|
|
if strings.TrimSpace(in.PrincipalID) == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateRoute: principal_id is required")
|
|
}
|
|
if strings.TrimSpace(in.SlotID) == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateRoute: slot_id is required")
|
|
}
|
|
if strings.TrimSpace(in.UpstreamModel) == "" {
|
|
return nil, fmt.Errorf("credentialstore: CreateRoute: upstream_model is required")
|
|
}
|
|
|
|
alias := normalizeAlias(in.Alias)
|
|
resSelector := normalizeResourceSelector(in.ResourceSelector)
|
|
upstreamModel := strings.TrimSpace(in.UpstreamModel)
|
|
routeID := uuid.New().String()
|
|
now := time.Now().UTC()
|
|
|
|
var record RouteRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
slot, err := s.routeSlotTx(ctx, tx, in.PrincipalID, in.SlotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if slot.Status == StatusRevoked {
|
|
return ErrSlotRevoked
|
|
}
|
|
if slot.Status == StatusDisabled {
|
|
return ErrSlotNotActive
|
|
}
|
|
profile, err := resolveSlotProfile(slot, in.ProfileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureRouteIDAvailableTx(ctx, tx, in.PrincipalID, routeID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureRouteAliasAvailableTx(ctx, tx, in.PrincipalID, routeID, alias, ""); err != nil {
|
|
return err
|
|
}
|
|
var aliasArg any
|
|
if alias != "" {
|
|
aliasArg = alias
|
|
}
|
|
if _, err := tx.ExecContext(ctx, s.bind(`
|
|
INSERT INTO routes (id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), routeID, in.PrincipalID, slot.ID, aliasArg, profile.ID, upstreamModel, resSelector, StatusActive, 0, now, now); err != nil {
|
|
return fmt.Errorf("credentialstore: insert route: %w", err)
|
|
}
|
|
if slot.Status == StatusDraft {
|
|
if err := s.activateDraftSlotTx(ctx, tx, slot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.bumpProjectionGenerationTx(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
record = RouteRecord{ID: routeID, PrincipalID: in.PrincipalID, SlotID: slot.ID, Alias: alias, ProfileID: profile.ID, UpstreamModel: upstreamModel, ResourceSelector: resSelector, Status: StatusActive, Revision: 0, CreatedAt: now, UpdatedAt: now}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
func normalizeResourceSelector(value string) string {
|
|
if normalized := strings.TrimSpace(value); normalized != "" {
|
|
return normalized
|
|
}
|
|
return "default"
|
|
}
|
|
|
|
func resolveSlotProfile(slot CredentialSlotRecord, profileID string) (config.ConcreteProtocolProfile, error) {
|
|
profile, err := config.ResolveProtocolProfile(profileID, slot.Vendor, credentialProtocolProfiles)
|
|
if err != nil {
|
|
return config.ConcreteProtocolProfile{}, fmt.Errorf("%w: %v", ErrIncompatibleProfile, err)
|
|
}
|
|
if err := checkSlotProfileCompatibility(slot, profile); err != nil {
|
|
return config.ConcreteProtocolProfile{}, err
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
func (s *Store) routeSlotTx(ctx context.Context, tx *sql.Tx, principalID, slotID string) (CredentialSlotRecord, error) {
|
|
var slot CredentialSlotRecord
|
|
if err := s.loadSlotTx(ctx, tx, principalID, slotID, &slot); err == nil {
|
|
return slot, nil
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
return CredentialSlotRecord{}, err
|
|
}
|
|
var actualPrincipalID string
|
|
err := tx.QueryRowContext(ctx, s.bind(`SELECT principal_id FROM credential_slots WHERE id=?`), slotID).Scan(&actualPrincipalID)
|
|
if err == nil && actualPrincipalID != principalID {
|
|
return CredentialSlotRecord{}, ErrCrossPrincipalSlot
|
|
}
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return CredentialSlotRecord{}, err
|
|
}
|
|
return CredentialSlotRecord{}, ErrSlotNotFound
|
|
}
|
|
|
|
func (s *Store) ensureRouteAliasAvailableTx(ctx context.Context, tx *sql.Tx, principalID, routeID, alias, excludeRouteID string) error {
|
|
if alias == "" {
|
|
return nil
|
|
}
|
|
query := `SELECT COUNT(*) FROM routes WHERE principal_id=? AND (alias=? OR id=?)`
|
|
args := []any{principalID, alias, alias}
|
|
if excludeRouteID != "" {
|
|
query += ` AND id<>?`
|
|
args = append(args, excludeRouteID)
|
|
}
|
|
var count int
|
|
if err := tx.QueryRowContext(ctx, s.bind(query), args...).Scan(&count); err != nil {
|
|
return fmt.Errorf("credentialstore: check route alias: %w", err)
|
|
}
|
|
if count > 0 || alias == routeID {
|
|
return ErrRouteAliasAlreadyExists
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ensureRouteIDAvailableTx(ctx context.Context, tx *sql.Tx, principalID, routeID string) error {
|
|
var count int
|
|
if err := tx.QueryRowContext(ctx, s.bind(`SELECT COUNT(*) FROM routes WHERE principal_id=? AND (id=? OR alias=?)`), principalID, routeID, routeID).Scan(&count); err != nil {
|
|
return fmt.Errorf("credentialstore: check route id: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return ErrRouteAliasAlreadyExists
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) activateDraftSlotTx(ctx context.Context, tx *sql.Tx, slot CredentialSlotRecord) error {
|
|
if err := s.validateEnvelopeKey(ctx, slot.Envelope); err != nil {
|
|
return err
|
|
}
|
|
res, err := tx.ExecContext(ctx, s.bind(`UPDATE credential_slots SET status=?, revision=revision+1, updated_at=? WHERE id=? AND revision=? AND status=?`), StatusActive, time.Now().UTC(), slot.ID, slot.Revision, StatusDraft)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != 1 {
|
|
return fmt.Errorf("%w: draft slot activation lost", ErrRevisionMismatch)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetRoute retrieves a route binding by principal ID and route ID.
|
|
func (s *Store) GetRoute(ctx context.Context, principalID, routeID string) (*RouteRecord, error) {
|
|
var r RouteRecord
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := s.db.QueryRowContext(ctx, s.bind(`
|
|
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
|
|
FROM routes
|
|
WHERE principal_id=? AND id=?
|
|
`), principalID, routeID).Scan(
|
|
&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
|
|
&createdAt, &updatedAt, &revokedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrRouteNotFound
|
|
}
|
|
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
|
|
}
|
|
|
|
// GetRouteByAlias retrieves a route binding by principal ID and alias.
|
|
func (s *Store) GetRouteByAlias(ctx context.Context, principalID, alias string) (*RouteRecord, error) {
|
|
var r RouteRecord
|
|
var aliasVal sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := s.db.QueryRowContext(ctx, s.bind(`
|
|
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
|
|
FROM routes
|
|
WHERE principal_id=? AND alias=?
|
|
`), principalID, alias).Scan(
|
|
&r.ID, &r.PrincipalID, &r.SlotID, &aliasVal, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
|
|
&createdAt, &updatedAt, &revokedAt,
|
|
)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrRouteNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if aliasVal.Valid {
|
|
r.Alias = aliasVal.String
|
|
}
|
|
r.CreatedAt, _ = parseTime(createdAt)
|
|
r.UpdatedAt, _ = parseTime(updatedAt)
|
|
if revokedAt.Valid {
|
|
parsed, _ := parseTime(revokedAt.String)
|
|
r.RevokedAt = &parsed
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// ListRoutes returns all route bindings for a principal.
|
|
func (s *Store) ListRoutes(ctx context.Context, principalID string) ([]RouteRecord, error) {
|
|
rows, err := s.db.QueryContext(ctx, s.bind(`
|
|
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
|
|
FROM routes
|
|
WHERE principal_id=?
|
|
ORDER BY created_at ASC
|
|
`), principalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []RouteRecord
|
|
for rows.Next() {
|
|
var r RouteRecord
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
if err := rows.Scan(
|
|
&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
|
|
&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()
|
|
}
|
|
|
|
// ListRoutesBySlot returns all route bindings attached to a specific slot.
|
|
func (s *Store) ListRoutesBySlot(ctx context.Context, principalID, slotID string) ([]RouteRecord, error) {
|
|
rows, err := s.db.QueryContext(ctx, s.bind(`
|
|
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
|
|
FROM routes
|
|
WHERE principal_id=? AND slot_id=?
|
|
ORDER BY created_at ASC
|
|
`), principalID, slotID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []RouteRecord
|
|
for rows.Next() {
|
|
var r RouteRecord
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
if err := rows.Scan(
|
|
&r.ID, &r.PrincipalID, &r.SlotID, &alias, &r.ProfileID, &r.UpstreamModel, &r.ResourceSelector, &r.Status, &r.Revision,
|
|
&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()
|
|
}
|
|
|
|
// UpdateRoute atomically replaces a route's slot/profile/model/selector/alias
|
|
// when the caller presents the current route revision.
|
|
func (s *Store) UpdateRoute(ctx context.Context, in UpdateRouteInput) (*RouteRecord, error) {
|
|
if strings.TrimSpace(in.PrincipalID) == "" || strings.TrimSpace(in.RouteID) == "" || strings.TrimSpace(in.SlotID) == "" {
|
|
return nil, fmt.Errorf("credentialstore: UpdateRoute: principal_id, route_id, and slot_id are required")
|
|
}
|
|
if strings.TrimSpace(in.UpstreamModel) == "" {
|
|
return nil, fmt.Errorf("credentialstore: UpdateRoute: upstream_model is required")
|
|
}
|
|
alias := normalizeAlias(in.Alias)
|
|
resSelector := normalizeResourceSelector(in.ResourceSelector)
|
|
upstreamModel := strings.TrimSpace(in.UpstreamModel)
|
|
|
|
var updated RouteRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
var route RouteRecord
|
|
if err := s.loadRouteTx(ctx, tx, in.PrincipalID, in.RouteID, &route); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrRouteNotFound
|
|
}
|
|
return err
|
|
}
|
|
if route.Status == StatusRevoked {
|
|
return ErrRouteRevoked
|
|
}
|
|
if route.Revision != in.CurrentRevision {
|
|
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, route.Revision, in.CurrentRevision)
|
|
}
|
|
slot, err := s.routeSlotTx(ctx, tx, in.PrincipalID, in.SlotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if slot.Status != StatusActive {
|
|
return ErrSlotNotActive
|
|
}
|
|
profile, err := resolveSlotProfile(slot, in.ProfileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureRouteAliasAvailableTx(ctx, tx, in.PrincipalID, in.RouteID, alias, in.RouteID); err != nil {
|
|
return err
|
|
}
|
|
var aliasArg any
|
|
if alias != "" {
|
|
aliasArg = alias
|
|
}
|
|
now := time.Now().UTC()
|
|
res, err := tx.ExecContext(ctx, s.bind(`
|
|
UPDATE routes
|
|
SET slot_id=?, alias=?, profile_id=?, upstream_model=?, resource_selector=?, revision=revision+1, updated_at=?
|
|
WHERE principal_id=? AND id=? AND revision=? AND status<>?
|
|
`), slot.ID, aliasArg, profile.ID, upstreamModel, resSelector, now, in.PrincipalID, in.RouteID, in.CurrentRevision, StatusRevoked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != 1 {
|
|
return fmt.Errorf("%w: failed to update route", ErrRevisionMismatch)
|
|
}
|
|
if err := s.loadRouteTx(ctx, tx, in.PrincipalID, in.RouteID, &updated); err != nil {
|
|
return err
|
|
}
|
|
return s.bumpProjectionGenerationTx(ctx, tx)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
func (s *Store) slotHasCompatibleBindingTx(ctx context.Context, tx *sql.Tx, slot CredentialSlotRecord) (bool, error) {
|
|
rows, err := tx.QueryContext(ctx, s.bind(`SELECT profile_id FROM routes WHERE principal_id=? AND slot_id=? AND status<>?`), slot.PrincipalID, slot.ID, StatusRevoked)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var profileID string
|
|
if err := rows.Scan(&profileID); err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := resolveSlotProfile(slot, profileID); err == nil {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, rows.Err()
|
|
}
|
|
|
|
// DisableRoute disables an active route binding using CAS on revision.
|
|
func (s *Store) DisableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
|
|
return s.casRouteStatus(ctx, principalID, routeID, currentRevision, []string{StatusActive}, StatusDisabled)
|
|
}
|
|
|
|
// EnableRoute enables a disabled route binding using CAS on revision.
|
|
func (s *Store) EnableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
|
|
return s.enableRoute(ctx, principalID, routeID, currentRevision)
|
|
}
|
|
|
|
func (s *Store) enableRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
|
|
var updated RouteRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
var route RouteRecord
|
|
if err := s.loadRouteTx(ctx, tx, principalID, routeID, &route); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrRouteNotFound
|
|
}
|
|
return err
|
|
}
|
|
if route.Revision != currentRevision {
|
|
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, route.Revision, currentRevision)
|
|
}
|
|
if route.Status == StatusRevoked {
|
|
return ErrRouteRevoked
|
|
}
|
|
if route.Status != StatusDisabled {
|
|
return ErrRouteNotActive
|
|
}
|
|
slot, err := s.routeSlotTx(ctx, tx, principalID, route.SlotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if slot.Status != StatusActive {
|
|
return ErrSlotNotActive
|
|
}
|
|
if _, err := resolveSlotProfile(slot, route.ProfileID); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
res, err := tx.ExecContext(ctx, s.bind(`UPDATE routes SET status=?, revision=revision+1, updated_at=?, revoked_at=NULL WHERE principal_id=? AND id=? AND revision=? AND status=?`), StatusActive, now, principalID, routeID, currentRevision, StatusDisabled)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected != 1 {
|
|
return fmt.Errorf("%w: failed to enable route", ErrRevisionMismatch)
|
|
}
|
|
if err := s.loadRouteTx(ctx, tx, principalID, routeID, &updated); err != nil {
|
|
return err
|
|
}
|
|
return s.bumpProjectionGenerationTx(ctx, tx)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
// RevokeRoute revokes a route binding permanently using CAS on revision.
|
|
func (s *Store) RevokeRoute(ctx context.Context, principalID, routeID string, currentRevision int64) (*RouteRecord, error) {
|
|
return s.casRouteStatus(ctx, principalID, routeID, currentRevision, []string{StatusActive, StatusDisabled}, StatusRevoked)
|
|
}
|
|
|
|
func (s *Store) casRouteStatus(ctx context.Context, principalID, routeID string, currentRevision int64, fromStatuses []string, toStatus string) (*RouteRecord, error) {
|
|
var updated RouteRecord
|
|
err := s.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
now := time.Now().UTC()
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(fromStatuses)), ",")
|
|
query := `UPDATE routes 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, routeID, 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.classifyRouteTransitionMiss(ctx, tx, principalID, routeID, currentRevision, fromStatuses)
|
|
}
|
|
if err := s.loadRouteTx(ctx, tx, principalID, routeID, &updated); err != nil {
|
|
return err
|
|
}
|
|
return s.bumpProjectionGenerationTx(ctx, tx)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &updated, nil
|
|
}
|
|
|
|
func (s *Store) classifyRouteTransitionMiss(ctx context.Context, tx *sql.Tx, principalID, routeID string, currentRevision int64, fromStatuses []string) error {
|
|
var status string
|
|
var revision int64
|
|
err := tx.QueryRowContext(ctx, s.bind(`
|
|
SELECT status, revision FROM routes WHERE principal_id=? AND id=?
|
|
`), principalID, routeID).Scan(&status, &revision)
|
|
if err == sql.ErrNoRows {
|
|
return ErrRouteNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if revision != currentRevision {
|
|
return fmt.Errorf("%w: have %d want %d", ErrRevisionMismatch, revision, currentRevision)
|
|
}
|
|
if status == StatusRevoked {
|
|
return ErrRouteRevoked
|
|
}
|
|
return fmt.Errorf("%w: have %s want one of %s", ErrRouteNotActive, status, strings.Join(fromStatuses, ","))
|
|
}
|
|
|
|
func (s *Store) loadRouteTx(ctx context.Context, tx *sql.Tx, principalID, routeID string, target *RouteRecord) error {
|
|
var alias sql.NullString
|
|
var createdAt, updatedAt string
|
|
var revokedAt sql.NullString
|
|
err := tx.QueryRowContext(ctx, s.bind(`
|
|
SELECT id, principal_id, slot_id, alias, profile_id, upstream_model, resource_selector, status, revision, created_at, updated_at, revoked_at
|
|
FROM routes
|
|
WHERE principal_id=? AND id=?
|
|
`), principalID, routeID).Scan(
|
|
&target.ID, &target.PrincipalID, &target.SlotID, &alias, &target.ProfileID, &target.UpstreamModel, &target.ResourceSelector, &target.Status, &target.Revision,
|
|
&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
|
|
}
|