Files
seaweedfs/weed/s3api/s3api_embedded_iam_oidc.go
T
Chris LuandGitHub 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

342 lines
13 KiB
Go

package s3api
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"github.com/aws/aws-sdk-go/service/iam"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
)
// OIDC provider IAM actions handled by this file.
const (
actionGetOpenIDConnectProvider = "GetOpenIDConnectProvider"
actionListOpenIDConnectProviders = "ListOpenIDConnectProviders"
actionCreateOpenIDConnectProvider = "CreateOpenIDConnectProvider"
actionDeleteOpenIDConnectProvider = "DeleteOpenIDConnectProvider"
actionAddClientIDToOpenIDConnectProvider = "AddClientIDToOpenIDConnectProvider"
actionRemoveClientIDFromOpenIDConnectProvider = "RemoveClientIDFromOpenIDConnectProvider"
actionUpdateOpenIDConnectProviderThumbprint = "UpdateOpenIDConnectProviderThumbprint"
actionTagOpenIDConnectProvider = "TagOpenIDConnectProvider"
actionUntagOpenIDConnectProvider = "UntagOpenIDConnectProvider"
)
// isOIDCProviderAction reports whether an action belongs to the OIDC provider
// family. Used by ExecuteAction to short-circuit the S3ApiConfiguration code
// path for actions that don't operate on it.
func isOIDCProviderAction(action string) bool {
switch action {
case actionGetOpenIDConnectProvider,
actionListOpenIDConnectProviders,
actionCreateOpenIDConnectProvider,
actionDeleteOpenIDConnectProvider,
actionAddClientIDToOpenIDConnectProvider,
actionRemoveClientIDFromOpenIDConnectProvider,
actionUpdateOpenIDConnectProviderThumbprint,
actionTagOpenIDConnectProvider,
actionUntagOpenIDConnectProvider:
return true
default:
return false
}
}
// dispatchOIDCProviderAction handles the OIDC provider IAM actions. Returns a
// response, the IAM error if any, and a boolean indicating whether the action
// was recognised (so the caller can fall through when false).
func (e *EmbeddedIamApi) dispatchOIDCProviderAction(ctx context.Context, values url.Values) (iamlib.RequestIDSetter, *iamError, bool) {
if !isOIDCProviderAction(values.Get("Action")) {
return nil, nil, false
}
mgr := e.oidcIAMManager()
if mgr == nil {
return nil, &iamError{
Code: iam.ErrCodeServiceFailureException,
Error: errors.New("OIDC provider store not configured"),
}, true
}
switch values.Get("Action") {
case actionListOpenIDConnectProviders:
resp, err := e.listOpenIDConnectProviders(ctx, mgr)
return resp, err, true
case actionGetOpenIDConnectProvider:
resp, err := e.getOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
case actionCreateOpenIDConnectProvider:
resp, err := e.createOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
case actionDeleteOpenIDConnectProvider:
resp, err := e.deleteOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
case actionAddClientIDToOpenIDConnectProvider:
resp, err := e.addClientIDToOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
case actionRemoveClientIDFromOpenIDConnectProvider:
resp, err := e.removeClientIDFromOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
case actionUpdateOpenIDConnectProviderThumbprint:
resp, err := e.updateOpenIDConnectProviderThumbprint(ctx, mgr, values)
return resp, err, true
case actionTagOpenIDConnectProvider:
resp, err := e.tagOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
case actionUntagOpenIDConnectProvider:
resp, err := e.untagOpenIDConnectProvider(ctx, mgr, values)
return resp, err, true
}
return nil, nil, false
}
// extractMemberList collects all values from `Foo.member.<n>=...` form
// parameters in order of N. AWS query-string conventions encode list inputs
// this way, so List/Add/Update/Tag actions all share this helper.
func extractMemberList(values url.Values, prefix string) []string {
out := []string{}
// Members are indexed from 1; iterate until a missing index breaks the run.
for i := 1; ; i++ {
key := fmt.Sprintf("%s.member.%d", prefix, i)
v := values.Get(key)
if v == "" {
break
}
out = append(out, v)
}
return out
}
func (e *EmbeddedIamApi) createOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.CreateOpenIDConnectProviderResponse, *iamError) {
urlStr := strings.TrimSpace(values.Get("Url"))
if urlStr == "" {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("Url is required")}
}
clientIDs := extractMemberList(values, "ClientIDList")
if len(clientIDs) == 0 {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientIDList must contain at least one entry")}
}
thumbprints := extractMemberList(values, "ThumbprintList")
tags := extractTags(values)
accountID := mgr.GetSTSService().Config.AccountId
arn, err := integration.DeriveOIDCProviderARN(accountID, urlStr)
if err != nil {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
rec := &integration.OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: urlStr,
ClientIDs: clientIDs,
Thumbprints: thumbprints,
Tags: tags,
}
if err := mgr.CreateOIDCProvider(ctx, rec); err != nil {
if errors.Is(err, integration.ErrOIDCProviderAlreadyExists) {
return nil, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: err}
}
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
resp := &iamlib.CreateOpenIDConnectProviderResponse{}
resp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn = rec.ARN
if len(rec.Tags) > 0 {
out := make([]*iamlib.IAMTag, 0, len(rec.Tags))
for k, v := range rec.Tags {
out = append(out, &iamlib.IAMTag{Key: k, Value: v})
}
resp.CreateOpenIDConnectProviderResult.Tags = out
}
return resp, nil
}
func (e *EmbeddedIamApi) deleteOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.DeleteOpenIDConnectProviderResponse, *iamError) {
arn, iamErr := requireProviderArn(values)
if iamErr != nil {
return nil, iamErr
}
if err := mgr.DeleteOIDCProvider(ctx, arn); err != nil {
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
return &iamlib.DeleteOpenIDConnectProviderResponse{}, nil
}
func (e *EmbeddedIamApi) addClientIDToOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.AddClientIDToOpenIDConnectProviderResponse, *iamError) {
arn, iamErr := requireProviderArn(values)
if iamErr != nil {
return nil, iamErr
}
clientID := strings.TrimSpace(values.Get("ClientID"))
if clientID == "" {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientID is required")}
}
if err := mgr.AddClientIDToOIDCProvider(ctx, arn, clientID); err != nil {
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
return &iamlib.AddClientIDToOpenIDConnectProviderResponse{}, nil
}
func (e *EmbeddedIamApi) removeClientIDFromOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.RemoveClientIDFromOpenIDConnectProviderResponse, *iamError) {
arn, iamErr := requireProviderArn(values)
if iamErr != nil {
return nil, iamErr
}
clientID := strings.TrimSpace(values.Get("ClientID"))
if clientID == "" {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ClientID is required")}
}
if err := mgr.RemoveClientIDFromOIDCProvider(ctx, arn, clientID); err != nil {
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
return &iamlib.RemoveClientIDFromOpenIDConnectProviderResponse{}, nil
}
func (e *EmbeddedIamApi) updateOpenIDConnectProviderThumbprint(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.UpdateOpenIDConnectProviderThumbprintResponse, *iamError) {
arn, iamErr := requireProviderArn(values)
if iamErr != nil {
return nil, iamErr
}
thumbprints := extractMemberList(values, "ThumbprintList")
if len(thumbprints) == 0 {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("ThumbprintList must contain at least one entry")}
}
if err := mgr.UpdateOIDCProviderThumbprints(ctx, arn, thumbprints); err != nil {
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
}
return &iamlib.UpdateOpenIDConnectProviderThumbprintResponse{}, nil
}
func (e *EmbeddedIamApi) tagOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.TagOpenIDConnectProviderResponse, *iamError) {
arn, iamErr := requireProviderArn(values)
if iamErr != nil {
return nil, iamErr
}
tags := extractTags(values)
if len(tags) == 0 {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("Tags must contain at least one Key/Value pair")}
}
if err := mgr.TagOIDCProvider(ctx, arn, tags); err != nil {
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
return &iamlib.TagOpenIDConnectProviderResponse{}, nil
}
func (e *EmbeddedIamApi) untagOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.UntagOpenIDConnectProviderResponse, *iamError) {
arn, iamErr := requireProviderArn(values)
if iamErr != nil {
return nil, iamErr
}
keys := extractMemberList(values, "TagKeys")
if len(keys) == 0 {
return nil, &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("TagKeys must contain at least one entry")}
}
if err := mgr.UntagOIDCProvider(ctx, arn, keys); err != nil {
if errors.Is(err, integration.ErrOIDCProviderNotFound) {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
return &iamlib.UntagOpenIDConnectProviderResponse{}, nil
}
// extractTags walks the AWS IAM "Tags.member.N.Key / Tags.member.N.Value"
// query-string convention and returns the parsed map. Returns nil (not an
// empty map) when no tags are present so the caller can distinguish
// "untouched" from "empty".
func extractTags(values url.Values) map[string]string {
var out map[string]string
for i := 1; ; i++ {
k := values.Get(fmt.Sprintf("Tags.member.%d.Key", i))
if k == "" {
break
}
if out == nil {
out = make(map[string]string)
}
out[k] = values.Get(fmt.Sprintf("Tags.member.%d.Value", i))
}
return out
}
func requireProviderArn(values url.Values) (string, *iamError) {
arn := strings.TrimSpace(values.Get("OpenIDConnectProviderArn"))
if arn == "" {
return "", &iamError{Code: iam.ErrCodeInvalidInputException, Error: errors.New("OpenIDConnectProviderArn is required")}
}
return arn, nil
}
func (e *EmbeddedIamApi) oidcIAMManager() *integration.IAMManager {
if e.iam == nil || e.iam.iamIntegration == nil {
return nil
}
provider, ok := e.iam.iamIntegration.(IAMManagerProvider)
if !ok {
return nil
}
return provider.GetIAMManager()
}
func (e *EmbeddedIamApi) listOpenIDConnectProviders(ctx context.Context, mgr *integration.IAMManager) (*iamlib.ListOpenIDConnectProvidersResponse, *iamError) {
records, err := mgr.ListOIDCProviders(ctx)
if err != nil {
return nil, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
resp := &iamlib.ListOpenIDConnectProvidersResponse{}
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList = make([]*iamlib.OpenIDConnectProviderListEntry, 0, len(records))
for _, rec := range records {
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList = append(
resp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList,
&iamlib.OpenIDConnectProviderListEntry{Arn: rec.ARN},
)
}
return resp, nil
}
func (e *EmbeddedIamApi) getOpenIDConnectProvider(ctx context.Context, mgr *integration.IAMManager, values url.Values) (*iamlib.GetOpenIDConnectProviderResponse, *iamError) {
arn := strings.TrimSpace(values.Get("OpenIDConnectProviderArn"))
if arn == "" {
return nil, &iamError{
Code: iam.ErrCodeInvalidInputException,
Error: fmt.Errorf("OpenIDConnectProviderArn is required"),
}
}
rec, err := mgr.GetOIDCProvider(ctx, arn)
if err != nil {
return nil, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: err}
}
resp := &iamlib.GetOpenIDConnectProviderResponse{}
resp.GetOpenIDConnectProviderResult.Url = rec.URL
resp.GetOpenIDConnectProviderResult.ClientIDList = append([]string(nil), rec.ClientIDs...)
resp.GetOpenIDConnectProviderResult.ThumbprintList = append([]string(nil), rec.Thumbprints...)
if !rec.CreatedAt.IsZero() {
// AWS uses ISO-8601; the IAM XML format accepts time.Time-string output.
resp.GetOpenIDConnectProviderResult.CreateDate = rec.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")
}
if len(rec.Tags) > 0 {
tags := make([]*iamlib.IAMTag, 0, len(rec.Tags))
for k, v := range rec.Tags {
tags = append(tags, &iamlib.IAMTag{Key: k, Value: v})
}
resp.GetOpenIDConnectProviderResult.Tags = tags
}
return resp, nil
}