s3/iam: persist IAM-managed OIDC providers in the filer, and trust them after a restart

The S3 server's IAM config loader never read the documented
`oidcProviderStore` key, so the OIDC provider store was always in memory:
a provider created with CreateOpenIDConnectProvider lived in one gateway's
process, was lost on restart, and was never seen by peers. The
/etc/iam/oidc-providers metadata subscription refreshed from that empty
in-memory store.

- Read `oidcProviderStore` and pass it to the IAM manager. With an IAM
  config file the default stays memory. With no config file (zero-config
  IAM, as `weed filer -s3` and operator-managed clusters run) it defaults
  to the filer: there is nothing static to shadow, and providers created at
  runtime otherwise vanish on restart.
- With a store that outlives the process, load the STS runtime view from it
  at startup, so providers created on an earlier boot or on a peer are
  trusted without waiting for the next mutation.
- If the store cannot be read at startup (a filer not up yet), the load is
  retried in the background with backoff until it succeeds: the metadata
  subscription reports only later changes, so providers already stored would
  otherwise stay unknown to STS until one of them changed.
- Mark records mirrored from STS.Providers as `source: static-config`, and
  at startup delete such records whose provider has left the config, so
  removing a provider from the config file still revokes it. Records created
  through the IAM API are never pruned.
- The filer store reported every failed lookup, an unreachable filer
  included, as ErrOIDCProviderNotFound, which CreateOIDCProvider reads as
  "free to create". Only a confirmed absence is now not-found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khris Richardson
2026-09-28 12:43:50 -07:00
co-authored by Claude Opus 5.5
parent 150a69fe11
commit 1207c37542
5 changed files with 336 additions and 4 deletions
+66
View File
@@ -559,6 +559,8 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
}
m.oidcProviderStore = store
mirrored := map[string]bool{}
defer m.pruneAndHydrateOIDCProviders(context.Background(), mirrored)
if config.STS == nil {
return nil
}
@@ -598,16 +600,80 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
Thumbprints: extractStringList(pc.Config, "thumbprints"),
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
PolicyClaim: extractString(pc.Config, "policyClaim"),
Source: OIDCProviderSourceStaticConfig,
CreatedAt: createdAt,
UpdatedAt: now,
}
if err := store.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
glog.Warningf("mirror static OIDC provider %s into store: %v", pc.Name, err)
}
mirrored[arn] = true
}
return nil
}
// pruneAndHydrateOIDCProviders runs after the static mirror when the store
// outlives the process. A record the static config seeded on an earlier boot
// and no longer lists is deleted, so removing a provider from the config file
// still revokes it. The runtime STS view is then loaded from the store, since
// providers created through the IAM API on an earlier boot, or on a peer, are
// otherwise unknown until the next mutation. An in-memory store holds nothing
// from before this boot, so it needs neither.
func (m *IAMManager) pruneAndHydrateOIDCProviders(ctx context.Context, mirrored map[string]bool) {
if _, inMemory := m.oidcProviderStore.(*MemoryOIDCProviderStore); inMemory {
return
}
if err := m.pruneAndHydrateOnce(ctx, mirrored); err != nil {
// The metadata subscription only reports changes made from now on, so
// providers already in the store would stay unknown until one changes.
glog.Warningf("load OIDC providers from the store at startup: %v; retrying in the background", err)
keep := make(map[string]bool, len(mirrored))
for arn := range mirrored {
keep[arn] = true
}
go m.retryPruneAndHydrate(keep)
}
}
// oidcHydrateRetry bounds the backoff between startup load attempts.
var oidcHydrateRetry = struct{ initial, max time.Duration }{initial: time.Second, max: 30 * time.Second}
func (m *IAMManager) retryPruneAndHydrate(mirrored map[string]bool) {
delay := oidcHydrateRetry.initial
for {
time.Sleep(delay)
err := m.pruneAndHydrateOnce(context.Background(), mirrored)
if err == nil {
glog.V(0).Infof("loaded OIDC providers from the store after retrying")
return
}
glog.V(1).Infof("load OIDC providers from the store: %v; retrying in %v", err, delay)
if delay *= 2; delay > oidcHydrateRetry.max {
delay = oidcHydrateRetry.max
}
}
}
// pruneAndHydrateOnce deletes static-config records the config no longer
// lists, then loads the store into STS. It fails when the store cannot be read.
func (m *IAMManager) pruneAndHydrateOnce(ctx context.Context, mirrored map[string]bool) error {
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return fmt.Errorf("list OIDC providers: %w", err)
}
for _, rec := range records {
if rec == nil || rec.Source != OIDCProviderSourceStaticConfig || mirrored[rec.ARN] {
continue
}
if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), rec.ARN); err != nil {
glog.Warningf("prune OIDC provider %s removed from static config: %v", rec.ARN, err)
continue
}
glog.V(1).Infof("pruned OIDC provider %s: no longer in static config", rec.ARN)
}
return m.RefreshOIDCProvidersFromStore(ctx)
}
// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and
// logs a warning on failure. The IAM API call has already succeeded by the
// time we get here, so a refresh failure must not surface to the caller —
@@ -0,0 +1,209 @@
package integration
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/stretchr/testify/assert"
)
// persistentTestStore stands in for a store that outlives the process (the
// filer store): anything but *MemoryOIDCProviderStore is treated as persistent.
type persistentTestStore struct{ *MemoryOIDCProviderStore }
const (
persistTestCurrentIssuer = "https://current.example"
persistTestStaleIssuer = "https://stale.example"
persistTestAPIIssuer = "https://api.example"
)
func newPersistTestManager(t *testing.T) *IAMManager {
t.Helper()
mgr := NewIAMManager()
cfg := &IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
Providers: []*sts.ProviderConfig{{
Name: "current",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{"issuer": persistTestCurrentIssuer, "clientId": "aud"},
}},
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &RoleStoreConfig{StoreType: "memory"},
}
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
t.Fatalf("Initialize: %v", err)
}
return mgr
}
// seedEarlierBoot fills a store as an earlier boot would have left it: the
// provider the config still lists, one it has since dropped, and one created
// through the IAM API.
func seedEarlierBoot(t *testing.T, store OIDCProviderStore) (current, stale, api string) {
t.Helper()
ctx := context.Background()
for _, r := range []struct{ issuer, source string }{
{persistTestCurrentIssuer, OIDCProviderSourceStaticConfig},
{persistTestStaleIssuer, OIDCProviderSourceStaticConfig},
{persistTestAPIIssuer, ""},
} {
arn, err := DeriveOIDCProviderARN("", r.issuer)
if err != nil {
t.Fatalf("derive ARN: %v", err)
}
rec := &OIDCProviderRecord{ARN: arn, URL: r.issuer, ClientIDs: []string{"aud"}, Source: r.source}
if err := store.StoreProvider(ctx, "", rec); err != nil {
t.Fatalf("seed %s: %v", r.issuer, err)
}
}
current, _ = DeriveOIDCProviderARN("", persistTestCurrentIssuer)
stale, _ = DeriveOIDCProviderARN("", persistTestStaleIssuer)
api, _ = DeriveOIDCProviderARN("", persistTestAPIIssuer)
return current, stale, api
}
func storedARNs(t *testing.T, mgr *IAMManager) map[string]bool {
t.Helper()
recs, err := mgr.ListOIDCProviders(context.Background())
if err != nil {
t.Fatalf("ListOIDCProviders: %v", err)
}
out := map[string]bool{}
for _, r := range recs {
out[r.ARN] = true
}
return out
}
// stsKnowsIssuer reports whether STS resolves a provider for the issuer. The
// token is not validly signed; the only question is which error comes back.
func stsKnowsIssuer(t *testing.T, mgr *IAMManager, issuer string) bool {
t.Helper()
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": issuer, "sub": "probe", "aud": "aud", "exp": time.Now().Add(time.Hour).Unix(),
}).SignedString([]byte("not-the-providers-key"))
if err != nil {
t.Fatalf("mint token: %v", err)
}
_, _, err = mgr.GetSTSService().ValidateWebIdentityToken(context.Background(), tok)
if err == nil {
t.Fatalf("an unsigned-by-provider token for %s was accepted", issuer)
}
return !strings.Contains(err.Error(), "no identity provider registered")
}
func TestPersistentStorePrunesDroppedStaticProvidersAndHydratesSTS(t *testing.T) {
mgr := newPersistTestManager(t)
store := &persistentTestStore{NewMemoryOIDCProviderStore()}
current, stale, api := seedEarlierBoot(t, store)
mgr.SetOIDCProviderStore(store)
if stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
t.Fatal("precondition: STS already knows the API-created issuer before hydration")
}
mgr.pruneAndHydrateOIDCProviders(context.Background(), map[string]bool{current: true})
got := storedARNs(t, mgr)
if got[stale] {
t.Errorf("provider dropped from static config is still stored: %s", stale)
}
if !got[current] {
t.Errorf("provider still in static config was pruned: %s", current)
}
if !got[api] {
t.Errorf("provider created through the IAM API was pruned: %s", api)
}
if !stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
t.Error("STS does not trust the API-created provider found in the store at startup")
}
if stsKnowsIssuer(t, mgr, persistTestStaleIssuer) {
t.Error("STS still trusts the provider dropped from static config")
}
}
func TestInMemoryStoreIsNeitherPrunedNorHydrated(t *testing.T) {
mgr := newPersistTestManager(t)
store := NewMemoryOIDCProviderStore()
current, stale, _ := seedEarlierBoot(t, store)
mgr.SetOIDCProviderStore(store)
mgr.pruneAndHydrateOIDCProviders(context.Background(), map[string]bool{current: true})
if !storedARNs(t, mgr)[stale] {
t.Error("an in-memory store was pruned; it holds nothing from an earlier boot")
}
if stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
t.Error("an in-memory store was hydrated into STS at startup")
}
}
func TestStaticMirrorMarksItsRecords(t *testing.T) {
mgr := newPersistTestManager(t)
recs, err := mgr.ListOIDCProviders(context.Background())
if err != nil {
t.Fatalf("ListOIDCProviders: %v", err)
}
if len(recs) != 1 || recs[0].Source != OIDCProviderSourceStaticConfig {
t.Fatalf("static mirror did not mark its record: %+v", recs)
}
}
// unreachableThenReadyStore fails its first reads, as a filer that is not up
// yet when the S3 server starts.
type unreachableThenReadyStore struct {
*MemoryOIDCProviderStore
mu sync.Mutex
failsLeft int
}
func (s *unreachableThenReadyStore) ListProviders(ctx context.Context, addr string) ([]*OIDCProviderRecord, error) {
s.mu.Lock()
if s.failsLeft > 0 {
s.failsLeft--
s.mu.Unlock()
return nil, errors.New("filer unavailable")
}
s.mu.Unlock()
return s.MemoryOIDCProviderStore.ListProviders(ctx, addr)
}
// A store that cannot be read at startup is retried: the metadata
// subscription only reports later changes, so without a retry the providers
// already stored would stay unknown to STS.
func TestStartupLoadRetriesUntilTheStoreIsReadable(t *testing.T) {
saved := oidcHydrateRetry
oidcHydrateRetry.initial, oidcHydrateRetry.max = time.Millisecond, 5*time.Millisecond
t.Cleanup(func() { oidcHydrateRetry = saved })
mgr := newPersistTestManager(t)
store := &unreachableThenReadyStore{MemoryOIDCProviderStore: NewMemoryOIDCProviderStore(), failsLeft: 3}
current, stale, api := seedEarlierBoot(t, store)
mgr.SetOIDCProviderStore(store)
mgr.pruneAndHydrateOIDCProviders(context.Background(), map[string]bool{current: true})
deadline := time.Now().Add(2 * time.Second)
for !stsKnowsIssuer(t, mgr, persistTestAPIIssuer) {
if time.Now().After(deadline) {
t.Fatal("STS never learned the stored provider after the store became readable")
}
time.Sleep(5 * time.Millisecond)
}
got := storedARNs(t, mgr)
assert.False(t, got[stale], "the provider dropped from static config was not pruned on retry")
assert.True(t, got[api])
}
+18 -3
View File
@@ -71,10 +71,20 @@ type OIDCProviderRecord struct {
// (audit/inventory metadata, not propagated into sessions).
Tags map[string]string `json:"tags,omitempty"`
// Source records where the entry came from. OIDCProviderSourceStaticConfig
// marks a record mirrored from STS.Providers at boot; empty means it was
// created through the IAM API. Only static-config records are pruned when
// their provider leaves the configuration.
Source string `json:"source,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// OIDCProviderSourceStaticConfig is the Source of a record mirrored from the
// static STS provider configuration.
const OIDCProviderSourceStaticConfig = "static-config"
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
// expected to be safe for concurrent use.
type OIDCProviderStore interface {
@@ -267,15 +277,20 @@ func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddr
var data []byte
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
})
// Only a confirmed absence is ErrOIDCProviderNotFound: callers create
// on it, so an unreachable filer must not read as "no such provider".
if errors.Is(err, filer_pb.ErrNotFound) {
return fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
}
if err != nil {
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
return fmt.Errorf("lookup OIDC provider %s: %w", arn, err)
}
if resp.Entry == nil {
return fmt.Errorf("OIDC provider not found: %s", arn)
return fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
}
data = resp.Entry.Content
return nil
+29
View File
@@ -277,3 +277,32 @@ func TestLoadIAMManagerFromConfig_ExplicitFileEnforcesUserScopedPolicy(t *testin
assert.NoError(t, err)
assert.True(t, allowed, "user-scoped bucket creation should be allowed")
}
func TestLoadIAMManagerFromConfig_HonorsOIDCProviderStore(t *testing.T) {
// The documented oidcProviderStore key must reach the IAM manager; without
// it, providers created through the IAM API live in one gateway's memory.
cases := []struct {
name string
store string
filer bool
}{
{"absent keeps memory", ``, false},
{"filer persists", `,"oidcProviderStore":{"storeType":"filer"}`, true},
{"no config file persists", "no-file", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
configPath := ""
if tc.store != "no-file" {
configPath = filepath.Join(t.TempDir(), "iam_config.json")
configContent := `{"sts":{"providers":[]},"policy":{"storeType":"memory","defaultEffect":"Deny"}` + tc.store + `}`
assert.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
}
manager, err := loadIAMManagerFromConfig(configPath, func() string { return "localhost:8888" }, func() string { return "oidc-store-signing-key" })
assert.NoError(t, err)
_, isFiler := manager.GetOIDCProviderStore().(*integration.FilerOIDCProviderStore)
assert.Equal(t, tc.filer, isFiler)
})
}
}
+14 -1
View File
@@ -1168,7 +1168,10 @@ func loadIAMManagerFromConfig(configPath string, filerAddressProvider func() str
Policy *policy.PolicyEngineConfig `json:"policy"`
Providers []map[string]interface{} `json:"providers"`
Roles []*integration.RoleDefinition `json:"roles"`
Policies []struct {
// OIDCProviderStore selects where IAM-managed OIDC providers persist.
// Absent, they live in memory and are lost on restart.
OIDCProviderStore *integration.OIDCProviderStoreConfig `json:"oidcProviderStore"`
Policies []struct {
Name string `json:"name"`
Document *policy.PolicyDocument `json:"document"`
} `json:"policies"`
@@ -1213,6 +1216,15 @@ func loadIAMManagerFromConfig(configPath string, filerAddressProvider func() str
glog.V(1).Infof("Using policy defaults: DefaultEffect=%s, StoreType=%s", configRoot.Policy.DefaultEffect, configRoot.Policy.StoreType)
}
// With no IAM config file there is nothing static for a persisted
// provider to shadow or outlive, so providers created at runtime default
// to the filer, where restarts and peer S3 servers see them. A config
// file keeps the in-memory default unless it sets oidcProviderStore.
oidcProviderStore := configRoot.OIDCProviderStore
if oidcProviderStore == nil && configPath == "" && filerAddressProvider != nil {
oidcProviderStore = &integration.OIDCProviderStoreConfig{StoreType: "filer"}
}
// Create IAM configuration
iamConfig := &integration.IAMConfig{
STS: configRoot.STS,
@@ -1220,6 +1232,7 @@ func loadIAMManagerFromConfig(configPath string, filerAddressProvider func() str
Roles: &integration.RoleStoreConfig{
StoreType: sts.StoreTypeMemory, // Use memory store for JSON config-based setup
},
OIDCProviders: oidcProviderStore,
}
// Apply default signing key if not present in config