feat: open staged self-host models to all accounts (#922)

A staged self-host model is granted to every account without a per-account
grant, making the admin import the only v1 gate. Wrap the access resolver so
every gate opens together; the admin catalog reports self-host state
weights-first. Per-account grants remain as a dormant seam.
This commit is contained in:
Josh Siviour
2026-08-27 21:42:35 +10:00
committed by GitHub
parent 7e36922931
commit da5b7de2bb
5 changed files with 239 additions and 19 deletions
+5 -3
View File
@@ -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
}
@@ -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
}
@@ -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)
}
+17 -12
View File
@@ -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
+37 -4
View File
@@ -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