diff --git a/spinifex/gateway/bedrock.go b/spinifex/gateway/bedrock.go index 087394046..a9c3b5d99 100644 --- a/spinifex/gateway/bedrock.go +++ b/spinifex/gateway/bedrock.go @@ -282,13 +282,15 @@ func (gw *GatewayConfig) bedrockRecorder() gateway_bedrock.Recorder { return gateway_bedrock.NoopRecorder } -// bedrockAccessResolver returns gw.BedrockAccess as an AccessResolver, or the +// bedrockAccessResolver returns gw.BedrockAccess wrapped so a staged +// self-host model is granted to every account (import is the grant), or the // deny-all fallback when no grant store is configured. Model access is // deny-by-default, so an unconfigured gateway advertises and serves no models -// rather than all of them. +// rather than all of them; staged-open behaviour requires the access +// subsystem to be present, so the fallback stays unwrapped. func (gw *GatewayConfig) bedrockAccessResolver() gateway_bedrock.AccessResolver { if gw.BedrockAccess != nil { - return gw.BedrockAccess + return gateway_bedrock.NewStagedOpenAccessResolver(gw.BedrockAccess) } return gateway_bedrock.DenyAllAccessResolver } diff --git a/spinifex/gateway/bedrock/access_autogrant.go b/spinifex/gateway/bedrock/access_autogrant.go new file mode 100644 index 000000000..068b76bb8 --- /dev/null +++ b/spinifex/gateway/bedrock/access_autogrant.go @@ -0,0 +1,49 @@ +package gateway_bedrock + +import ( + "context" + "fmt" +) + +// stagedOpenAccessResolver wraps an inner AccessResolver so a staged +// self-host model is granted to every account without an explicit grant: +// import is the grant. An explicit inner grant (and the GlobalAccountID +// system bypass it already honors) still wins, so the per-account seam stays +// live for a later isolated/paid tier. Provider-tier models are unaffected — +// they still need an explicit inner grant. +type stagedOpenAccessResolver struct { + inner AccessResolver +} + +var _ AccessResolver = (*stagedOpenAccessResolver)(nil) + +// NewStagedOpenAccessResolver wraps inner so a staged self-host model is +// granted to every account without an explicit grant, per import-is-the-grant. +func NewStagedOpenAccessResolver(inner AccessResolver) AccessResolver { + return stagedOpenAccessResolver{inner: inner} +} + +// Granted reports true when inner already grants accountID modelID, or when +// modelID is a self-host catalog entry with a staged weights snapshot. A +// weights-resolve error is a fault, not a false, and propagates rather than +// silently denying access. +func (r stagedOpenAccessResolver) Granted(ctx context.Context, accountID, modelID string) (bool, error) { + granted, err := r.inner.Granted(ctx, accountID, modelID) + if err != nil { + return false, err + } + if granted { + return true, nil + } + + entry, ok := lookupCatalogEntry(modelID) + if !ok || entry.Provider != tierSelfHost { + return false, nil + } + + _, staged, err := currentWeightsResolver().Resolve(ctx, modelID) + if err != nil { + return false, fmt.Errorf("resolve weights for %s: %w", modelID, err) + } + return staged, nil +} diff --git a/spinifex/gateway/bedrock/access_autogrant_test.go b/spinifex/gateway/bedrock/access_autogrant_test.go new file mode 100644 index 000000000..560848a89 --- /dev/null +++ b/spinifex/gateway/bedrock/access_autogrant_test.go @@ -0,0 +1,131 @@ +package gateway_bedrock + +//test:in-package — exercises the unexported stagedOpenAccessResolver and +// reuses grantSet/grantAll/failingAccess/stubResolver/stubWeightsResolver/ +// errWeightsResolver/withWeightsResolver/withProviderCatalogEntry, this +// package's existing unexported test doubles. + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go/service/bedrock" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// systemBypassInner mimics ModelAccessStore.Granted's GlobalAccountID +// shortcut without a real JetStream-backed store: true only for the system +// account, false for everyone else regardless of model. +type systemBypassInner struct{} + +func (systemBypassInner) Granted(_ context.Context, accountID, _ string) (bool, error) { + return accountID == utils.GlobalAccountID, nil +} + +func TestStagedOpenAccessResolver_StagedSelfHostGrantedWithoutInnerGrant(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}}) + r := NewStagedOpenAccessResolver(grantSet{}) + + granted, err := r.Granted(context.Background(), "000000000099", selfHostTestModel) + require.NoError(t, err) + assert.True(t, granted, "a staged self-host model must be granted to any account") +} + +func TestStagedOpenAccessResolver_UnstagedSelfHostNotGranted(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}}) + r := NewStagedOpenAccessResolver(grantSet{}) + + granted, err := r.Granted(context.Background(), "000000000099", selfHostTestModel) + require.NoError(t, err) + assert.False(t, granted, "an unstaged self-host model must not auto-grant") +} + +// TestStagedOpenAccessResolver_ProviderTierStillNeedsInnerGrant covers the +// tier the plan keeps dormant: staging only auto-opens self-host entries. +func TestStagedOpenAccessResolver_ProviderTierStillNeedsInnerGrant(t *testing.T) { + withProviderCatalogEntry(t, anthropicTestModel) + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}}) + r := NewStagedOpenAccessResolver(grantSet{}) + + granted, err := r.Granted(context.Background(), "000000000099", anthropicTestModel) + require.NoError(t, err) + assert.False(t, granted, "a provider-tier model must not be auto-granted by staging") +} + +func TestStagedOpenAccessResolver_ExplicitInnerGrantStillWins(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}}) + r := NewStagedOpenAccessResolver(grantSet{selfHostTestModel: true}) + + granted, err := r.Granted(context.Background(), "000000000001", selfHostTestModel) + require.NoError(t, err) + assert.True(t, granted, "an explicit inner grant must still be honored even when unstaged") +} + +func TestStagedOpenAccessResolver_SystemAccountBypassIntact(t *testing.T) { + r := NewStagedOpenAccessResolver(systemBypassInner{}) + + granted, err := r.Granted(context.Background(), utils.GlobalAccountID, "nonexistent.model-v1:0") + require.NoError(t, err) + assert.True(t, granted, "the inner resolver's system-account bypass must still win") +} + +func TestStagedOpenAccessResolver_WeightsResolveErrorPropagates(t *testing.T) { + withWeightsResolver(t, errWeightsResolver{}) + r := NewStagedOpenAccessResolver(grantSet{}) + + _, err := r.Granted(context.Background(), "000000000001", selfHostTestModel) + require.Error(t, err) +} + +func TestStagedOpenAccessResolver_InnerErrorPropagates(t *testing.T) { + r := NewStagedOpenAccessResolver(failingAccess{}) + + _, err := r.Granted(context.Background(), "000000000001", selfHostTestModel) + require.Error(t, err) +} + +func TestStagedOpenAccessResolver_UnknownModelNotGranted(t *testing.T) { + r := NewStagedOpenAccessResolver(grantSet{}) + + granted, err := r.Granted(context.Background(), "000000000001", "nonexistent.model-v1:0") + require.NoError(t, err) + assert.False(t, granted) +} + +// TestStagedOpenAccessResolver_ListFoundationModelsIncludesStagedSelfHostForNonGrantedAccount +// is the catalog-layer proof that wrapping bedrockAccessResolver's store +// actually reaches ListFoundationModels: a staged self-host model appears for +// an account holding no explicit grant. +func TestStagedOpenAccessResolver_ListFoundationModelsIncludesStagedSelfHostForNonGrantedAccount(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}}) + access := NewStagedOpenAccessResolver(grantSet{}) + + out, err := ListFoundationModels(context.Background(), "000000000099", stubResolver{ok: map[string]bool{}}, access, &bedrock.ListFoundationModelsInput{}) + require.NoError(t, err) + assert.Contains(t, modelIDs(out), selfHostTestModel) +} + +// TestStagedOpenAccessResolver_GetFoundationModelReturnsStagedSelfHostForNonGrantedAccount +// covers the describe path the same way. +func TestStagedOpenAccessResolver_GetFoundationModelReturnsStagedSelfHostForNonGrantedAccount(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}}) + access := NewStagedOpenAccessResolver(grantSet{}) + + out, err := GetFoundationModel(context.Background(), "000000000099", selfHostTestModel, access) + require.NoError(t, err) + require.NotNil(t, out.ModelDetails) + assert.Equal(t, selfHostTestModel, *out.ModelDetails.ModelId) +} + +// TestStagedOpenAccessResolver_GrantedCatalogEntryAdmitsStagedSelfHostForNonGrantedAccount +// covers the invoke gate every runtime router shares. +func TestStagedOpenAccessResolver_GrantedCatalogEntryAdmitsStagedSelfHostForNonGrantedAccount(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}}) + access := NewStagedOpenAccessResolver(grantSet{}) + + entry, err := grantedCatalogEntry(context.Background(), "000000000099", selfHostTestModel, access) + require.NoError(t, err) + assert.Equal(t, selfHostTestModel, entry.ModelID) +} diff --git a/spinifex/gateway/bedrock/admin_catalog.go b/spinifex/gateway/bedrock/admin_catalog.go index 14834d93d..edfd4eac1 100644 --- a/spinifex/gateway/bedrock/admin_catalog.go +++ b/spinifex/gateway/bedrock/admin_catalog.go @@ -64,19 +64,16 @@ func (e catalogEntry) toAdminEntry(availability string) AdminCatalogEntry { } } -// availabilityReason runs the same three checks tieredCatalog uses to decide -// whether to include entry for accountID, but returns which one failed -// instead of a bool: an operator-only view is allowed to say why, where the -// tenant-facing catalog is not. +// availabilityReason runs the same checks tieredCatalog's access.Granted +// (via stagedOpenAccessResolver) uses to decide whether to include entry for +// accountID, but returns which one failed instead of a bool: an operator-only +// view is allowed to say why, where the tenant-facing catalog is not. +// +// Self-host is judged weights-first, ahead of the grant check: import is the +// grant in v1, so a self-host model's only gate is whether it is staged, and +// it must never report AvailabilityUngranted. The grant/credential checks +// below are reserved for the provider tier, which still requires one. func availabilityReason(ctx context.Context, accountID string, entry catalogEntry, resolver CredentialResolver, access AccessResolver) (string, error) { - granted, err := access.Granted(ctx, accountID, entry.ModelID) - if err != nil { - return "", err - } - if !granted { - return AvailabilityUngranted, nil - } - if entry.Provider == tierSelfHost { _, resolvable, err := currentWeightsResolver().Resolve(ctx, entry.ModelID) if err != nil { @@ -89,6 +86,14 @@ func availabilityReason(ctx context.Context, accountID string, entry catalogEntr return AvailabilityAvailable, nil } + granted, err := access.Granted(ctx, accountID, entry.ModelID) + if err != nil { + return "", err + } + if !granted { + return AvailabilityUngranted, nil + } + vendor, ok := strings.CutPrefix(entry.Provider, providerPrefix) if !ok { return AvailabilityNoCredential, nil diff --git a/spinifex/gateway/bedrock/admin_catalog_test.go b/spinifex/gateway/bedrock/admin_catalog_test.go index 19304e153..6cfdf6be8 100644 --- a/spinifex/gateway/bedrock/admin_catalog_test.go +++ b/spinifex/gateway/bedrock/admin_catalog_test.go @@ -1,5 +1,10 @@ package gateway_bedrock +//test:in-package — shares unexported test doubles (grantSet, grantAll, +// stubResolver, withWeightsResolver, withProviderCatalogEntry) with the rest +// of this package's *_test.go files rather than duplicating them behind an +// exported surface. + import ( "context" "encoding/json" @@ -34,14 +39,42 @@ func TestAdminCatalog_ClaudeEntryAbsent(t *testing.T) { } } -// TestAdminCatalog_ReasonUngranted covers the ungranted case: the grant check -// runs before any weights/credential resolution, so an ungranted account -// never learns whether the model would otherwise be servable. +// TestAdminCatalog_ReasonUngranted covers the ungranted case for the +// provider tier, the only one still gated by an explicit grant: self-host is +// judged weights-first and never reports ungranted (see +// TestAdminCatalog_SelfHostNeverUngranted). func TestAdminCatalog_ReasonUngranted(t *testing.T) { + withProviderCatalogEntry(t, anthropicTestModel) + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}}) + entries, err := AdminCatalog(context.Background(), "000000000001", stubResolver{ok: map[string]bool{"anthropic": true}}, grantSet{}) + require.NoError(t, err) + assert.Equal(t, AvailabilityUngranted, adminEntry(t, entries, anthropicTestModel).Availability) +} + +// TestAdminCatalog_SelfHostAvailableWithoutGrant is import-is-the-grant at +// the admin layer: a staged self-host model reports available even for an +// account holding no explicit grant on it. +func TestAdminCatalog_SelfHostAvailableWithoutGrant(t *testing.T) { withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{selfHostTestModel: true}}) entries, err := AdminCatalog(context.Background(), "000000000001", stubResolver{ok: map[string]bool{}}, grantSet{}) require.NoError(t, err) - assert.Equal(t, AvailabilityUngranted, adminEntry(t, entries, selfHostTestModel).Availability) + assert.Equal(t, AvailabilityAvailable, adminEntry(t, entries, selfHostTestModel).Availability) +} + +// TestAdminCatalog_SelfHostNeverUngranted proves no self-host catalog entry +// can report AvailabilityUngranted, staged or not: the reason reserved for +// the provider tier must never leak onto a self-host entry. +func TestAdminCatalog_SelfHostNeverUngranted(t *testing.T) { + withWeightsResolver(t, stubWeightsResolver{ok: map[string]bool{}}) + entries, err := AdminCatalog(context.Background(), "000000000001", stubResolver{ok: map[string]bool{}}, grantSet{}) + require.NoError(t, err) + for _, e := range entries { + entry, ok := lookupCatalogEntry(e.ModelID) + if !ok || entry.Provider != tierSelfHost { + continue + } + assert.NotEqual(t, AvailabilityUngranted, e.Availability, "self-host model %q must never report ungranted", e.ModelID) + } } // TestAdminCatalog_ReasonNoWeightsStaged covers a granted self-host model