Files
seaweedfs/weed/s3api/s3api_iam_oidc_test.go
T
Chris Lu f8973b3ed6 feat(iam): OIDC provider mutations + multi-client + TLS thumbprints (Phase 2b) (#9320)
* feat(iam): OIDC provider mutations + multi-client + TLS thumbprints

- Mutating IAM actions: CreateOpenIDConnectProvider,
  DeleteOpenIDConnectProvider, AddClientIDToOpenIDConnectProvider,
  RemoveClientIDFromOpenIDConnectProvider,
  UpdateOpenIDConnectProviderThumbprint, TagOpenIDConnectProvider,
  UntagOpenIDConnectProvider. Each enforces AWS-shape input bounds and
  the read-only mode rejects all mutations.
- Multiple client_ids per provider in OIDCConfig (clientIds list, plural)
  with full backward compatibility — singular clientId still works and is
  merged into the audience allowlist. Provider factory accepts both.
- AWS-compatible TLS thumbprint pinning: when OIDCConfig.Thumbprints is
  non-empty, JWKS fetches enforce that the negotiated TLS chain contains
  a certificate whose SHA-1 hex matches the allowlist. Empty list keeps
  the existing system-trust path.

* fix(iam): factor Tags.member.N parser into a helper

CreateOpenIDConnectProvider and TagOpenIDConnectProvider were both
walking the AWS Tags.member.N.Key / Tags.member.N.Value query-string
convention with copy-pasted loops. Factor into extractTags so the
parsing rules and the "no tags present" semantics live in one place.

Addresses gemini medium review on PR #9320.

* fix(iam): sentinel errors for OIDC provider not-found / already-exists

The s3api dispatcher was using strings.Contains(err.Error(), "not found")
and "already exists" to map IAM-manager errors back to AWS error codes.
Substring matching on a formatted message couples the API error code
to the exact wording of the upstream message — touching the message
silently changes the IAM API contract.

Define ErrOIDCProviderNotFound and ErrOIDCProviderAlreadyExists in the
integration package, fmt.Errorf("%w: ...") them at the four return
sites in iam_manager.go and oidc_provider_store.go, and use errors.Is
at the s3api call sites. Same control flow, no string-match fragility.

Addresses gemini medium review on PR #9320.

* fix(iam): surface non-NotFound errors from CreateOIDCProvider lookup

Previously CreateOIDCProvider only treated GetProviderByARN's success path
as "exists" and silently fell through on any error, including transient
backend failures. That hid real problems and still attempted a write.
Distinguish ErrOIDCProviderNotFound (the only "safe to create" case) from
other errors so we don't mask filer outages or partition issues.

* fix(iam): enforce 100-client-ID cap on AddClientIDToOIDCProvider

CreateOIDCProvider and the implicit update path through validateOIDC-
ProviderRecord both reject lists with more than 100 client IDs, but
AddClientIDToOIDCProvider could grow the list past that bound one
ID at a time. Refuse the add when the list is already at the cap so
the invariant holds across every mutation entry point.

* feat(iam): IAM-managed OIDC provider live view in STS service

Add a separate, mutex-guarded map of admin-managed OIDC providers on
the STS service. The map can be atomically replaced via
SetIAMManagedOIDCProvidersByIssuer; AssumeRoleWithWebIdentity lookups
consult it first and fall back to the existing static-config map, so
records persisted through the IAM API can shadow bootstrap entries
without a restart.

This is the runtime hook the IAM API and the metadata-subscribe path
will both call when the OIDCProviderStore changes (next two commits).

* feat(iam): refresh STS service runtime view after OIDC mutations

Add IAMManager.RefreshOIDCProvidersFromStore: lists every persisted
OIDCProviderRecord, builds a runtime OIDCProvider for each, and atomically
publishes the issuer-keyed map into the STS service. Each mutating IAM API
call (Create / Delete / AddClientID / RemoveClientID / UpdateThumbprints)
now triggers this refresh inline so the local instance picks up the change
without waiting for a metadata-subscribe round trip. Tag mutations skip
the refresh because tags do not affect token validation.

Refresh failures only log; the persisted write has already succeeded by
that point, so a transient list error must not surface to the API caller.
The peer-instance update path (filer metadata subscription) is added in a
follow-up commit.

* feat(iam): subscribe to OIDC provider changes on the filer

Watch /etc/iam/oidc-providers under the existing s3 metadata-subscribe
loop and call RefreshOIDCProvidersFromStore on any create / update /
delete / rename. This is the cross-instance update path: S3 server A
writes via the IAM API, the filer fans out the metadata change, and S3
servers B..N pick up the new runtime view without a restart.

Mirrors the existing onIamConfigChange / onCircuitBreakerConfigChange
pattern. The handler short-circuits when the path is unrelated, and
when no IAMManager is wired in (static-only configurations).
2026-05-05 11:26:08 -07:00

397 lines
14 KiB
Go

package s3api
import (
"context"
"net/url"
"testing"
"time"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
)
// stubIntegration is the smallest IAMManagerProvider that lets the OIDC
// dispatcher reach an IAMManager. The other IAMIntegration methods are
// unused by these tests and panic if invoked, which is what we want — any
// unexpected call signals a routing bug.
type stubIntegration struct {
IAMIntegration
mgr *integration.IAMManager
}
func (s *stubIntegration) GetIAMManager() *integration.IAMManager { return s.mgr }
func newOIDCTestAPI(t *testing.T) (*EmbeddedIamApiForTest, *integration.IAMManager) {
t.Helper()
mgr := integration.NewIAMManager()
cfg := &integration.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"),
AccountId: "111122223333",
Providers: []*sts.ProviderConfig{
{
Name: "google",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://accounts.google.com",
"clientId": "client-google",
},
},
{
Name: "github",
Type: sts.ProviderTypeOIDC,
Enabled: true,
Config: map[string]interface{}{
"issuer": "https://token.actions.githubusercontent.com",
"clientId": "sts.amazonaws.com",
},
},
},
},
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
}
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
t.Fatalf("Initialize IAM manager: %v", err)
}
api := NewEmbeddedIamApiForTest()
api.iam.iamIntegration = &stubIntegration{mgr: mgr}
return api, mgr
}
func TestListOpenIDConnectProviders(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionListOpenIDConnectProviders)
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-1")
if iamErr != nil {
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
}
listResp, ok := resp.(*iamlib.ListOpenIDConnectProvidersResponse)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
got := listResp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList
if len(got) != 2 {
t.Fatalf("expected 2 providers, got %d", len(got))
}
}
func TestGetOpenIDConnectProvider(t *testing.T) {
api, _ := newOIDCTestAPI(t)
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
values := url.Values{}
values.Set("Action", actionGetOpenIDConnectProvider)
values.Set("OpenIDConnectProviderArn", arn)
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-2")
if iamErr != nil {
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
}
getResp, ok := resp.(*iamlib.GetOpenIDConnectProviderResponse)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
if getResp.GetOpenIDConnectProviderResult.Url != "https://accounts.google.com" {
t.Fatalf("URL mismatch: %s", getResp.GetOpenIDConnectProviderResult.Url)
}
if len(getResp.GetOpenIDConnectProviderResult.ClientIDList) != 1 ||
getResp.GetOpenIDConnectProviderResult.ClientIDList[0] != "client-google" {
t.Fatalf("ClientIDList wrong: %v", getResp.GetOpenIDConnectProviderResult.ClientIDList)
}
}
func TestGetOpenIDConnectProviderMissing(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionGetOpenIDConnectProvider)
values.Set("OpenIDConnectProviderArn", "arn:aws:iam::111122223333:oidc-provider/nope.example")
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-3")
if iamErr == nil {
t.Fatal("expected NoSuchEntity error")
}
if iamErr.Code != "NoSuchEntity" {
t.Fatalf("expected NoSuchEntity code, got %s", iamErr.Code)
}
}
func TestGetOpenIDConnectProviderRequiresArn(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionGetOpenIDConnectProvider)
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-4")
if iamErr == nil {
t.Fatal("expected error for missing ARN")
}
if iamErr.Code != "InvalidInput" {
t.Fatalf("expected InvalidInput code, got %s", iamErr.Code)
}
}
func TestReadOnlyAllowsOIDCList(t *testing.T) {
api, _ := newOIDCTestAPI(t)
api.readOnly = true
values := url.Values{}
values.Set("Action", actionListOpenIDConnectProviders)
if _, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-1"); iamErr != nil {
t.Fatalf("read-only mode should allow ListOpenIDConnectProviders: %v", iamErr.Error)
}
}
func TestReadOnlyDeniesOIDCMutations(t *testing.T) {
api, _ := newOIDCTestAPI(t)
api.readOnly = true
mutations := []string{
actionCreateOpenIDConnectProvider,
actionDeleteOpenIDConnectProvider,
actionAddClientIDToOpenIDConnectProvider,
actionRemoveClientIDFromOpenIDConnectProvider,
actionUpdateOpenIDConnectProviderThumbprint,
actionTagOpenIDConnectProvider,
actionUntagOpenIDConnectProvider,
}
for _, action := range mutations {
t.Run(action, func(t *testing.T) {
values := url.Values{}
values.Set("Action", action)
_, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-deny")
if iamErr == nil {
t.Fatalf("expected denial for %s in read-only mode", action)
}
})
}
}
func TestCreateOpenIDConnectProvider(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionCreateOpenIDConnectProvider)
values.Set("Url", "https://auth.example.com")
values.Set("ClientIDList.member.1", "alpha")
values.Set("ClientIDList.member.2", "beta")
values.Set("ThumbprintList.member.1", "9e99a48a9960b14926bb7f3b02e22da2b0ab7280")
values.Set("Tags.member.1.Key", "team")
values.Set("Tags.member.1.Value", "infra")
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "create-1")
if iamErr != nil {
t.Fatalf("ExecuteAction: %v", iamErr.Error)
}
createResp, ok := resp.(*iamlib.CreateOpenIDConnectProviderResponse)
if !ok {
t.Fatalf("unexpected response type %T", resp)
}
wantArn := "arn:aws:iam::111122223333:oidc-provider/auth.example.com"
if createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn != wantArn {
t.Fatalf("ARN mismatch: got=%s want=%s",
createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn, wantArn)
}
// Get the new provider back to confirm persistence and clientId list.
getValues := url.Values{}
getValues.Set("Action", actionGetOpenIDConnectProvider)
getValues.Set("OpenIDConnectProviderArn", wantArn)
getResp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "get-after-create")
if iamErr != nil {
t.Fatalf("Get after create: %v", iamErr.Error)
}
gr := getResp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
if len(gr.ClientIDList) != 2 || gr.ClientIDList[0] != "alpha" || gr.ClientIDList[1] != "beta" {
t.Fatalf("ClientIDList persisted incorrectly: %v", gr.ClientIDList)
}
if len(gr.ThumbprintList) != 1 || gr.ThumbprintList[0] != "9e99a48a9960b14926bb7f3b02e22da2b0ab7280" {
t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList)
}
if len(gr.Tags) != 1 || gr.Tags[0].Key != "team" || gr.Tags[0].Value != "infra" {
t.Fatalf("Tags persisted incorrectly: %v", gr.Tags)
}
}
func TestCreateOpenIDConnectProviderRejectsBadInput(t *testing.T) {
api, _ := newOIDCTestAPI(t)
t.Run("missing Url", func(t *testing.T) {
values := url.Values{}
values.Set("Action", actionCreateOpenIDConnectProvider)
values.Set("ClientIDList.member.1", "x")
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
if iamErr == nil || iamErr.Code != "InvalidInput" {
t.Fatalf("expected InvalidInput, got %v", iamErr)
}
})
t.Run("missing ClientIDList", func(t *testing.T) {
values := url.Values{}
values.Set("Action", actionCreateOpenIDConnectProvider)
values.Set("Url", "https://auth.example.com")
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
if iamErr == nil || iamErr.Code != "InvalidInput" {
t.Fatalf("expected InvalidInput, got %v", iamErr)
}
})
t.Run("invalid thumbprint", func(t *testing.T) {
values := url.Values{}
values.Set("Action", actionCreateOpenIDConnectProvider)
values.Set("Url", "https://auth.example.com")
values.Set("ClientIDList.member.1", "x")
values.Set("ThumbprintList.member.1", "not-a-sha1")
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
if iamErr == nil || iamErr.Code != "InvalidInput" {
t.Fatalf("expected InvalidInput, got %v", iamErr)
}
})
}
func TestCreateOpenIDConnectProviderConflict(t *testing.T) {
api, _ := newOIDCTestAPI(t)
values := url.Values{}
values.Set("Action", actionCreateOpenIDConnectProvider)
values.Set("Url", "https://accounts.google.com") // already mirrored from static config
values.Set("ClientIDList.member.1", "x")
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
if iamErr == nil {
t.Fatal("expected conflict for duplicate provider")
}
if iamErr.Code != "EntityAlreadyExists" {
t.Fatalf("expected EntityAlreadyExists, got %s", iamErr.Code)
}
}
func TestDeleteOpenIDConnectProvider(t *testing.T) {
api, _ := newOIDCTestAPI(t)
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
values := url.Values{}
values.Set("Action", actionDeleteOpenIDConnectProvider)
values.Set("OpenIDConnectProviderArn", arn)
if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil {
t.Fatalf("delete: %v", iamErr.Error)
}
// Verify gone.
getValues := url.Values{}
getValues.Set("Action", actionGetOpenIDConnectProvider)
getValues.Set("OpenIDConnectProviderArn", arn)
if _, iamErr := api.ExecuteAction(context.Background(), getValues, true, ""); iamErr == nil {
t.Fatal("expected not-found after delete")
}
// Idempotent.
if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil {
t.Fatalf("idempotent delete failed: %v", iamErr.Error)
}
}
func TestAddRemoveClientID(t *testing.T) {
api, _ := newOIDCTestAPI(t)
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
add := url.Values{}
add.Set("Action", actionAddClientIDToOpenIDConnectProvider)
add.Set("OpenIDConnectProviderArn", arn)
add.Set("ClientID", "extra-client")
if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil {
t.Fatalf("add: %v", iamErr.Error)
}
getValues := url.Values{}
getValues.Set("Action", actionGetOpenIDConnectProvider)
getValues.Set("OpenIDConnectProviderArn", arn)
resp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "")
if iamErr != nil {
t.Fatalf("get: %v", iamErr.Error)
}
got := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList
if len(got) != 2 {
t.Fatalf("expected 2 clients after add, got %v", got)
}
// Idempotent add.
if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil {
t.Fatalf("idempotent add: %v", iamErr.Error)
}
// Remove.
rm := url.Values{}
rm.Set("Action", actionRemoveClientIDFromOpenIDConnectProvider)
rm.Set("OpenIDConnectProviderArn", arn)
rm.Set("ClientID", "extra-client")
if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil {
t.Fatalf("remove: %v", iamErr.Error)
}
resp, _ = api.ExecuteAction(context.Background(), getValues, true, "")
got = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList
if len(got) != 1 || got[0] != "client-google" {
t.Fatalf("unexpected clients after remove: %v", got)
}
// Removing a missing client is a no-op.
if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil {
t.Fatalf("idempotent remove: %v", iamErr.Error)
}
}
func TestUpdateThumbprintAndTags(t *testing.T) {
api, _ := newOIDCTestAPI(t)
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
upd := url.Values{}
upd.Set("Action", actionUpdateOpenIDConnectProviderThumbprint)
upd.Set("OpenIDConnectProviderArn", arn)
upd.Set("ThumbprintList.member.1", "0000000000000000000000000000000000000000")
upd.Set("ThumbprintList.member.2", "1111111111111111111111111111111111111111")
if _, iamErr := api.ExecuteAction(context.Background(), upd, true, ""); iamErr != nil {
t.Fatalf("update thumbprints: %v", iamErr.Error)
}
tag := url.Values{}
tag.Set("Action", actionTagOpenIDConnectProvider)
tag.Set("OpenIDConnectProviderArn", arn)
tag.Set("Tags.member.1.Key", "owner")
tag.Set("Tags.member.1.Value", "platform")
if _, iamErr := api.ExecuteAction(context.Background(), tag, true, ""); iamErr != nil {
t.Fatalf("tag: %v", iamErr.Error)
}
get := url.Values{}
get.Set("Action", actionGetOpenIDConnectProvider)
get.Set("OpenIDConnectProviderArn", arn)
resp, iamErr := api.ExecuteAction(context.Background(), get, true, "")
if iamErr != nil {
t.Fatalf("get: %v", iamErr.Error)
}
gr := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
if len(gr.ThumbprintList) != 2 {
t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList)
}
if len(gr.Tags) != 1 || gr.Tags[0].Key != "owner" {
t.Fatalf("Tags persisted incorrectly: %v", gr.Tags)
}
untag := url.Values{}
untag.Set("Action", actionUntagOpenIDConnectProvider)
untag.Set("OpenIDConnectProviderArn", arn)
untag.Set("TagKeys.member.1", "owner")
if _, iamErr := api.ExecuteAction(context.Background(), untag, true, ""); iamErr != nil {
t.Fatalf("untag: %v", iamErr.Error)
}
resp, _ = api.ExecuteAction(context.Background(), get, true, "")
gr = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
if len(gr.Tags) != 0 {
t.Fatalf("Tags should be empty after untag, got: %v", gr.Tags)
}
}