사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
643 lines
28 KiB
Go
643 lines
28 KiB
Go
package credentialstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type fakeKeyRegistry struct {
|
|
keys map[string]map[uint64]bool
|
|
}
|
|
|
|
func newFakeKeyRegistry() *fakeKeyRegistry {
|
|
return &fakeKeyRegistry{
|
|
keys: make(map[string]map[uint64]bool),
|
|
}
|
|
}
|
|
|
|
func (f *fakeKeyRegistry) RegisterKey(keyID string, version uint64) {
|
|
if f.keys[keyID] == nil {
|
|
f.keys[keyID] = make(map[uint64]bool)
|
|
}
|
|
f.keys[keyID][version] = true
|
|
}
|
|
|
|
func (f *fakeKeyRegistry) RemoveKey(keyID string, version uint64) {
|
|
if versions := f.keys[keyID]; versions != nil {
|
|
delete(versions, version)
|
|
}
|
|
}
|
|
|
|
func (f *fakeKeyRegistry) HasEnvelopeKey(_ context.Context, keyID string, keyVersion uint64) (bool, error) {
|
|
if versions, ok := f.keys[keyID]; ok {
|
|
return versions[keyVersion], nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func setupTestStoreWithRegistry(t *testing.T, registry EnvelopeKeyRegistry) (*Store, *IssuedPrincipal) {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "test_slots.db")
|
|
ctx := context.Background()
|
|
|
|
opts := []Option{}
|
|
if registry != nil {
|
|
opts = append(opts, WithEnvelopeKeyRegistry(registry))
|
|
}
|
|
|
|
store, err := Open(ctx, dbPath, opts...)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
p, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "test-principal"})
|
|
require.NoError(t, err)
|
|
|
|
return store, p
|
|
}
|
|
|
|
func TestCredentialSlotsAllowSameVendorMultipleSlots(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
|
|
env1 := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher1"),
|
|
}
|
|
|
|
slot1, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "slot-1",
|
|
Envelope: env1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "openai", slot1.Vendor)
|
|
require.Equal(t, "slot-1", slot1.Alias)
|
|
|
|
env2 := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher2"),
|
|
}
|
|
|
|
slot2, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "slot-2",
|
|
Envelope: env2,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "openai", slot2.Vendor)
|
|
require.Equal(t, "slot-2", slot2.Alias)
|
|
|
|
slots, err := store.ListSlots(ctx, p.Principal.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, slots, 2)
|
|
}
|
|
|
|
func TestCredentialSlotAcceptsCallerSuppliedSlotID(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
env := SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")}
|
|
|
|
callerID := uuid.New().String()
|
|
created, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
SlotID: callerID, PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "caller-id", Envelope: env,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, callerID, created.ID)
|
|
|
|
// The persisted row and its initial revision both use the caller id.
|
|
loaded, err := store.GetSlot(ctx, p.Principal.ID, callerID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, callerID, loaded.ID)
|
|
revs, err := store.ListSlotRevisions(ctx, p.Principal.ID, callerID)
|
|
require.NoError(t, err)
|
|
require.Len(t, revs, 1)
|
|
require.Equal(t, callerID, revs[0].SlotID)
|
|
|
|
// A non-UUID caller id is rejected before any row is written.
|
|
_, err = store.CreateSlot(ctx, CreateSlotInput{
|
|
SlotID: "not-a-uuid", PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "bad-id", Envelope: env,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "slot_id")
|
|
|
|
// The legacy zero value still yields a store-generated id.
|
|
legacy, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "legacy-id", Envelope: env,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, legacy.ID)
|
|
require.NotEqual(t, callerID, legacy.ID)
|
|
}
|
|
|
|
func TestCredentialSlotRejectsUnknownEnvelopeKey(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
// Do not register "unknown-key"
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
|
|
env := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "unknown-key",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher"),
|
|
}
|
|
|
|
_, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "slot-unknown",
|
|
Envelope: env,
|
|
})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrUnknownEnvelopeKey)
|
|
|
|
// Test unconfigured store (unavailable key registry)
|
|
unconfiguredStore, p2 := setupTestStoreWithRegistry(t, nil)
|
|
envRegistered := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher"),
|
|
}
|
|
_, err = unconfiguredStore.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p2.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "slot-unavail",
|
|
Envelope: envRegistered,
|
|
})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrEnvelopeKeyUnavailable)
|
|
}
|
|
|
|
func TestCredentialSlotRotationRequiresRegisteredKey(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
|
|
env1 := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher1"),
|
|
}
|
|
|
|
slot, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "rotate-slot",
|
|
Envelope: env1,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Attempt rotation with unregistered key "k2"
|
|
env2 := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k2",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher2"),
|
|
}
|
|
|
|
_, err = store.RotateSlotSecret(ctx, RotateSlotSecretInput{
|
|
PrincipalID: p.Principal.ID,
|
|
SlotID: slot.ID,
|
|
CurrentRevision: slot.Revision,
|
|
Envelope: env2,
|
|
})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrUnknownEnvelopeKey)
|
|
|
|
// Register k2 and rotate successfully
|
|
reg.RegisterKey("k2", 1)
|
|
rotated, err := store.RotateSlotSecret(ctx, RotateSlotSecretInput{
|
|
PrincipalID: p.Principal.ID,
|
|
SlotID: slot.ID,
|
|
CurrentRevision: slot.Revision,
|
|
Envelope: env2,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), rotated.Revision)
|
|
require.Equal(t, "k2", rotated.Envelope.KeyID)
|
|
}
|
|
|
|
func TestCredentialSlotAliasUniqueWithinPrincipal(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
|
|
env := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher1"),
|
|
}
|
|
|
|
_, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "dup-alias",
|
|
Envelope: env,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "anthropic",
|
|
CredentialKind: CredentialKindAPIKey,
|
|
Alias: "dup-alias",
|
|
Envelope: env,
|
|
})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrSlotAliasAlreadyExists)
|
|
}
|
|
|
|
func TestCredentialSlotLifecyclePersists(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "lifecycle.db")
|
|
ctx := context.Background()
|
|
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
reg.RegisterKey("k1", 2)
|
|
|
|
store1, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
|
|
require.NoError(t, err)
|
|
|
|
p, err := store1.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: "life-principal"})
|
|
require.NoError(t, err)
|
|
|
|
env1 := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher-v1"),
|
|
}
|
|
|
|
slot, err := store1.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: p.Principal.ID,
|
|
Vendor: "openai",
|
|
CredentialKind: CredentialKindBearer,
|
|
Alias: "life-slot",
|
|
Envelope: env1,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusDraft, slot.Status)
|
|
|
|
activatedRoute, err := store1.CreateRoute(ctx, CreateRouteInput{
|
|
PrincipalID: p.Principal.ID,
|
|
SlotID: slot.ID,
|
|
Alias: "life-route",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
require.NoError(t, err)
|
|
_ = activatedRoute
|
|
slot, err = store1.GetSlot(ctx, p.Principal.ID, slot.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusActive, slot.Status)
|
|
|
|
// Disable slot
|
|
disabled, err := store1.DisableSlot(ctx, p.Principal.ID, slot.ID, slot.Revision)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusDisabled, disabled.Status)
|
|
require.Equal(t, int64(2), disabled.Revision)
|
|
|
|
// Enable slot
|
|
enabled, err := store1.EnableSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusActive, enabled.Status)
|
|
require.Equal(t, int64(3), enabled.Revision)
|
|
|
|
// Rotate secret
|
|
env2 := SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "k1",
|
|
KeyVersion: 2,
|
|
Nonce: []byte("123456789012"),
|
|
Ciphertext: []byte("cipher-v2"),
|
|
}
|
|
rotated, err := store1.RotateSlotSecret(ctx, RotateSlotSecretInput{
|
|
PrincipalID: p.Principal.ID,
|
|
SlotID: slot.ID,
|
|
CurrentRevision: enabled.Revision,
|
|
Envelope: env2,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(4), rotated.Revision)
|
|
require.Equal(t, uint64(2), rotated.Envelope.KeyVersion)
|
|
|
|
// Close store and reopen from dbPath
|
|
require.NoError(t, store1.Close())
|
|
|
|
store2, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
|
|
require.NoError(t, err)
|
|
defer store2.Close()
|
|
|
|
reloaded, err := store2.GetSlot(ctx, p.Principal.ID, slot.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusActive, reloaded.Status)
|
|
require.Equal(t, int64(4), reloaded.Revision)
|
|
require.Equal(t, []byte("cipher-v2"), reloaded.Envelope.Ciphertext)
|
|
|
|
revisions, err := store2.ListSlotRevisions(ctx, p.Principal.ID, slot.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, revisions, 2)
|
|
require.Equal(t, int64(0), revisions[0].Revision)
|
|
require.Equal(t, int64(4), revisions[1].Revision)
|
|
|
|
// Revoke slot
|
|
revoked, err := store2.RevokeSlot(ctx, p.Principal.ID, slot.ID, reloaded.Revision)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusRevoked, revoked.Status)
|
|
require.NotNil(t, revoked.RevokedAt)
|
|
}
|
|
|
|
func TestCredentialSlotLifecycleReopensWithInactiveBindings(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
transition func(*Store, string, string, int64) (*CredentialSlotRecord, error)
|
|
}{
|
|
{
|
|
name: "disabled",
|
|
transition: func(store *Store, principalID, slotID string, revision int64) (*CredentialSlotRecord, error) {
|
|
return store.DisableSlot(ctx, principalID, slotID, revision)
|
|
},
|
|
},
|
|
{
|
|
name: "revoked",
|
|
transition: func(store *Store, principalID, slotID string, revision int64) (*CredentialSlotRecord, error) {
|
|
return store.RevokeSlot(ctx, principalID, slotID, revision)
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), tc.name+".db")
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
|
|
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
|
|
require.NoError(t, err)
|
|
principal, err := store.CreatePrincipalWithToken(ctx, CreatePrincipalInput{Alias: tc.name + "-principal"})
|
|
require.NoError(t, err)
|
|
slot, err := store.CreateSlot(ctx, CreateSlotInput{
|
|
PrincipalID: principal.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: tc.name + "-slot",
|
|
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")},
|
|
})
|
|
require.NoError(t, err)
|
|
route, err := store.CreateRoute(ctx, CreateRouteInput{
|
|
PrincipalID: principal.Principal.ID, SlotID: slot.ID, Alias: tc.name + "-route", ProfileID: "openai", UpstreamModel: "gpt-4o",
|
|
})
|
|
require.NoError(t, err)
|
|
slot, err = store.GetSlot(ctx, principal.Principal.ID, slot.ID)
|
|
require.NoError(t, err)
|
|
|
|
inactive, err := tc.transition(store, principal.Principal.ID, slot.ID, slot.Revision)
|
|
require.NoError(t, err)
|
|
require.NoError(t, store.Close())
|
|
|
|
reopened, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
|
|
require.NoError(t, err)
|
|
defer reopened.Close()
|
|
|
|
reloaded, err := reopened.GetSlot(ctx, principal.Principal.ID, slot.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, inactive.Status, reloaded.Status)
|
|
retainedRoute, err := reopened.GetRoute(ctx, principal.Principal.ID, route.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusActive, retainedRoute.Status)
|
|
|
|
if tc.name == "disabled" {
|
|
enabled, err := reopened.EnableSlot(ctx, principal.Principal.ID, slot.ID, reloaded.Revision)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusActive, enabled.Status)
|
|
} else {
|
|
_, err := reopened.EnableSlot(ctx, principal.Principal.ID, slot.ID, reloaded.Revision)
|
|
require.Error(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCredentialSlotOptionalAliasDoesNotCollide(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
env := SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")}
|
|
|
|
first, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: " OPENAI ", CredentialKind: " BEARER ", Envelope: env})
|
|
require.NoError(t, err)
|
|
require.Empty(t, first.Alias)
|
|
require.Equal(t, "openai", first.Vendor)
|
|
require.Equal(t, CredentialKindBearer, first.CredentialKind)
|
|
|
|
second, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: " ", Envelope: env})
|
|
require.NoError(t, err)
|
|
require.Empty(t, second.Alias)
|
|
|
|
aliased, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: " primary ", Envelope: env})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "primary", aliased.Alias)
|
|
byAlias, err := store.GetSlotByAlias(ctx, p.Principal.ID, " primary ")
|
|
require.NoError(t, err)
|
|
require.Equal(t, aliased.ID, byAlias.ID)
|
|
|
|
_, err = store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "primary", Envelope: env})
|
|
require.ErrorIs(t, err, ErrSlotAliasAlreadyExists)
|
|
}
|
|
|
|
func TestCredentialSlotRequiresKnownCredentialKind(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
_, err := store.CreateSlot(context.Background(), CreateSlotInput{
|
|
PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: "session_cookie", Alias: "bad-kind",
|
|
Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "credential_kind")
|
|
}
|
|
|
|
func TestCredentialSlotActivationRequiresRegisteredKey(t *testing.T) {
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, p := setupTestStoreWithRegistry(t, reg)
|
|
ctx := context.Background()
|
|
env := SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("opaque")}
|
|
slot, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: p.Principal.ID, Vendor: "openai", CredentialKind: CredentialKindBearer, Alias: "key-gated", Envelope: env})
|
|
require.NoError(t, err)
|
|
_, err = store.CreateRoute(ctx, CreateRouteInput{PrincipalID: p.Principal.ID, SlotID: slot.ID, ProfileID: "openai", UpstreamModel: "gpt-4o"})
|
|
require.NoError(t, err)
|
|
slot, err = store.GetSlot(ctx, p.Principal.ID, slot.ID)
|
|
require.NoError(t, err)
|
|
disabled, err := store.DisableSlot(ctx, p.Principal.ID, slot.ID, slot.Revision)
|
|
require.NoError(t, err)
|
|
|
|
reg.RemoveKey("k1", 1)
|
|
_, err = store.EnableSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
|
|
require.ErrorIs(t, err, ErrUnknownEnvelopeKey)
|
|
reg.RegisterKey("k1", 1)
|
|
enabled, err := store.EnableSlot(ctx, p.Principal.ID, slot.ID, disabled.Revision)
|
|
require.NoError(t, err)
|
|
require.Equal(t, StatusActive, enabled.Status)
|
|
}
|
|
|
|
func TestCredentialSlotMigrationPreservesIntermediateCatalog(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := filepath.Join(t.TempDir(), "intermediate.db")
|
|
db, err := sql.Open(dialectSQLite, dbPath)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`
|
|
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE TABLE credential_slots (
|
|
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
|
|
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
|
|
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
|
|
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
|
|
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
|
|
INSERT INTO credential_slots VALUES ('slot-1', 'principal-1', 'openai', 'legacy-slot', 'active', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
|
|
INSERT INTO credential_slots VALUES ('slot-2', 'principal-1', 'anthropic', 'legacy-anthropic', 'active', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
|
|
INSERT INTO credential_slot_revisions VALUES ('revision-1', 'slot-1', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
|
|
INSERT INTO credential_slot_revisions VALUES ('revision-2', 'slot-2', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
|
|
INSERT INTO routes VALUES ('route-1', 'principal-1', 'slot-1', 'legacy-openai', 'openai', 'gpt-4o', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
|
|
INSERT INTO routes VALUES ('route-2', 'principal-1', 'slot-2', 'legacy-anthropic-route', 'anthropic', 'claude-3-5-sonnet', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Close())
|
|
|
|
reg := newFakeKeyRegistry()
|
|
reg.RegisterKey("k1", 1)
|
|
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
|
|
require.NoError(t, err)
|
|
defer store.Close()
|
|
legacy, err := store.GetSlot(ctx, "principal-1", "slot-1")
|
|
require.NoError(t, err)
|
|
require.Equal(t, CredentialKindBearer, legacy.CredentialKind)
|
|
require.Equal(t, "legacy-slot", legacy.Alias)
|
|
legacyAnthropic, err := store.GetSlot(ctx, "principal-1", "slot-2")
|
|
require.NoError(t, err)
|
|
require.Equal(t, CredentialKindAPIKey, legacyAnthropic.CredentialKind)
|
|
|
|
created, err := store.CreateSlot(ctx, CreateSlotInput{PrincipalID: "principal-1", Vendor: "openai", CredentialKind: CredentialKindBearer, Envelope: SecretEnvelope{Algorithm: "AES-256-GCM", KeyID: "k1", KeyVersion: 1, Nonce: []byte("123456789012"), Ciphertext: []byte("new")}})
|
|
require.NoError(t, err)
|
|
require.Empty(t, created.Alias)
|
|
require.NoError(t, store.Close())
|
|
|
|
reopened, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(reg))
|
|
require.NoError(t, err)
|
|
defer reopened.Close()
|
|
loaded, err := reopened.GetSlot(ctx, "principal-1", created.ID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, loaded.Alias)
|
|
}
|
|
|
|
func TestCredentialSlotMigrationRejectsIncompatibleActiveBinding(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := filepath.Join(t.TempDir(), "incompatible-intermediate.db")
|
|
db, err := sql.Open(dialectSQLite, dbPath)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`
|
|
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE TABLE credential_slots (
|
|
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
|
|
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
|
|
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
|
|
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
|
|
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
|
|
INSERT INTO credential_slots VALUES ('slot-1', 'principal-1', 'unknown-provider', 'legacy-slot', 'active', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
|
|
INSERT INTO credential_slot_revisions VALUES ('revision-1', 'slot-1', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
|
|
INSERT INTO routes VALUES ('route-1', 'principal-1', 'slot-1', 'legacy-route', 'openai', 'model', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Close())
|
|
|
|
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(newFakeKeyRegistry()))
|
|
require.Nil(t, store)
|
|
require.ErrorIs(t, err, ErrIncompatibleProfile)
|
|
require.NotContains(t, err.Error(), "opaque")
|
|
}
|
|
|
|
func TestCredentialSlotMigrationRejectsIncompatibleInactiveBinding(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := filepath.Join(t.TempDir(), "incompatible-inactive-intermediate.db")
|
|
db, err := sql.Open(dialectSQLite, dbPath)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`
|
|
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE TABLE credential_slots (
|
|
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
|
|
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
|
|
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
|
|
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
|
|
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
|
|
INSERT INTO credential_slots VALUES ('slot-1', 'principal-1', 'unknown-provider', 'legacy-slot', 'disabled', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);
|
|
INSERT INTO credential_slot_revisions VALUES ('revision-1', 'slot-1', 0, 'AES-256-GCM', 'k1', 1, X'313233343536373839303132', X'6F7061717565', NULL, '2026-01-01T00:00:00Z');
|
|
INSERT INTO routes VALUES ('route-1', 'principal-1', 'slot-1', 'legacy-route', 'openai', 'model', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Close())
|
|
|
|
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(newFakeKeyRegistry()))
|
|
require.Nil(t, store)
|
|
require.ErrorIs(t, err, ErrIncompatibleProfile)
|
|
require.NotContains(t, err.Error(), "opaque")
|
|
}
|
|
|
|
func TestCredentialSlotMigrationRejectsActiveRouteWithoutSlot(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := filepath.Join(t.TempDir(), "orphan-route-intermediate.db")
|
|
db, err := sql.Open(dialectSQLite, dbPath)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`
|
|
CREATE TABLE principals (id TEXT PRIMARY KEY, alias TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE TABLE credential_slots (
|
|
id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, vendor TEXT NOT NULL, alias TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL,
|
|
algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, UNIQUE(principal_id, alias), UNIQUE(principal_id, id));
|
|
CREATE TABLE credential_slot_revisions (id TEXT PRIMARY KEY, slot_id TEXT NOT NULL, revision INTEGER NOT NULL, algorithm TEXT NOT NULL, key_id TEXT NOT NULL, key_version INTEGER NOT NULL, nonce BLOB NOT NULL, ciphertext BLOB NOT NULL, aad BLOB, created_at TEXT NOT NULL, UNIQUE(slot_id, revision));
|
|
CREATE TABLE routes (id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, slot_id TEXT NOT NULL, alias TEXT, profile_id TEXT NOT NULL, upstream_model TEXT NOT NULL, resource_selector TEXT NOT NULL DEFAULT 'default', status TEXT NOT NULL CHECK(status IN ('active','disabled','revoked')), revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT);
|
|
INSERT INTO principals VALUES ('principal-1', 'legacy', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
|
|
INSERT INTO routes VALUES ('route-1', 'principal-1', 'nonexistent-slot', 'legacy-route', 'openai', 'model', 'default', 'active', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', NULL);`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Close())
|
|
|
|
store, err := Open(ctx, dbPath, WithEnvelopeKeyRegistry(newFakeKeyRegistry()))
|
|
require.Nil(t, store)
|
|
require.ErrorIs(t, err, ErrIncompatibleProfile)
|
|
require.NotContains(t, err.Error(), "opaque")
|
|
}
|