Files
seaweedfs/weed/iam/integration/oidc_provider_store.go
T
30069f3e45 iam: manage OIDC providers and roles over the filer IAM gRPC service (#11523)
* s3/iam: manage roles through the IAM API, with an opt-in persistent role store

Roles could only come from the IAM config file: the S3 server pinned the
role store to memory and the embedded IAM API had no role actions, so a
role could not be created, retrusted or revoked without editing the file
and restarting every gateway.

Role store
- Read the `roleStore` key (the IAMConfig field already existed). With an
  IAM config file the default stays memory; with none it is the filer, as
  for OIDC providers, so zero-config clusters keep runtime-created roles.
- Roles from the IAM config file never go into a persistent role store,
  which outlives the file and may be shared by S3 servers with different
  files. They are served from memory beneath the store, as OIDC providers
  are: a stored role of the same name takes precedence, and deleting it
  restores the file's. A config-file role cannot be changed or deleted
  through the API (UnmodifiableEntity), and removing one from the file
  removes it at the next start. An in-memory store holds them as records,
  as before. They have no creation time, so CreateDate is omitted rather
  than reporting when this server started. SetRoleStore installs a store
  the same way, so a store set after startup keeps the config-file roles,
  as SetOIDCProviderStore does for providers.
- Watch /etc/iam/roles and drop the cached role definitions on change. The
  cached filer store otherwise serves a peer's stale role for up to its 5m
  TTL, which keeps a revoked trust policy in force on the other gateways.
- Role stores wrap ErrRoleNotFound for a missing role; the filer store
  used to report any failed lookup as "role not found". CreateRole proceeds
  only on a confirmed absence, so an unreadable store cannot let it write
  over an existing role.

IAM actions
- CreateRole, GetRole, ListRoles, DeleteRole, UpdateAssumeRolePolicy,
  AttachRolePolicy, DetachRolePolicy, ListAttachedRolePolicies. The reads
  are allowed in read-only mode.
- A role defined in the config file is reloaded from it at every start, so
  changing or deleting it through the API is refused (UnmodifiableEntity)
  rather than silently reverted.
- DeleteRole with policies attached is refused (DeleteConflict), as on AWS.
- Role names follow AWS's rules ([\w+=,.@-]{1,64}); a role is stored as
  <name>.json in the filer, so this also keeps a name from leaving the role
  store's directory. At most 10 managed policies per role (AWS's default
  quota; MaxManagedPoliciesPerUser is 10 too), LimitExceeded beyond.
- DeletePolicy is refused (DeleteConflict) while a role attaches the
  policy, as it already is for users and groups: roles attach policies by
  name, so a policy created later under the deleted one's name would
  otherwise take effect on the role.
- Role paths other than "/" and role tags are not stored, so they are
  refused rather than dropped.

Role IDs and sessions
- Roles get a unique RoleId when first stored (random, AWS AROA form),
  kept across updates; a config-file role gets a stable ID derived from its
  name, since it is created again at every start.
- Sessions issued through AssumeRoleWithWebIdentity, AssumeRoleWithCredentials
  and AssumeRole carry the role's ID (claim "rid"), and a request under a role
  whose current ID differs is denied. Resolving a session's policies by role
  name let a session outlive its role: once a role was deleted, a role later
  created under the same name — with a different trust policy and different
  policies — revived every unexpired session of the old one with the new
  role's permissions. Sessions issued before this change carry no ID and are
  unaffected until they expire.

Integration test (test/s3/iam, run with `make start-services`):
TestWebIdentityWithProviderAndRoleManagedThroughIAMAPI configures an OIDC
provider, a managed policy and a role entirely through the IAM API against a
JWKS served by the test, then checks the trusted subject gets credentials
scoped to the attached policy; another subject, a token signed by another
key, an unsigned token and a token for another audience are refused; and UpdateAssumeRolePolicy moves the
trust at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: manage OIDC providers and roles over the filer IAM gRPC service

The filer's SeaweedIdentityAccessManagement service covers users, access
keys, policies and service accounts, but not the OIDC providers and roles
that STS web-identity federation needs. A controller that already manages
IAM over this service (seaweedfs-operator's S3OIDCProvider) has no
transport for them; its swadmin client returns ErrOIDCNotWired and names
this as the recommended fix.

- PutOIDCProvider / GetOIDCProvider / DeleteOIDCProvider / ListOIDCProviders
  and PutRole / GetRole / DeleteRole / ListRoles.
- They write the filer-backed stores at their default paths, which S3
  servers read when configured with a filer-typed "oidcProviderStore" and
  "roleStore"; the S3 servers' /etc/iam subscription applies changes
  without a restart.
- Put is an upsert, so a controller can reconcile to it. Deleting a
  provider or role that does not exist returns NotFound, as DeleteUser does
  for a user; clients treat that as already deleted. The provider's account
  ID travels in the request, since the filer does not know the STS
  accountId.
- PutRole applies the IAM API's rules: AWS role names, at most 10 managed
  policies.
- An S3 server serves the roles and providers of its own IAM config file
  ahead of the store, so a stored entry with the same name has no effect
  on that server.
- PutRole keeps a replaced role's RoleId and gives a role created anew a
  fresh one, so sessions of a deleted role do not carry over to a later role
  of the same name.
- DeletePolicy returns FailedPrecondition while a role attaches the policy
  (see the IAM API's DeleteConflict in the previous change). DeletePolicy on
  this service still does not check user attachments, which predates this.
- PutOIDCProvider requires an https issuer (http only for a loopback host):
  STS fetches the issuer's signing keys from it, so over plain HTTP anyone
  on the network path could substitute their own.
- The OIDC provider and role RPCs refuse to run on an unauthenticated
  service (FailedPrecondition until jwt.filer_signing.key is set). Users and
  policies keep the service's opt-in auth, but these grant STS access
  outright: otherwise anyone who can reach the port could register an issuer
  they control, create a role trusting it, and exchange a token for S3
  credentials. The filer's unauthenticated notice becomes a warning that says
  so.
- A store that cannot be read is Unavailable, never "not found", so a Put
  never writes over an entry it could not see.
- Validation is shared with the IAM API through PrepareRoleDefinition and
  PrepareOIDCProviderRecord.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* s3/iam: bind every role session to its role, and change roles atomically

Review follow-ups.

Session binding
- The role-ID check ran only when a session carried no policy names, and
  AssumeRole embeds the role's attached policies, so those sessions kept
  their permissions after the role was deleted or recreated. The check
  now runs for every session carrying a role ID, before policy selection.
- A named role that cannot be resolved at issuance gets no session,
  instead of one with no role ID (which nothing binds).
- A config-file role's ID is derived from its name and trust policy, not
  the name alone: a different role put in the file under the same name
  gets a new ID, while an unchanged role keeps its sessions across restarts.

Role writes
- RoleStore gains UpdateRole, a read-modify-write that lands only if the
  role is unchanged since the read, and otherwise re-reads and retries. The
  filer store uses the filer's write conditions (IF_NOT_EXISTS for a new
  role, IF_ENTRY_EQUAL otherwise). CreateRole, UpdateAssumeRolePolicy and
  Attach/DetachRolePolicy all go through it, so two gateways no longer
  overwrite each other's changes, a change racing a delete no longer
  writes the role back, and of two concurrent creates one gets
  EntityAlreadyExists.
- The filer store's ListRoles pages past 1,000 entries and fails on a
  broken stream instead of returning what arrived, so DeletePolicy's
  attachment check sees every role. ListRoles skips a role deleted between
  listing and reading it.
- CreateRole validates first; a failed write is ServiceFailure, not
  InvalidInput. Any Tags.* parameter is refused, not only the first key.
- ExecuteAction's skipPersist covers the S3ApiConfiguration only; the
  comment now says so. Role and OIDC provider actions write their own stores.

Each fix has a test that fails without it. Against a real filer with two
gateways, concurrent AttachRolePolicy calls lost 1-4 of 8 attachments per
run before this change and none after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: PutRole changes roles atomically and checks its ARN; https issuers' keys stay on https

Review follow-ups on top of the role-store changes.

- PutRole goes through RoleStore.UpdateRole, so the decision to keep an
  existing role's ID or mint a new one is made against the role as it is
  when written. A PutRole racing a DeleteRole can no longer write the
  deleted role back with its old ID, which would revive its sessions. A
  failed store read or write is Unavailable.
- PutRole refuses a role_arn that does not name the role: STS resolves a
  role by the name in the ARN it is given.
- PutOIDCProvider requires an https issuer, but discovery could still name
  a plain-http jwks_uri, and a key fetch could be redirected to http. For
  an https issuer, a non-https jwks_uri from discovery is refused (the
  issuer's own /.well-known/jwks.json is used instead), and the client
  that fetches discovery and keys refuses any https-to-http redirect. An
  operator-set jwksUri is left as configured.

Each has a test that fails without its guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* s3/iam: one role snapshot per decision; DeleteRole is atomic; watch a custom role store path

Review follow-ups.

- Authorization evaluates the policies of the role definition the session's
  binding was checked against, instead of reading the role again: a role
  replaced in between cannot lend a session its policies.
- AssumeRole and AssumeRoleWithLDAPIdentity issue the session from the
  definition whose trust admits the caller (IAMManager.ResolveRoleForPrincipal),
  and take its ID, duration cap and embedded policies from that same
  definition. A role replaced after the caller's trust check by one that does
  not trust the caller now yields AccessDenied, not a session bound to the
  replacement.
- A RoleUpdate that returns nil deletes the role, on the same condition as a
  write: the filer store deletes with ObjectTransaction on IF_ENTRY_EQUAL,
  routed and locked like the conditional CreateEntry. DeleteRole decides
  against the role it deletes, so a policy attached meanwhile on another
  server is a DeleteConflict, and a delete never removes a role written
  after its check.
- S3 servers watch the role store's configured basePath, not only
  /etc/iam/roles, so a custom path also drops peers' cached roles on change.

Each has a test that fails without it. Live against a real filer: DeleteRole
refuses while a policy is attached and removes the entry once detached; all
test/s3/iam CI stages pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* s3/iam: state which roles DeletePolicy's attachment check can see

RolesAttachingPolicy sees the stored roles and this server's config-file
roles. A role defined only in another server's IAM config file is invisible
to it, so a config-file role that attaches a managed policy is protected
only on the servers whose file defines it. The doc comment now says so and
how to avoid it: keep such roles in every server's file, or attach only
config-file policies to config-file roles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: note that a role store set after startup is not watched for peer changes

S3 servers build their metadata watch list once, at startup, from the role
store installed then. SetRoleStore's doc now says that a filer-backed store
installed later with a different basePath is not watched, so peers' changes
to it reach this server's cached roles only when the cache expires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: DeleteRole deletes only the role it saw; issuer URLs are bare

Review follow-ups.

- The filer IAM service's DeleteRole looked the role up, then deleted by
  name, so a PutRole landing in between had its new definition deleted. It
  now deletes through RoleStore.UpdateRole, conditional on the entry it
  read. If the role was replaced meanwhile, it returns Aborted rather than
  deleting the replacement, and the caller decides again.
- PutOIDCProvider refuses an issuer URL with userinfo, a query or a
  fragment. The provider's ARN comes from host and path alone, while STS
  matches a token's iss claim against the stored URL exactly, so such a
  provider shared the bare issuer's ARN and matched no token. A loopback
  "localhost" is now matched without regard to case.

Both have tests that fail without them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* iam: write OIDC providers atomically over the filer IAM gRPC service

PutOIDCProvider read the record, then stored unconditionally; a racing
DeleteOIDCProvider left the put's stale read merged into the rewritten
record. DeleteOIDCProvider read, then deleted unconditionally; a racing
PutOIDCProvider's newer record could be removed instead. These are the
races the role RPCs closed with UpdateRole.

OIDCProviderStore gains UpdateProvider with the same contract: memory
under its lock, filer as a conditional write (IF_ENTRY_EQUAL /
IF_NOT_EXISTS) or conditional delete retrying a changed entry.
PutOIDCProvider merges the fields the request cannot carry against the
record as it is written; DeleteOIDCProvider aborts rather than delete a
record replaced meanwhile.

isRoleWriteConflict is renamed isEntryWriteConflict — the conditional-
write check is shared by both stores now.

* iam: guard PutRole against a nil credential manager, fix its doc comment

PutRole read attached policies through s.credentialManager without the
nil check its sibling handlers make, so a server built without one
panicked on a PutRole naming a policy. It now fails the call as
FailedPrecondition like the others.

The doc comment also had the store/static precedence backwards: a stored
role shadows a same-named config-file role (as the overlay serves it),
not the other way around.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-10-03 08:36:19 +08:00

648 lines
23 KiB
Go

package integration
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers
// in the s3api layer use errors.Is rather than substring-matching the
// formatted message, so the message can be edited freely without changing
// the IAM error code surfaced to the API caller.
var (
ErrOIDCProviderNotFound = errors.New("OIDC provider not found")
ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists")
// ErrOIDCProviderStatic refuses a change to a provider defined in the
// server's IAM config file: change it there instead.
ErrOIDCProviderStatic = errors.New("OIDC provider is defined in the IAM config file")
)
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
// provider. It is the source of truth consulted at AssumeRoleWithWebIdentity
// time. Static configuration entries are loaded into this same store at
// startup so the resolution path is uniform.
type OIDCProviderRecord struct {
// AccountID scopes the record. Empty means "global" — usable from any
// account. Non-empty records are only resolvable when the assuming
// caller's RoleArn lives in the same account. Phase 2a leaves this
// field unenforced; Phase 3c lights up the cross-account check.
AccountID string `json:"accountId,omitempty"`
// ARN is the canonical IAM identifier for the provider:
// arn:aws:iam::<account>:oidc-provider/<host>/<path>.
ARN string `json:"arn"`
// URL is the issuer URL (no trailing slash), e.g.
// https://token.actions.githubusercontent.com.
URL string `json:"url"`
// ClientIDs are the audiences AWS calls "client IDs". A token's `aud` or
// `azp` must match one of these for the token to be accepted.
ClientIDs []string `json:"clientIds,omitempty"`
// Thumbprints are SHA-1 hex digests of the IDP's TLS certificate, matching
// the AWS-compatible thumbprint algorithm. Used at JWKS-fetch time when
// non-empty; an empty list means "trust the system root store".
Thumbprints []string `json:"thumbprints,omitempty"`
// AllowedPrincipalTagKeys is the per-provider ABAC allowlist for the
// `https://aws.amazon.com/tags/principal_tags` claim namespace. Empty
// means no tags are surfaced from this provider.
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
// PolicyClaim, when non-empty, enables claim-based policy mode for this
// provider: tokens whose RoleArn matches the configured sentinel will
// derive their effective policies from this JWT claim.
PolicyClaim string `json:"policyClaim,omitempty"`
// Tags is the AWS-style tag set attached to the IAM resource itself
// (audit/inventory metadata, not propagated into sessions).
Tags map[string]string `json:"tags,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
// expected to be safe for concurrent use.
type OIDCProviderStore interface {
StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error
GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error)
GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error)
GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error)
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
// UpdateProvider replaces a provider's record with update's result,
// atomically: the write or delete happens only against the record update
// saw, so a change in between applies update again to what the other
// writer left.
UpdateProvider(ctx context.Context, filerAddress string, arn string, update OIDCProviderUpdate) error
}
// OIDCProviderUpdate computes a provider's new record from its current one,
// nil when the provider does not exist. It returns nil to delete the
// provider, and an error to leave it unchanged. It may run more than once: it
// is called again with the fresh record when another writer changed the
// provider in between.
type OIDCProviderUpdate func(current *OIDCProviderRecord) (*OIDCProviderRecord, error)
// maxProviderUpdateAttempts bounds UpdateProvider's retries under contention.
const maxProviderUpdateAttempts = 10
// errProviderUpdateContended is returned when the provider kept changing
// under UpdateProvider for maxProviderUpdateAttempts reads.
var errProviderUpdateContended = errors.New("OIDC provider changed concurrently; retry")
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
// single-node deployments. It also acts as the in-memory cache hydrated from
// static config at boot.
type MemoryOIDCProviderStore struct {
mu sync.RWMutex
providers map[string]*OIDCProviderRecord // keyed by ARN
}
// NewMemoryOIDCProviderStore creates an empty in-memory store.
func NewMemoryOIDCProviderStore() *MemoryOIDCProviderStore {
return &MemoryOIDCProviderStore{providers: make(map[string]*OIDCProviderRecord)}
}
// StoreProvider replaces any existing record with the same ARN.
func (m *MemoryOIDCProviderStore) StoreProvider(ctx context.Context, _ string, record *OIDCProviderRecord) error {
if record == nil {
return fmt.Errorf("record cannot be nil")
}
if record.ARN == "" {
return fmt.Errorf("record.ARN is required")
}
m.mu.Lock()
defer m.mu.Unlock()
m.providers[record.ARN] = copyOIDCProviderRecord(record)
return nil
}
// GetProviderByARN returns the record with the given ARN, or an error if absent.
func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string, arn string) (*OIDCProviderRecord, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.providers[arn]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
}
return copyOIDCProviderRecord(rec), nil
}
// GetProviderByIssuer scans for the first record whose URL matches `issuer`.
// Comparison strips trailing slashes and is case-insensitive on host.
func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ string, issuer string) (*OIDCProviderRecord, error) {
want := normalizeIssuer(issuer)
m.mu.RLock()
defer m.mu.RUnlock()
for _, rec := range m.providers {
if normalizeIssuer(rec.URL) == want {
return copyOIDCProviderRecord(rec), nil
}
}
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
}
// GetProviderByIssuerAndAccount returns the record matching `issuer` whose
// AccountID is empty (global) or equals `accountID`. AWS-style account
// scoping: cross-account references must go through a shared/global
// provider, which is why an empty AccountID is treated as a wildcard.
//
// `accountID` may itself be empty when the caller has no specific account
// context (e.g. claim-based mode); in that case any registered provider
// matching the issuer is acceptable.
func (m *MemoryOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, _ string, issuer, accountID string) (*OIDCProviderRecord, error) {
want := normalizeIssuer(issuer)
m.mu.RLock()
defer m.mu.RUnlock()
for _, rec := range m.providers {
if normalizeIssuer(rec.URL) != want {
continue
}
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
return copyOIDCProviderRecord(rec), nil
}
}
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
}
// ListProviders returns every record sorted by ARN for stable output.
func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*OIDCProviderRecord, 0, len(m.providers))
for _, rec := range m.providers {
out = append(out, copyOIDCProviderRecord(rec))
}
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
return out, nil
}
// DeleteProvider removes the record. Idempotent — deleting a missing ARN is success.
func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string, arn string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.providers, arn)
return nil
}
// UpdateProvider applies update under the store's lock (filerAddress ignored
// for the memory store).
func (m *MemoryOIDCProviderStore) UpdateProvider(ctx context.Context, _ string, arn string, update OIDCProviderUpdate) error {
if arn == "" {
return fmt.Errorf("ARN is required")
}
m.mu.Lock()
defer m.mu.Unlock()
next, err := update(copyOIDCProviderRecord(m.providers[arn]))
if err != nil {
return err
}
if next == nil {
delete(m.providers, arn)
return nil
}
next.ARN = arn
m.providers[arn] = copyOIDCProviderRecord(next)
return nil
}
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
// mirroring FilerRoleStore.
type FilerOIDCProviderStore struct {
grpcDialOption grpc.DialOption
basePath string
filerAddressProvider func() string
}
// NewFilerOIDCProviderStore returns a filer-backed store. Default path
// `/etc/iam/oidc-providers` aligns with the existing roles directory.
func NewFilerOIDCProviderStore(config map[string]interface{}, filerAddressProvider func() string) *FilerOIDCProviderStore {
store := &FilerOIDCProviderStore{
basePath: "/etc/iam/oidc-providers",
filerAddressProvider: filerAddressProvider,
}
if config != nil {
if bp, ok := config["basePath"].(string); ok && bp != "" {
store.basePath = strings.TrimSuffix(bp, "/")
}
}
glog.V(2).Infof("Initialized FilerOIDCProviderStore with basePath %s", store.basePath)
return store
}
func (f *FilerOIDCProviderStore) resolveFilerAddress(filerAddress string) string {
if filerAddress != "" {
return filerAddress
}
if f.filerAddressProvider != nil {
return f.filerAddressProvider()
}
return ""
}
func (f *FilerOIDCProviderStore) fileName(arn string) string {
// Hash the ARN to a fixed-width filename so we can store any character set
// (including the URL fragments AWS lets through) without filer-path issues.
h := sha1.Sum([]byte(arn))
return hex.EncodeToString(h[:]) + ".json"
}
// StoreProvider persists `record` as JSON.
func (f *FilerOIDCProviderStore) StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return fmt.Errorf("filer address is required")
}
if record == nil || record.ARN == "" {
return fmt.Errorf("record with ARN is required")
}
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return fmt.Errorf("marshal OIDC provider: %v", err)
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: f.basePath,
Entry: &filer_pb.Entry{
Name: f.fileName(record.ARN),
IsDirectory: false,
Attributes: &filer_pb.FuseAttributes{
Mtime: time.Now().Unix(),
Crtime: time.Now().Unix(),
FileMode: uint32(0o600),
},
Content: data,
},
})
if err != nil {
return fmt.Errorf("store OIDC provider %s: %v", record.ARN, err)
}
return nil
})
}
// GetProviderByARN looks up the record file by its ARN-derived filename.
func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return nil, fmt.Errorf("filer address is required")
}
var data []byte
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
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("lookup OIDC provider %s: %w", arn, err)
}
if resp.Entry == nil {
return fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
}
data = resp.Entry.Content
return nil
})
if err != nil {
return nil, err
}
var rec OIDCProviderRecord
if err := json.Unmarshal(data, &rec); err != nil {
return nil, fmt.Errorf("unmarshal OIDC provider: %v", err)
}
return &rec, nil
}
// GetProviderByIssuer linearly scans the directory. Phase 2b adds an issuer
// index when write throughput grows; for read-only this is good enough.
func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error) {
records, err := f.ListProviders(ctx, filerAddress)
if err != nil {
return nil, err
}
want := normalizeIssuer(issuer)
for _, rec := range records {
if normalizeIssuer(rec.URL) == want {
return rec, nil
}
}
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
}
// GetProviderByIssuerAndAccount filters by AccountID on top of the issuer scan.
// See the MemoryOIDCProviderStore equivalent for semantics.
func (f *FilerOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error) {
records, err := f.ListProviders(ctx, filerAddress)
if err != nil {
return nil, err
}
want := normalizeIssuer(issuer)
for _, rec := range records {
if normalizeIssuer(rec.URL) != want {
continue
}
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
return rec, nil
}
}
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
}
// ListProviders enumerates every record in the directory.
func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return nil, fmt.Errorf("filer address is required")
}
var out []*OIDCProviderRecord
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
// Stream-paginate via StartFromFileName so deployments with more
// than 1000 providers don't get a silently truncated list.
const pageSize = 1000
startFrom := ""
for {
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
Directory: f.basePath,
Limit: pageSize,
StartFromFileName: startFrom,
InclusiveStartFrom: false,
})
if err != nil {
return fmt.Errorf("list OIDC providers: %v", err)
}
lastName := ""
pageCount := 0
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if errors.Is(recvErr, io.EOF) {
break
}
return fmt.Errorf("recv OIDC provider entry: %w", recvErr)
}
if resp.Entry == nil || resp.Entry.IsDirectory {
continue
}
lastName = resp.Entry.Name
pageCount++
if !strings.HasSuffix(resp.Entry.Name, ".json") {
continue
}
var rec OIDCProviderRecord
if err := json.Unmarshal(resp.Entry.Content, &rec); err != nil {
glog.Warningf("skipping malformed OIDC provider record %s: %v", resp.Entry.Name, err)
continue
}
out = append(out, &rec)
}
if pageCount < pageSize {
break
}
startFrom = lastName
}
return nil
})
if err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
return out, nil
}
// DeleteProvider removes the record. Missing ARN is treated as success.
func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddress string, arn string) error {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return fmt.Errorf("filer address is required")
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
IsDeleteData: true,
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
return nil
}
return fmt.Errorf("delete OIDC provider %s: %v", arn, err)
}
if resp.Error != "" && !strings.Contains(resp.Error, "not found") {
return fmt.Errorf("delete OIDC provider %s: %s", arn, resp.Error)
}
return nil
})
}
// UpdateProvider reads the provider's entry, applies update, and writes the
// result on the condition that the entry is unchanged since the read —
// absent, when the provider did not exist — so the filer refuses a write
// racing another writer's change or delete, and update is applied again to
// what that writer left. A delete is made on the same condition, so it
// removes the record update saw and not one written after it.
func (f *FilerOIDCProviderStore) UpdateProvider(ctx context.Context, filerAddress string, arn string, update OIDCProviderUpdate) error {
filerAddress = f.resolveFilerAddress(filerAddress)
if filerAddress == "" {
return fmt.Errorf("filer address is required")
}
if arn == "" {
return fmt.Errorf("ARN is required")
}
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
for attempt := 0; attempt < maxProviderUpdateAttempts; attempt++ {
var entry *filer_pb.Entry
var current *OIDCProviderRecord
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: f.basePath,
Name: f.fileName(arn),
})
switch {
case errors.Is(err, filer_pb.ErrNotFound):
case err != nil:
return fmt.Errorf("lookup OIDC provider %s: %w", arn, err)
case resp.Entry != nil:
entry = resp.Entry
current = &OIDCProviderRecord{}
if err := json.Unmarshal(entry.Content, current); err != nil {
return fmt.Errorf("failed to deserialize OIDC provider %s: %v", arn, err)
}
}
next, err := update(current)
if err != nil {
return err
}
if next == nil {
if entry == nil {
return nil
}
deleted, err := f.deleteProviderEntryIfUnchanged(ctx, client, entry)
if err != nil {
return fmt.Errorf("failed to delete OIDC provider %s: %w", arn, err)
}
if !deleted {
glog.V(3).Infof("OIDC provider %s changed before its delete; retrying", arn)
continue
}
return nil
}
next.ARN = arn
data, err := json.MarshalIndent(next, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize OIDC provider %s: %v", arn, err)
}
clause := &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_NOT_EXISTS}
if entry != nil {
clause = &filer_pb.WriteCondition_Clause{Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry}
}
now := time.Now().Unix()
created, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
Directory: f.basePath,
Entry: &filer_pb.Entry{
Name: f.fileName(arn),
Attributes: &filer_pb.FuseAttributes{
Mtime: now,
Crtime: now,
FileMode: uint32(0600),
},
Content: data,
},
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{clause}},
})
if isEntryWriteConflict(created, err) {
glog.V(3).Infof("OIDC provider %s changed during update; retrying", arn)
continue
}
if err != nil {
return fmt.Errorf("failed to store OIDC provider %s: %v", arn, err)
}
if created.Error != "" {
return fmt.Errorf("failed to store OIDC provider %s: %s", arn, created.Error)
}
return nil
}
return fmt.Errorf("update OIDC provider %s: %w", arn, errProviderUpdateContended)
})
}
// deleteProviderEntryIfUnchanged deletes the provider's entry if it still
// equals entry, reporting false when it changed. The delete is routed and
// locked as the conditional CreateEntry of the same path is, so the two
// serialize.
func (f *FilerOIDCProviderStore) deleteProviderEntryIfUnchanged(ctx context.Context, client filer_pb.SeaweedFilerClient, entry *filer_pb.Entry) (bool, error) {
fullPath := f.basePath + "/" + entry.Name
resp, err := client.ObjectTransaction(ctx, &filer_pb.ObjectTransactionRequest{
LockKey: fullPath,
RouteKey: s3_constants.ObjectWriteRouteKeyPrefix + fullPath,
Condition: &filer_pb.WriteCondition{Clauses: []*filer_pb.WriteCondition_Clause{{
Kind: filer_pb.WriteCondition_IF_ENTRY_EQUAL, ExpectedEntry: entry,
}}},
Mutations: []*filer_pb.ObjectMutation{{
Type: filer_pb.ObjectMutation_DELETE, Directory: f.basePath, Name: entry.Name, IsDeleteData: true,
}},
})
if err != nil {
if status.Code(err) == codes.FailedPrecondition {
return false, nil
}
return false, err
}
if resp.ErrorCode == filer_pb.FilerError_PRECONDITION_FAILED {
return false, nil
}
if resp.Error != "" {
return false, errors.New(resp.Error)
}
return true, nil
}
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
}
// DeriveOIDCProviderARN turns an issuer URL into the canonical IAM ARN AWS uses.
// Empty `accountID` produces a global-style ARN.
//
// Examples:
//
// https://accounts.google.com -> arn:aws:iam::<acct>:oidc-provider/accounts.google.com
// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED ->
// arn:aws:iam::<acct>:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED
func DeriveOIDCProviderARN(accountID, issuerURL string) (string, error) {
if issuerURL == "" {
return "", fmt.Errorf("issuer URL is required")
}
u, err := url.Parse(issuerURL)
if err != nil || u.Host == "" {
return "", fmt.Errorf("invalid issuer URL: %s", issuerURL)
}
host := strings.ToLower(u.Host)
resource := host + strings.TrimSuffix(u.Path, "/")
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, resource), nil
}
// normalizeIssuer compares-friendly form: lowercased host, no trailing slash.
func normalizeIssuer(issuer string) string {
u, err := url.Parse(issuer)
if err != nil || u.Host == "" {
return strings.TrimSuffix(strings.ToLower(issuer), "/")
}
u.Host = strings.ToLower(u.Host)
u.Path = strings.TrimSuffix(u.Path, "/")
return u.String()
}
func copyOIDCProviderRecord(rec *OIDCProviderRecord) *OIDCProviderRecord {
if rec == nil {
return nil
}
cp := *rec
if rec.ClientIDs != nil {
cp.ClientIDs = append([]string(nil), rec.ClientIDs...)
}
if rec.Thumbprints != nil {
cp.Thumbprints = append([]string(nil), rec.Thumbprints...)
}
if rec.AllowedPrincipalTagKeys != nil {
cp.AllowedPrincipalTagKeys = append([]string(nil), rec.AllowedPrincipalTagKeys...)
}
if rec.Tags != nil {
cp.Tags = make(map[string]string, len(rec.Tags))
for k, v := range rec.Tags {
cp.Tags[k] = v
}
}
return &cp
}