Files
seaweedfs/weed/iam/integration/iam_manager.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

2179 lines
79 KiB
Go

package integration
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// maxPoliciesForEvaluation defines an upper bound on the number of policies that
// will be evaluated for a single request. This protects against pathological or
// malicious inputs that attempt to create extremely large policy lists.
const maxPoliciesForEvaluation = 1024
// IAMManager orchestrates all IAM components
type IAMManager struct {
stsService *sts.STSService
policyEngine *policy.PolicyEngine
roleStore RoleStore
userStore UserStore
oidcProviderStore OIDCProviderStore
// staticOIDCProviders are the OIDC providers of this server's IAM config
// file, by ARN. With an in-memory store they are also written to the
// store; a persistent store never holds them (see installOIDCProviderStore).
staticOIDCProviders map[string]*OIDCProviderRecord
// staticRoles are the roles of this server's IAM config file, by name, once
// LoadStaticRoles has run (see installRoleStore).
staticRoles map[string]*RoleDefinition
// oidcRetryMu guards the background refresh retry and which store is
// current: cancelOIDCLoad stops the retry, oidcRetryGen names the one
// running (0 when none) so at most one runs, and oidcRetryAgain records a
// refresh that failed while it ran, so the retry runs once more.
oidcRetryMu sync.Mutex
cancelOIDCLoad context.CancelFunc
oidcRetryGen uint64
oidcRetrySeq uint64
oidcRetryAgain bool
// oidcRefreshMu serializes refreshes from reading the store to handing
// STS the result, so an older snapshot cannot replace a newer one.
oidcRefreshMu sync.Mutex
oidcAuditSink OIDCProviderAuditSink
revocationStore SessionRevocationStore
filerAddressProvider func() string // Function to get current filer address
initialized bool
runtimePolicyMu sync.Mutex
runtimePolicyNames map[string]struct{}
}
// SetOIDCProviderAuditSink configures the lifecycle event sink. When nil
// (default), GlogAuditSink is used so events still surface in logs.
func (m *IAMManager) SetOIDCProviderAuditSink(sink OIDCProviderAuditSink) {
m.oidcAuditSink = sink
}
// emitOIDCAudit logs a lifecycle event. Errors are swallowed: an audit
// failure must never block an IAM mutation that has already succeeded.
func (m *IAMManager) emitOIDCAudit(ctx context.Context, eventType OIDCProviderAuditEventType, arn, url string, detail map[string]string) {
sink := m.oidcAuditSink
if sink == nil {
sink = GlogAuditSink{}
}
event := &OIDCProviderAuditEvent{
Type: eventType,
ARN: arn,
URL: url,
Detail: detail,
OccurredAt: time.Now().UTC(),
}
if err := sink.Emit(ctx, event); err != nil {
glog.Warningf("OIDC audit emit %s for %s: %v", eventType, arn, err)
}
}
// SetSessionRevocationStore configures the per-session revocation list. When
// nil, RevokeSession returns an error and IsSessionRevoked is a no-op (every
// session is considered live until natural expiry). Operators who want
// revocation must wire a store explicitly.
func (m *IAMManager) SetSessionRevocationStore(store SessionRevocationStore) {
m.revocationStore = store
}
// RevokeSession marks a session as revoked using its JTI (which equals the
// session ID for STS-issued tokens).
func (m *IAMManager) RevokeSession(ctx context.Context, jti string, expiresAt time.Time, reason string) error {
if m.revocationStore == nil {
return fmt.Errorf("session revocation store not configured")
}
if jti == "" {
return fmt.Errorf("jti cannot be empty")
}
return m.revocationStore.Revoke(ctx, m.getFilerAddress(), &RevocationEntry{
JTI: jti,
ExpiresAt: expiresAt,
Reason: reason,
})
}
// IsSessionRevoked returns true if the given JTI has been revoked. Returns
// false (with nil error) when no revocation store is configured.
func (m *IAMManager) IsSessionRevoked(ctx context.Context, jti string) (bool, error) {
if m.revocationStore == nil || jti == "" {
return false, nil
}
return m.revocationStore.IsRevoked(ctx, m.getFilerAddress(), jti)
}
// PurgeRevokedSessions removes revocation entries whose underlying session
// has already expired. Safe to call on a cron schedule.
func (m *IAMManager) PurgeRevokedSessions(ctx context.Context) (int, error) {
if m.revocationStore == nil {
return 0, nil
}
return m.revocationStore.Purge(ctx, m.getFilerAddress(), time.Now().UTC())
}
// SetOIDCProviderStore configures the IAM-managed OIDC provider store. When
// nil, OIDC provider IAM actions return ServiceNotReady.
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
var stsConfig *sts.STSConfig
if m.stsService != nil {
stsConfig = m.stsService.Config
}
m.installOIDCProviderStore(store, stsConfig)
}
// GetOIDCProviderStore returns the configured store (may be nil).
func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
return m.oidcProviderStore
}
// GetRoleStore returns the configured role store.
func (m *IAMManager) GetRoleStore() RoleStore {
return m.roleStore
}
// SetRoleStore replaces the role store. An S3 server builds the list of
// directories it watches for peers' changes once, at startup, from the store
// installed then (RoleStoreDirectory): 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.
func (m *IAMManager) SetRoleStore(store RoleStore) {
m.installRoleStore(context.Background(), store)
}
// GetOIDCProvider returns the record for the given ARN, or an error if the
// store is not configured or the record is missing.
func (m *IAMManager) GetOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
if m.oidcProviderStore == nil {
return nil, fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if errors.Is(err, ErrOIDCProviderNotFound) {
if static, ok := m.staticOIDCProviders[arn]; ok {
return copyOIDCProviderRecord(static), nil
}
}
return rec, err
}
// ListOIDCProviders enumerates all configured OIDC providers.
func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderRecord, error) {
if m.oidcProviderStore == nil {
return nil, fmt.Errorf("OIDC provider store not configured")
}
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return nil, err
}
// A persistent store does not hold the config file's providers.
seen := make(map[string]bool, len(records))
for _, rec := range records {
seen[rec.ARN] = true
}
for arn, static := range m.staticOIDCProviders {
if !seen[arn] {
records = append(records, copyOIDCProviderRecord(static))
}
}
return records, nil
}
// mutableOIDCProvider loads a stored provider for a change. A provider only
// the IAM config file defines is refused with ErrOIDCProviderStatic.
func (m *IAMManager) mutableOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
if errors.Is(err, ErrOIDCProviderNotFound) {
if _, ok := m.staticOIDCProviders[arn]; ok {
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderStatic, arn)
}
}
return rec, err
}
// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses
// to overwrite an existing record so callers see EntityAlreadyExists semantics.
func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRecord) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
if rec == nil {
return fmt.Errorf("record cannot be nil")
}
if err := validateOIDCProviderRecord(rec); err != nil {
return err
}
if _, static := m.staticOIDCProviders[rec.ARN]; static {
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
}
existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN)
if err == nil && existing != nil {
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
}
if err != nil && !errors.Is(err, ErrOIDCProviderNotFound) {
return fmt.Errorf("lookup existing OIDC provider %q: %w", rec.ARN, err)
}
now := time.Now().UTC()
rec.CreatedAt = now
rec.UpdatedAt = now
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "CreateOIDCProvider", rec.ARN)
m.emitOIDCAudit(ctx, OIDCAuditEventCreated, rec.ARN, rec.URL, nil)
return nil
}
// DeleteOIDCProvider removes the IAM-managed record. Idempotent.
func (m *IAMManager) DeleteOIDCProvider(ctx context.Context, arn string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
if _, err := m.mutableOIDCProvider(ctx, arn); errors.Is(err, ErrOIDCProviderStatic) {
return err
}
if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "DeleteOIDCProvider", arn)
m.emitOIDCAudit(ctx, OIDCAuditEventDeleted, arn, "", nil)
return nil
}
// AddClientIDToOIDCProvider appends `clientID` to the provider's allowed
// audience list. Adding an existing client ID is a no-op (AWS-compat).
func (m *IAMManager) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
if clientID == "" {
return fmt.Errorf("ClientID cannot be empty")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
if err != nil {
return err
}
for _, existing := range rec.ClientIDs {
if existing == clientID {
return nil // idempotent
}
}
if len(rec.ClientIDs) >= 100 {
return fmt.Errorf("ClientIDList must contain at most 100 entries")
}
rec.ClientIDs = append(rec.ClientIDs, clientID)
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "AddClientIDToOIDCProvider", arn)
m.emitOIDCAudit(ctx, OIDCAuditEventClientIDAdded, rec.ARN, rec.URL, map[string]string{"clientId": clientID})
return nil
}
// RemoveClientIDFromOIDCProvider drops `clientID` from the allowed audience
// list. Removing a missing client ID is a no-op.
func (m *IAMManager) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
if err != nil {
return err
}
pruned := make([]string, 0, len(rec.ClientIDs))
for _, existing := range rec.ClientIDs {
if existing != clientID {
pruned = append(pruned, existing)
}
}
if len(pruned) == len(rec.ClientIDs) {
return nil // not present; no-op
}
rec.ClientIDs = pruned
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "RemoveClientIDFromOIDCProvider", arn)
m.emitOIDCAudit(ctx, OIDCAuditEventClientIDRemoved, rec.ARN, rec.URL, map[string]string{"clientId": clientID})
return nil
}
// UpdateOIDCProviderThumbprints replaces the entire thumbprint list. AWS
// constrains the list to 1..5 entries when non-empty; we mirror that bound.
func (m *IAMManager) UpdateOIDCProviderThumbprints(ctx context.Context, arn string, thumbprints []string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
if len(thumbprints) > 5 {
return fmt.Errorf("ThumbprintList must contain at most 5 entries, got %d", len(thumbprints))
}
for _, tp := range thumbprints {
if !isValidSHA1Thumbprint(tp) {
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
}
}
rec, err := m.mutableOIDCProvider(ctx, arn)
if err != nil {
return err
}
rec.Thumbprints = append([]string(nil), thumbprints...)
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.refreshOIDCProvidersBestEffort(ctx, "UpdateOIDCProviderThumbprints", arn)
m.emitOIDCAudit(ctx, OIDCAuditEventThumbprintsSet, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(thumbprints))})
return nil
}
// TagOIDCProvider merges the supplied tags into the provider's tag set.
func (m *IAMManager) TagOIDCProvider(ctx context.Context, arn string, tags map[string]string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
if err != nil {
return err
}
if rec.Tags == nil {
rec.Tags = make(map[string]string, len(tags))
}
for k, v := range tags {
rec.Tags[k] = v
}
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.emitOIDCAudit(ctx, OIDCAuditEventTagsAdded, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(tags))})
return nil
}
// UntagOIDCProvider removes the named tags from the provider's tag set.
func (m *IAMManager) UntagOIDCProvider(ctx context.Context, arn string, keys []string) error {
if m.oidcProviderStore == nil {
return fmt.Errorf("OIDC provider store not configured")
}
rec, err := m.mutableOIDCProvider(ctx, arn)
if err != nil {
return err
}
for _, k := range keys {
delete(rec.Tags, k)
}
rec.UpdatedAt = time.Now().UTC()
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
return err
}
m.emitOIDCAudit(ctx, OIDCAuditEventTagsRemoved, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(keys))})
return nil
}
// validateOIDCProviderRecord enforces the invariants AWS imposes on the
// underlying CreateOpenIDConnectProvider call.
func validateOIDCProviderRecord(rec *OIDCProviderRecord) error {
if rec.URL == "" {
return fmt.Errorf("Url is required")
}
if rec.ARN == "" {
return fmt.Errorf("ARN is required")
}
if len(rec.ClientIDs) == 0 {
return fmt.Errorf("ClientIDList must contain at least one entry")
}
if len(rec.ClientIDs) > 100 {
return fmt.Errorf("ClientIDList must contain at most 100 entries")
}
if len(rec.Thumbprints) > 5 {
return fmt.Errorf("ThumbprintList must contain at most 5 entries")
}
for _, tp := range rec.Thumbprints {
if !isValidSHA1Thumbprint(tp) {
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
}
}
return nil
}
// isValidSHA1Thumbprint returns true iff `s` is exactly 40 hex characters,
// matching the SHA-1 digest format AWS expects.
func isValidSHA1Thumbprint(s string) bool {
if len(s) != 40 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// IAMConfig holds configuration for all IAM components
type IAMConfig struct {
// STS service configuration
STS *sts.STSConfig `json:"sts"`
// Policy engine configuration
Policy *policy.PolicyEngineConfig `json:"policy"`
// Role store configuration
Roles *RoleStoreConfig `json:"roleStore"`
// OIDCProviders configures the IAM-managed OIDC provider store. Optional;
// if absent the manager defaults to an in-memory store hydrated from
// STS.Providers at boot.
OIDCProviders *OIDCProviderStoreConfig `json:"oidcProviderStore,omitempty"`
}
// OIDCProviderStoreConfig holds OIDC provider store configuration.
type OIDCProviderStoreConfig struct {
StoreType string `json:"storeType"` // memory, filer
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
}
// RoleStoreConfig holds role store configuration
type RoleStoreConfig struct {
// StoreType specifies the role store backend (memory, filer, etc.)
StoreType string `json:"storeType"`
// StoreConfig contains store-specific configuration
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
}
// UserStore defines the interface for retrieving IAM user policy attachments.
type UserStore interface {
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
}
// RoleDefinition defines a role with its trust policy and attached policies
type RoleDefinition struct {
// RoleName is the name of the role
RoleName string `json:"roleName"`
// RoleArn is the full ARN of the role
RoleArn string `json:"roleArn"`
// TrustPolicy defines who can assume this role
TrustPolicy *policy.PolicyDocument `json:"trustPolicy"`
// AttachedPolicies lists the policy names attached to this role
AttachedPolicies []string `json:"attachedPolicies"`
// Description is an optional description of the role
Description string `json:"description,omitempty"`
// MaxSessionDuration is the upper bound (in seconds) on session length when
// callers assume this role. Zero means "use the global STS default". When
// set it must satisfy AWS bounds: 3600 ≤ MaxSessionDuration ≤ 43200.
// Honoured by AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithCredentials.
MaxSessionDuration int64 `json:"maxSessionDuration,omitempty"`
// Source records where the role came from. RoleSourceStaticConfig marks a
// role loaded from the IAM config file; empty means it was created at
// runtime. Only static-config roles are pruned when they leave the file.
Source string `json:"source,omitempty"`
// CreatedAt is when the role was created through the IAM API. Zero for
// roles loaded from the config file.
CreatedAt time.Time `json:"createdAt,omitempty"`
// RoleId uniquely identifies this role, as AWS's RoleId does. A role
// deleted and created again under the same name gets a new ID, and a
// session is honoured only while the role it was issued for still has
// the ID the session carries — so a session outlives neither the role's
// deletion nor a later role that reuses its name.
RoleId string `json:"roleId,omitempty"`
}
// NewRoleID returns a fresh, random role ID in AWS's AROA form.
func NewRoleID() string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
b := make([]byte, 17)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("read random role id: %v", err))
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "AROA" + string(b)
}
// StaticRoleID is the ID of a role defined in the IAM config file. Such a
// role is created again at every start, so its ID is derived rather than
// random, to keep sessions valid across restarts. It is derived from the name
// and the trust policy together: a role removed from the file and replaced by
// a different one under the same name trusts different principals, and must
// not inherit the old role's sessions. Restoring the same role restores its ID.
func StaticRoleID(role *RoleDefinition) string {
h := sha256.New()
h.Write([]byte("static-role:" + role.RoleName + "\x00"))
if role.TrustPolicy != nil {
trust, err := json.Marshal(role.TrustPolicy)
if err != nil {
// A trust policy that cannot be encoded cannot be matched on
// either; give the role an ID no session can carry.
return NewRoleID()
}
h.Write(trust)
}
return "AROA" + strings.ToUpper(hex.EncodeToString(h.Sum(nil)))[:17]
}
// RoleSourceStaticConfig is the Source of a role loaded from the IAM config
// file.
const RoleSourceStaticConfig = "static-config"
// checkSessionRoleBinding refuses a session issued for a role that has since
// been deleted, or replaced by a role reusing its name: the role's current ID
// must be the one the session carries. It runs for every session carrying a
// role ID, whatever policies the session embeds — the policies a session
// embeds are the ones its role had, and outlive the role otherwise. A session
// issued before role IDs were recorded carries none and is not bound. It
// returns the role it checked, nil for an unbound session.
func (m *IAMManager) checkSessionRoleBinding(ctx context.Context, sessionInfo *sts.SessionInfo) (*RoleDefinition, error) {
if sessionInfo == nil || sessionInfo.RoleId == "" {
return nil, nil
}
roleName := utils.ExtractRoleNameFromArn(sessionInfo.RoleArn)
if roleName == "" {
return nil, nil
}
role, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if errors.Is(err, ErrRoleNotFound) {
return nil, fmt.Errorf("session was issued for role %s, which no longer exists", roleName)
}
if err != nil {
return nil, fmt.Errorf("resolve role %s for session: %w", roleName, err)
}
if role.RoleId != sessionInfo.RoleId {
return nil, fmt.Errorf("session was issued for an earlier role named %s", roleName)
}
return role, nil
}
// ActionRequest represents a request to perform an action
type ActionRequest struct {
// Principal is the entity performing the action
Principal string `json:"principal"`
// Action is the action being requested
Action string `json:"action"`
// Resource is the resource being accessed
Resource string `json:"resource"`
// SessionToken for temporary credential validation
SessionToken string `json:"sessionToken"`
// RequestContext contains additional request information
RequestContext map[string]interface{} `json:"requestContext,omitempty"`
// PolicyNames to evaluate (overrides role-based policies if present)
PolicyNames []string `json:"policyNames,omitempty"`
}
// NewIAMManager creates a new IAM manager
func NewIAMManager() *IAMManager {
return &IAMManager{}
}
// SetUserStore assigns the user store used to resolve IAM user policy attachments.
func (m *IAMManager) SetUserStore(store UserStore) {
m.userStore = store
}
// SyncRuntimePolicies keeps zero-config runtime policies available to the
// in-memory policy engine used by the advanced IAM authorizer.
func (m *IAMManager) SyncRuntimePolicies(ctx context.Context, policies []*iam_pb.Policy) error {
if !m.initialized || m.policyEngine == nil {
return nil
}
if m.policyEngine.StoreType() != sts.StoreTypeMemory {
return nil
}
desiredPolicies := make(map[string]*policy.PolicyDocument, len(policies))
for _, runtimePolicy := range policies {
if runtimePolicy == nil || runtimePolicy.Name == "" {
continue
}
var document policy.PolicyDocument
if err := json.Unmarshal([]byte(runtimePolicy.Content), &document); err != nil {
// Drop just this one: aborting here would leave every other policy
// unsynced. Leaving it out of desiredPolicies also deletes it from
// the engine below, which is the point — a policy whose stored
// definition no longer parses must stop granting access rather than
// keep enforcing a document the operator can no longer see.
glog.Warningf("skipping unparsable runtime policy %q: %v", runtimePolicy.Name, err)
continue
}
desiredPolicies[runtimePolicy.Name] = &document
}
m.runtimePolicyMu.Lock()
defer m.runtimePolicyMu.Unlock()
filerAddress := m.getFilerAddress()
for policyName := range m.runtimePolicyNames {
if _, keep := desiredPolicies[policyName]; keep {
continue
}
if err := m.policyEngine.DeletePolicy(ctx, filerAddress, policyName); err != nil {
return fmt.Errorf("failed to delete runtime policy %q: %w", policyName, err)
}
}
for policyName, document := range desiredPolicies {
if err := m.policyEngine.AddPolicy(filerAddress, policyName, document); err != nil {
return fmt.Errorf("failed to sync runtime policy %q: %w", policyName, err)
}
}
m.runtimePolicyNames = make(map[string]struct{}, len(desiredPolicies))
for policyName := range desiredPolicies {
m.runtimePolicyNames[policyName] = struct{}{}
}
return nil
}
// Initialize initializes the IAM manager with all components
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
// Store the filer address provider function
m.filerAddressProvider = filerAddressProvider
// Initialize STS service
m.stsService = sts.NewSTSService()
if err := m.stsService.Initialize(config.STS); err != nil {
return fmt.Errorf("failed to initialize STS service: %w", err)
}
// CRITICAL SECURITY: Set trust policy validator to ensure proper role assumption validation
m.stsService.SetTrustPolicyValidator(m)
// Initialize policy engine
m.policyEngine = policy.NewPolicyEngine()
if err := m.policyEngine.InitializeWithProvider(config.Policy, m.filerAddressProvider); err != nil {
return fmt.Errorf("failed to initialize policy engine: %w", err)
}
// Initialize role store
roleStore, err := m.createRoleStoreWithProvider(config.Roles, m.filerAddressProvider)
if err != nil {
return fmt.Errorf("failed to initialize role store: %w", err)
}
m.roleStore = roleStore
// Initialize OIDC provider store and hydrate from static configuration so
// the read-only IAM API can return the same providers the STS service
// already accepts. Mutations will land in Phase 2b.
if err := m.initOIDCProviderStore(config); err != nil {
return fmt.Errorf("failed to initialize OIDC provider store: %w", err)
}
m.initialized = true
return nil
}
// initOIDCProviderStore creates the OIDC provider store and seeds it from the
// static STS provider configuration. The static path remains the bootstrap
// source: each enabled OIDC entry under STS.Providers is mirrored as an
// OIDCProviderRecord so the IAM API surfaces the same set the STS service
// validates against.
func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
store, err := m.createOIDCProviderStore(config.OIDCProviders)
if err != nil {
return err
}
m.installOIDCProviderStore(store, config.STS)
return nil
}
// installOIDCProviderStore makes store the manager's OIDC provider store.
//
// The IAM config file's providers are reported by the IAM API alongside the
// stored ones. An in-memory store holds them as records, as it always has. A
// persistent store never does: it outlives this process and may be shared by
// S3 servers with different config files, so a record written from one file
// would outlive its removal from that file and be trusted by every server.
// Those providers stay in memory (staticOIDCProviders) and STS keeps serving
// them from its static configuration; the store holds only providers created
// through the IAM API, and those are loaded into STS here.
//
// A provider stored under the same ARN as a config-file provider takes
// precedence, in the IAM API as in STS, which already prefers IAM-managed
// providers so that an API call can shadow a bootstrap entry. Deleting the
// stored provider brings the config-file one back.
func (m *IAMManager) installOIDCProviderStore(store OIDCProviderStore, stsConfig *sts.STSConfig) {
// Cancel the old store's retry and switch stores in one step, so a failed
// refresh of the old store cannot start a retry after the cancel.
m.oidcRetryMu.Lock()
m.stopOIDCRetryLocked()
m.oidcProviderStore = store
m.oidcRetryMu.Unlock()
m.staticOIDCProviders = staticOIDCProviderRecords(stsConfig)
if _, inMemory := store.(*MemoryOIDCProviderStore); inMemory {
ctx := context.Background()
now := time.Now().UTC()
for _, rec := range m.staticOIDCProviders {
mirrored := copyOIDCProviderRecord(rec)
mirrored.CreatedAt, mirrored.UpdatedAt = now, now
if err := store.StoreProvider(ctx, m.getFilerAddress(), mirrored); err != nil {
glog.Warningf("mirror static OIDC provider %s into store: %v", rec.ARN, err)
}
}
// The store now holds them and the API may change them, as before;
// the overlay is only for stores that must not hold them.
m.staticOIDCProviders = nil
return
}
// The metadata subscription only reports changes made from now on, so
// providers already in the store would stay unknown until one changes; a
// failed load is retried (RefreshOIDCProvidersFromStore).
if err := m.RefreshOIDCProvidersFromStore(context.Background()); err != nil {
glog.Warningf("load OIDC providers from the store at startup: %v; retrying in the background", err)
}
}
// startOIDCRetry retries loading store in the background until it succeeds.
// A store that is no longer current gets no retry: nothing would cancel it,
// and its eventual success would replace the current store's providers. When
// a retry is already running, it is asked to run once more instead, because
// it may already have listed a snapshot older than this failure.
func (m *IAMManager) startOIDCRetry(store OIDCProviderStore) {
m.oidcRetryMu.Lock()
defer m.oidcRetryMu.Unlock()
if store != m.oidcProviderStore {
return
}
if m.oidcRetryGen != 0 {
m.oidcRetryAgain = true
return
}
ctx, cancel := context.WithCancel(context.Background())
m.oidcRetrySeq++
gen := m.oidcRetrySeq
m.cancelOIDCLoad, m.oidcRetryGen = cancel, gen
bounds := oidcHydrateRetry // read here, not in the goroutine: it outlives its caller
go func() {
defer cancel()
for {
m.retryOIDCProviderLoad(ctx, store, bounds)
m.oidcRetryMu.Lock()
if m.oidcRetryGen != gen {
m.oidcRetryMu.Unlock()
return // cancelled: another store was installed
}
if m.oidcRetryAgain && ctx.Err() == nil {
m.oidcRetryAgain = false
m.oidcRetryMu.Unlock()
continue
}
m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false
m.oidcRetryMu.Unlock()
return
}
}()
}
// stopOIDCRetryLocked cancels a running retry, as installing another store
// must. The caller holds oidcRetryMu.
func (m *IAMManager) stopOIDCRetryLocked() {
if m.cancelOIDCLoad != nil {
m.cancelOIDCLoad()
}
m.cancelOIDCLoad, m.oidcRetryGen, m.oidcRetryAgain = nil, 0, false
}
// currentOIDCProviderStore is the installed store, read under oidcRetryMu.
func (m *IAMManager) currentOIDCProviderStore() OIDCProviderStore {
m.oidcRetryMu.Lock()
defer m.oidcRetryMu.Unlock()
return m.oidcProviderStore
}
// staticOIDCProviderRecords describes the enabled OIDC providers of the IAM
// config file as provider records.
func staticOIDCProviderRecords(stsConfig *sts.STSConfig) map[string]*OIDCProviderRecord {
out := map[string]*OIDCProviderRecord{}
if stsConfig == nil {
return out
}
for _, pc := range stsConfig.Providers {
if pc == nil || !pc.Enabled || pc.Type != sts.ProviderTypeOIDC {
continue
}
issuer, _ := pc.Config["issuer"].(string)
if issuer == "" {
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping", pc.Name)
continue
}
arn, err := DeriveOIDCProviderARN(stsConfig.AccountId, issuer)
if err != nil {
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
continue
}
out[arn] = &OIDCProviderRecord{
AccountID: stsConfig.AccountId,
ARN: arn,
URL: issuer,
ClientIDs: extractClientIDs(pc.Config),
Thumbprints: extractStringList(pc.Config, "thumbprints"),
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
PolicyClaim: extractString(pc.Config, "policyClaim"),
// No CreatedAt: a config-file provider has no creation time the
// server could report consistently across restarts.
}
}
return out
}
// oidcHydrateRetry bounds the backoff between startup load attempts.
var oidcHydrateRetry = struct{ initial, max time.Duration }{initial: time.Second, max: 30 * time.Second}
// retryOIDCProviderLoad retries loading store, the store it was started for,
// until it succeeds or ctx is cancelled because another store was installed.
// It calls refreshOIDCProvidersFrom, not RefreshOIDCProvidersFromStore, so it
// never schedules a retry of its own.
func (m *IAMManager) retryOIDCProviderLoad(ctx context.Context, store OIDCProviderStore, bounds struct{ initial, max time.Duration }) {
delay := bounds.initial
for {
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
err := m.refreshOIDCProvidersFrom(ctx, store)
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 > bounds.max {
delay = bounds.max
}
}
}
// 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 —
// the worst case is that the local instance keeps the stale runtime view
// until a peer's metadata-subscribe event triggers another refresh.
func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn string) {
if err := m.RefreshOIDCProvidersFromStore(ctx); err != nil {
glog.Warningf("refresh OIDC providers after %s on %s: %v", op, arn, err)
}
}
// RefreshOIDCProvidersFromStore reloads every OIDCProviderRecord from the
// configured store and pushes the resulting runtime providers into the STS
// service so AssumeRoleWithWebIdentity sees the latest set without a
// restart. Safe to call when no store is configured (returns nil) and when
// the store is empty (clears the IAM-managed map). Records with empty URLs
// or invalid configuration are logged and skipped so a single bad entry
// does not stop the rest from refreshing.
//
// A refresh that fails keeps retrying in the background until the store
// answers. Every caller needs that: a metadata-subscription event reports each
// change ONCE, so a refresh that found the filer unreachable on it would leave
// a peer's new provider untrusted, or a deleted one trusted, until an unrelated
// later change; the refresh after a local IAM API mutation and the startup load
// have the same shape. At most one retry runs.
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
store := m.currentOIDCProviderStore()
err := m.refreshOIDCProvidersFrom(ctx, store)
if err != nil && store != nil {
m.startOIDCRetry(store)
}
return err
}
// refreshOIDCProvidersFrom is RefreshOIDCProvidersFromStore for a given store.
func (m *IAMManager) refreshOIDCProvidersFrom(ctx context.Context, store OIDCProviderStore) error {
if store == nil || m.stsService == nil {
return nil
}
// Refreshes run concurrently: after an IAM API change, on a peer's change
// and in the startup retry. Unserialized, a refresh that read the store
// before a DeleteOIDCProvider could finish after that call's own refresh
// and keep the deleted provider trusted.
m.oidcRefreshMu.Lock()
defer m.oidcRefreshMu.Unlock()
records, err := store.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return fmt.Errorf("list OIDC providers: %w", err)
}
// A snapshot of a store that has since been replaced must not replace the
// current store's providers: a retry is cancelled when another store is
// installed, and a refresh may have listed the old store just before.
if err := ctx.Err(); err != nil {
return err
}
if store != m.currentOIDCProviderStore() {
return fmt.Errorf("list OIDC providers: the store was replaced during the refresh")
}
byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records))
for _, rec := range records {
if rec == nil || rec.URL == "" {
continue
}
provider, err := buildOIDCProviderFromRecord(rec)
if err != nil {
glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err)
continue
}
// Multiple records may share an issuer when each is scoped to a
// different account; STS picks the right one at validation time
// based on the role being assumed. See lookupOIDCProviderForAccount.
byIssuer[rec.URL] = append(byIssuer[rec.URL], sts.ScopedOIDCProvider{
AccountID: rec.AccountID,
Provider: provider,
})
}
m.stsService.SetIAMManagedOIDCProviders(byIssuer)
return nil
}
// buildOIDCProviderFromRecord turns a stored record into a runtime
// OIDCProvider. The provider name is the ARN so re-registration is
// idempotent and never collides with static-config entries.
func buildOIDCProviderFromRecord(rec *OIDCProviderRecord) (*oidc.OIDCProvider, error) {
if rec == nil {
return nil, fmt.Errorf("record cannot be nil")
}
cfg := &oidc.OIDCConfig{
Issuer: rec.URL,
ClientIDs: append([]string(nil), rec.ClientIDs...),
Thumbprints: append([]string(nil), rec.Thumbprints...),
AllowedPrincipalTagKeys: append([]string(nil), rec.AllowedPrincipalTagKeys...),
PolicyClaim: rec.PolicyClaim,
}
provider := oidc.NewOIDCProvider(rec.ARN)
if err := provider.Initialize(cfg); err != nil {
return nil, err
}
return provider, nil
}
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
// to memory; "filer" requires a filerAddressProvider to be configured.
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
if cfg == nil || cfg.StoreType == "" || cfg.StoreType == "memory" {
return NewMemoryOIDCProviderStore(), nil
}
if cfg.StoreType == "filer" {
return NewFilerOIDCProviderStore(cfg.StoreConfig, m.filerAddressProvider), nil
}
return nil, fmt.Errorf("unsupported OIDC provider store type: %s", cfg.StoreType)
}
// extractClientIDs reads a single clientId or a clientIds list from the
// provider's static config map. Mirrors the OIDCConfig schema.
func extractClientIDs(cfg map[string]interface{}) []string {
if cfg == nil {
return nil
}
if list, ok := cfg["clientIds"].([]interface{}); ok {
out := make([]string, 0, len(list))
for _, v := range list {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
if id, ok := cfg["clientId"].(string); ok && id != "" {
return []string{id}
}
return nil
}
// extractStringList reads a JSON string array out of the provider's static
// config map and returns the non-empty entries. Returns nil when the key is
// missing, the value is the wrong shape, or every entry is empty.
func extractStringList(cfg map[string]interface{}, key string) []string {
if cfg == nil {
return nil
}
list, ok := cfg[key].([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(list))
for _, v := range list {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
// extractString reads a single string field from the provider's static
// config map; missing or non-string values produce "".
func extractString(cfg map[string]interface{}, key string) string {
if cfg == nil {
return ""
}
s, _ := cfg[key].(string)
return s
}
// getFilerAddress returns the current filer address using the provider function
func (m *IAMManager) getFilerAddress() string {
if m.filerAddressProvider != nil {
return m.filerAddressProvider()
}
return "" // Fallback to empty string if no provider is set
}
// createRoleStoreWithProvider creates a role store with a filer address provider function
func (m *IAMManager) createRoleStoreWithProvider(config *RoleStoreConfig, filerAddressProvider func() string) (RoleStore, error) {
if config == nil {
// Default to generic cached filer role store when no config provided
return NewGenericCachedRoleStore(nil, filerAddressProvider)
}
switch config.StoreType {
case "", "filer":
// Check if caching is explicitly disabled
if config.StoreConfig != nil {
if noCache, ok := config.StoreConfig["noCache"].(bool); ok && noCache {
return NewFilerRoleStore(config.StoreConfig, filerAddressProvider)
}
}
// Default to generic cached filer store for better performance
return NewGenericCachedRoleStore(config.StoreConfig, filerAddressProvider)
case "cached-filer", "generic-cached":
return NewGenericCachedRoleStore(config.StoreConfig, filerAddressProvider)
case "memory":
return NewMemoryRoleStore(), nil
default:
return nil, fmt.Errorf("unsupported role store type: %s", config.StoreType)
}
}
// RegisterIdentityProvider registers an identity provider
func (m *IAMManager) RegisterIdentityProvider(provider providers.IdentityProvider) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
return m.stsService.RegisterProvider(provider)
}
// CreatePolicy creates a new policy
func (m *IAMManager) CreatePolicy(ctx context.Context, filerAddress string, name string, policyDoc *policy.PolicyDocument) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
return m.policyEngine.AddPolicy(filerAddress, name, policyDoc)
}
// CreateRole creates a new role with trust policy and attached policies
func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleName string, roleDef *RoleDefinition) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
if err := PrepareRoleDefinition(roleName, roleDef); err != nil {
return err
}
if roleDef.RoleId == "" {
roleDef.RoleId = NewRoleID()
}
// Store role definition
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
}
// UpdateRole changes a role atomically in the role store (see
// RoleStore.UpdateRole). update receives the role's current definition, nil
// when it does not exist, and its result is validated like CreateRole's; a
// nil result deletes the role.
// The IAM API's role actions use it, so a change made on one S3 server is
// neither lost to a concurrent change on another nor written over a delete.
func (m *IAMManager) UpdateRole(ctx context.Context, roleName string, update RoleUpdate) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
return m.roleStore.UpdateRole(ctx, "", roleName, func(current *RoleDefinition) (*RoleDefinition, error) {
next, err := update(current)
if err != nil || next == nil {
return next, err
}
if err := PrepareRoleDefinition(roleName, next); err != nil {
return nil, err
}
return next, nil
})
}
// PrepareRoleDefinition applies CreateRole's defaults and validation to a role
// about to be stored or loaded.
func PrepareRoleDefinition(roleName string, roleDef *RoleDefinition) error {
if roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
if roleDef == nil {
return fmt.Errorf("role definition cannot be nil")
}
if roleDef.RoleName == "" {
roleDef.RoleName = roleName
}
// Set role ARN if not provided
if roleDef.RoleArn == "" {
roleDef.RoleArn = fmt.Sprintf("arn:aws:iam::role/%s", roleName)
}
// Validate trust policy
if roleDef.TrustPolicy != nil {
if err := policy.ValidateTrustPolicyDocument(roleDef.TrustPolicy); err != nil {
return fmt.Errorf("invalid trust policy: %w", err)
}
}
// Validate per-role MaxSessionDuration if specified. AWS bounds: 1h..12h.
if roleDef.MaxSessionDuration != 0 {
if roleDef.MaxSessionDuration < 3600 || roleDef.MaxSessionDuration > 43200 {
return fmt.Errorf("MaxSessionDuration must be between 3600 and 43200 seconds, got %d", roleDef.MaxSessionDuration)
}
}
return nil
}
// LoadStaticRoles installs the roles of the IAM config file.
//
// An in-memory store holds them as records, as it always has. A persistent
// store never does: it outlives this process and may be shared by S3 servers
// with different config files, so a record written from one file would
// outlive its removal from that file and be honoured by every server. Those
// roles are served from memory instead, ahead of the store, and cannot be
// changed or deleted through the store (ErrRoleStatic). A role stored under
// the same name takes precedence. They report no creation time.
func (m *IAMManager) LoadStaticRoles(ctx context.Context, roles []*RoleDefinition) {
defs := make(map[string]*RoleDefinition, len(roles))
for _, role := range roles {
if role == nil {
continue
}
role.Source = RoleSourceStaticConfig
if role.RoleId == "" {
role.RoleId = StaticRoleID(role)
}
if err := PrepareRoleDefinition(role.RoleName, role); err != nil {
glog.Warningf("Failed to load role %s: %v", role.RoleName, err)
continue
}
defs[role.RoleName] = role
}
m.staticRoles = defs
m.installRoleStore(ctx, m.roleStore)
}
// installRoleStore makes store the role store, with the config-file roles
// installed in it as LoadStaticRoles describes, so a store set after startup
// behaves like the one set at startup.
func (m *IAMManager) installRoleStore(ctx context.Context, store RoleStore) {
if overlay, ok := store.(*staticRoleOverlay); ok {
store = overlay.inner
}
if store == nil || m.staticRoles == nil {
m.roleStore = store
return
}
if _, inMemory := store.(*MemoryRoleStore); inMemory {
for name, role := range m.staticRoles {
if err := store.StoreRole(ctx, "", name, role); err != nil {
glog.Warningf("Failed to create role %s: %v", name, err)
}
}
m.roleStore = store
return
}
m.roleStore = &staticRoleOverlay{static: m.staticRoles, inner: store}
}
// GetRole retrieves a role definition by name.
// ListRoles returns every stored role definition.
func (m *IAMManager) ListRoles(ctx context.Context) ([]*RoleDefinition, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
}
names, err := m.roleStore.ListRoles(ctx, m.getFilerAddress())
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
roles := make([]*RoleDefinition, 0, len(names))
for _, name := range names {
role, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), name)
if errors.Is(err, ErrRoleNotFound) {
continue // deleted between list and read
}
if err != nil {
return nil, fmt.Errorf("get role %s: %w", name, err)
}
roles = append(roles, role)
}
return roles, nil
}
// DeleteRole removes a role definition.
func (m *IAMManager) DeleteRole(ctx context.Context, roleName string) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
if roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
return m.roleStore.DeleteRole(ctx, m.getFilerAddress(), roleName)
}
// InvalidateRoleCache drops any cached role definitions, so a change written
// to the store by a peer is seen on the next lookup rather than after the
// cache TTL.
func (m *IAMManager) InvalidateRoleCache() {
if cached, ok := m.roleStore.(interface{ ClearCache() }); ok {
cached.ClearCache()
}
}
// RoleStoreDirectory is the filer directory the role store keeps roles in,
// its configured basePath; empty when the store is not filer-backed. S3
// servers watch it to drop cached roles when a peer changes one.
func (m *IAMManager) RoleStoreDirectory() string {
store := m.roleStore
if overlay, ok := store.(*staticRoleOverlay); ok {
store = overlay.inner
}
if cached, ok := store.(*GenericCachedRoleStore); ok {
store = cached.adapter.store
}
if filerStore, ok := store.(*FilerRoleStore); ok {
return filerStore.basePath
}
return ""
}
func (m *IAMManager) GetRole(ctx context.Context, roleName string) (*RoleDefinition, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
}
if roleName == "" {
return nil, fmt.Errorf("role name cannot be empty")
}
return m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
}
// UpdateBucketPolicy updates the policy for a bucket
func (m *IAMManager) UpdateBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
if bucketName == "" {
return fmt.Errorf("bucket name cannot be empty")
}
// Parse the policy document handled by the IAM policy engine
var policyDoc policy.PolicyDocument
if err := json.Unmarshal(policyJSON, &policyDoc); err != nil {
return fmt.Errorf("invalid policy JSON: %w", err)
}
// Store the policy with a special prefix to distinguish from IAM policies
policyName := "bucket-policy:" + bucketName
return m.policyEngine.AddPolicy(m.getFilerAddress(), policyName, &policyDoc)
}
// EnsureBucketPolicy stores the policy for a bucket only when no mirror is
// stored yet, backfilling policies that predate the IAM integration (the
// metadata subscription only sees changes). A present mirror is left alone,
// so repeat calls cost one cached read. Returns whether a write happened,
// so the caller can reconcile a write that raced a concurrent change.
func (m *IAMManager) EnsureBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) (bool, error) {
if !m.initialized {
return false, fmt.Errorf("IAM manager not initialized")
}
if bucketName == "" {
return false, fmt.Errorf("bucket name cannot be empty")
}
if existing, err := m.policyEngine.GetPolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName); err == nil && existing != nil {
return false, nil
}
if err := m.UpdateBucketPolicy(ctx, bucketName, policyJSON); err != nil {
return false, err
}
return true, nil
}
// RemoveBucketPolicy deletes the stored policy for a bucket. Removing a
// policy that was never stored is a success.
func (m *IAMManager) RemoveBucketPolicy(ctx context.Context, bucketName string) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
if bucketName == "" {
return fmt.Errorf("bucket name cannot be empty")
}
return m.policyEngine.DeletePolicy(ctx, m.getFilerAddress(), "bucket-policy:"+bucketName)
}
// AssumeRoleWithWebIdentity assumes a role using web identity (OIDC)
func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
}
// Claim-based mode bypasses role lookup and trust policy entirely; the
// STS service handles the policy resolution from the JWT itself. Account
// scoping still applies but at the provider-resolution layer in Phase 3c
// (we'll plug that in once a multi-account assume path lands).
if sts.IsClaimBasedPolicyRoleArn(request.RoleArn) {
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
}
// Extract role name from ARN
roleName := utils.ExtractRoleNameFromArn(request.RoleArn)
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return nil, fmt.Errorf("role not found: %s", roleName)
}
// Account scoping: when the role lives in account A, the OIDC provider
// validating the token must be either global (AccountID="") or also live
// in account A. Skip when we can't resolve account context; fall through
// to the existing trust-policy enforcement.
if err := m.enforceProviderAccountScope(ctx, request); err != nil {
return nil, err
}
// Validate trust policy before allowing STS to assume the role
if err := m.validateTrustPolicyForWebIdentity(ctx, roleDef, request.WebIdentityToken, request.DurationSeconds); err != nil {
return nil, fmt.Errorf("trust policy validation failed: %w", err)
}
// Apply role-level MaxSessionDuration cap. The STS service still applies
// the global MaxSessionLength and the source-token-expiry cap on top of
// this; per-role takes precedence whenever it is the tightest bound.
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds())
request.RoleId = roleDef.RoleId
// Use STS service to assume the role
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
}
// enforceProviderAccountScope checks that the OIDC provider matching the
// token's issuer is registered in either the role's account or as a global
// (account-less) provider. Returns nil when no provider store is configured
// (preserves the static-config-only path) or when the issuer is not known to
// the store (the existing STS-layer issuer→provider map handles that case
// during validation).
func (m *IAMManager) enforceProviderAccountScope(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) error {
if m.oidcProviderStore == nil {
return nil
}
roleAccount := utils.ParseRoleARN(request.RoleArn).AccountID
if roleAccount == "" {
// Legacy ARN form (no account): nothing to enforce.
return nil
}
issuer, err := extractIssuerFromJWT(request.WebIdentityToken)
if err != nil {
return nil // signature validation will reject, no need to fail twice
}
// Look for a provider that is either global or scoped to the role's
// account. Multiple providers may share an issuer (e.g. one global plus
// one per tenant), and GetProviderByIssuer returns the first match
// arbitrarily — using it here would falsely reject a valid request
// whenever the wrong record happens to come back first.
if _, err := m.oidcProviderStore.GetProviderByIssuerAndAccount(ctx, m.getFilerAddress(), issuer, roleAccount); err == nil {
return nil
}
// No allowed match. Distinguish "issuer entirely unknown" (let the STS
// layer reject with the existing not-registered error) from "issuer is
// registered but only in a different account" (surface a precise error
// so the operator knows the call was cross-account).
if other, err := m.oidcProviderStore.GetProviderByIssuer(ctx, m.getFilerAddress(), issuer); err == nil {
return fmt.Errorf("OIDC provider for issuer %s is registered in account %s; cannot be used to assume a role in account %s", issuer, other.AccountID, roleAccount)
}
return nil
}
// extractIssuerFromJWT returns the iss claim of a JWT without verifying its
// signature. Safe here because the caller still goes through full signature
// + issuer validation in the STS service; this is purely for routing.
func extractIssuerFromJWT(token string) (string, error) {
parser := new(jwt.Parser)
parsed, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return "", err
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return "", fmt.Errorf("invalid claims")
}
iss, _ := claims["iss"].(string)
if iss == "" {
return "", fmt.Errorf("token has no iss claim")
}
return iss, nil
}
// capDurationByRole returns the session duration clamped to the role's
// MaxSessionDuration. An omitted DurationSeconds resolves to the configured
// default first, so the role bound caps defaults and explicit values alike.
// A nil request is only materialized when something tightened the default
// and the explicit value still passes the service's own input validation —
// everything else is left nil so the service resolves the default and its
// MaxSessionLength cap itself.
func capDurationByRole(requested *int64, roleMax, defaultSec, serviceMaxSec int64) *int64 {
if requested != nil {
d := *requested
if roleMax > 0 && d > roleMax {
d = roleMax
}
return &d
}
d := defaultSec
if roleMax > 0 && d > roleMax {
d = roleMax
}
if d > serviceMaxSec {
d = serviceMaxSec
}
if d < 900 && roleMax > 0 {
d = 900
}
if d >= defaultSec || d < 900 {
return nil
}
return &d
}
func (m *IAMManager) defaultTokenDurationSeconds() int64 {
if m.stsService == nil || m.stsService.Config == nil {
return sts.DefaultTokenDuration
}
return int64(m.stsService.Config.TokenDuration.Duration / time.Second)
}
// maxSessionLengthSeconds mirrors validateSessionDurationSeconds so a
// materialized default stays inside the bound the service will enforce.
func (m *IAMManager) maxSessionLengthSeconds() int64 {
maxSec := int64(sts.DefaultMaxSessionLength)
if m.stsService != nil && m.stsService.Config != nil && m.stsService.Config.MaxSessionLength.Duration > 0 {
if configured := int64(m.stsService.Config.MaxSessionLength.Duration / time.Second); configured >= 900 {
maxSec = configured
}
}
return maxSec
}
// AssumeRoleWithCredentials assumes a role using credentials (LDAP)
func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts.AssumeRoleWithCredentialsRequest) (*sts.AssumeRoleResponse, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
}
// Extract role name from ARN
roleName := utils.ExtractRoleNameFromArn(request.RoleArn)
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return nil, fmt.Errorf("role not found: %s", roleName)
}
// Validate trust policy
if err := m.validateTrustPolicyForCredentials(ctx, roleDef, request); err != nil {
return nil, fmt.Errorf("trust policy validation failed: %w", err)
}
// Apply role-level MaxSessionDuration cap.
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration, m.defaultTokenDurationSeconds(), m.maxSessionLengthSeconds())
// Use STS service to assume the role
request.RoleId = roleDef.RoleId
return m.stsService.AssumeRoleWithCredentials(ctx, request)
}
// IsActionAllowed checks if a principal is allowed to perform an action on a resource
func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest) (bool, error) {
if !m.initialized {
return false, fmt.Errorf("IAM manager not initialized")
}
// Validate session token if present
// We always try to validate with the internal STS service first if it's a SeaweedFS token.
// This ensures that session policies embedded in the token are correctly extracted and enforced.
var sessionInfo *sts.SessionInfo
// boundRole is the role a session carrying a role ID was checked against;
// its policies are the ones evaluated, so the check and the evaluation
// see one definition even if the role is replaced in between.
var boundRole *RoleDefinition
if request.SessionToken != "" {
// Parse unverified to check issuer
parsed, _, err := new(jwt.Parser).ParseUnverified(request.SessionToken, jwt.MapClaims{})
isInternal := false
if err == nil {
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
if issuer, ok := claims["iss"].(string); ok && m.stsService != nil && m.stsService.Config != nil {
if issuer == m.stsService.Config.Issuer {
isInternal = true
}
}
}
}
if isInternal || !isOIDCToken(request.SessionToken) {
var err error
sessionInfo, err = m.stsService.ValidateSessionToken(ctx, request.SessionToken)
if err != nil {
return false, fmt.Errorf("invalid session: %w", err)
}
// Reject sessions whose JTI has been added to the revocation
// blocklist. SessionId == JTI for STS-issued tokens; this lookup
// is hot, so the store implementation must be O(1).
if sessionInfo != nil && sessionInfo.SessionId != "" {
revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId)
if rerr != nil {
return false, fmt.Errorf("revocation check failed: %w", rerr)
}
if revoked {
return false, fmt.Errorf("session has been revoked")
}
}
if boundRole, err = m.checkSessionRoleBinding(ctx, sessionInfo); err != nil {
return false, err
}
}
}
// Create evaluation context
evalCtx := &policy.EvaluationContext{
Principal: request.Principal,
Action: request.Action,
Resource: request.Resource,
RequestContext: request.RequestContext,
}
// Ensure RequestContext exists and populate with principal info
if evalCtx.RequestContext == nil {
evalCtx.RequestContext = make(map[string]interface{})
}
// Add principal to context for policy matching
// The PolicyEngine checks RequestContext["principal"] or RequestContext["aws:PrincipalArn"]
evalCtx.RequestContext["principal"] = request.Principal
evalCtx.RequestContext["aws:PrincipalArn"] = request.Principal // AWS standard key
// Check if this is an admin request - bypass policy evaluation if so
// This mirrors the logic in auth_signature_v4.go but applies it at authorization time
isAdmin := false
if request.RequestContext != nil {
if val, ok := request.RequestContext["is_admin"].(bool); ok && val {
isAdmin = true
}
// Print full request context for debugging
}
// Parse principal ARN to extract details for context variables (e.g. ${aws:username})
arnInfo := utils.ParsePrincipalARN(request.Principal)
if arnInfo.RoleName != "" {
// For assumed roles, AWS docs say aws:username IS the role name.
// However, for user isolation in these tests, we typically map the session name (the user who assumed the role) to aws:username.
// arn:aws:sts::account:assumed-role/RoleName/SessionName
awsUsername := arnInfo.RoleName
if idx := strings.LastIndex(request.Principal, "/"); idx != -1 && idx < len(request.Principal)-1 {
awsUsername = request.Principal[idx+1:]
}
evalCtx.RequestContext["aws:username"] = awsUsername
evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName
} else if userName := utils.ExtractUserNameFromPrincipal(request.Principal); userName != "" {
evalCtx.RequestContext["aws:username"] = userName
evalCtx.RequestContext["aws:userid"] = userName
}
if arnInfo.AccountID != "" {
evalCtx.RequestContext["aws:PrincipalAccount"] = arnInfo.AccountID
}
// Determine if there is a bucket policy to evaluate
var bucketPolicyName string
if strings.HasPrefix(request.Resource, "arn:aws:s3:::") {
resourcePath := request.Resource[13:] // remove "arn:aws:s3:::"
parts := strings.SplitN(resourcePath, "/", 2)
if len(parts) > 0 && parts[0] != "" {
bucketPolicyName = "bucket-policy:" + parts[0]
}
}
var baseResult *policy.EvaluationResult
var err error
// hasManagedSubject is true once we've resolved the principal to a registered
// IAM user or role (or the caller has supplied PolicyNames directly). For a
// managed subject, "no matching statement" must deny — the DefaultEffect=Allow
// fallback is only meant for the unmanaged zero-config startup case.
hasManagedSubject := false
if isAdmin {
// Admin always has base access allowed
baseResult = &policy.EvaluationResult{Effect: policy.EffectAllow}
} else {
policies := request.PolicyNames
if len(policies) > 0 {
hasManagedSubject = true
}
if len(policies) == 0 {
// Extract role name from principal ARN
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
if roleName == "" {
userName := utils.ExtractUserNameFromPrincipal(request.Principal)
if userName == "" {
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
}
if m.userStore == nil {
return false, fmt.Errorf("user store unavailable for principal: %s", request.Principal)
}
user, err := m.userStore.GetUser(ctx, userName)
if err != nil || user == nil {
return false, fmt.Errorf("user not found for principal: %s (user=%s)", request.Principal, userName)
}
hasManagedSubject = true
policies = user.GetPolicyNames()
} else {
// Get role definition
roleDef := boundRole
if roleDef == nil || roleDef.RoleName != roleName {
roleDef, err = m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return false, fmt.Errorf("role not found: %s", roleName)
}
}
hasManagedSubject = true
policies = roleDef.AttachedPolicies
}
}
if bucketPolicyName != "" {
// Enforce an upper bound on the number of policies to avoid excessive allocations
if len(policies) >= maxPoliciesForEvaluation {
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
}
// Create a new slice to avoid modifying the original and append the bucket policy
copied := make([]string, len(policies))
copy(copied, policies)
policies = append(copied, bucketPolicyName)
}
baseResult, err = m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
if err != nil {
return false, fmt.Errorf("policy evaluation failed: %w", err)
}
}
// Base policy must allow; if it doesn't, deny immediately (session policy can only further restrict)
if baseResult.Effect != policy.EffectAllow {
return false, nil
}
// Zero-config IAM uses DefaultEffect=Allow to preserve open-by-default behavior
// for requests without any subject policies. Once we resolve the principal to
// a registered IAM user or role (or the caller hands us policy names),
// "no matching statement" must fall back to deny — otherwise a freshly
// created user with zero policies would inherit full access.
if hasManagedSubject && len(baseResult.MatchingStatements) == 0 {
return false, nil
}
// If there's a session policy, it must also allow the action
if sessionInfo != nil && sessionInfo.SessionPolicy != "" {
var sessionPolicy policy.PolicyDocument
if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil {
return false, fmt.Errorf("invalid session policy JSON: %w", err)
}
if err := policy.ValidatePolicyDocument(&sessionPolicy); err != nil {
return false, fmt.Errorf("invalid session policy document: %w", err)
}
sessionResult, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny)
if err != nil {
return false, fmt.Errorf("session policy evaluation failed: %w", err)
}
if sessionResult.Effect != policy.EffectAllow {
// Session policy does not allow this action
return false, nil
}
}
return true, nil
}
// IsPrincipalActionExplicitlyDenied reports whether the action on the resource is
// explicitly denied for the principal by either the named policies or, for a
// chained STS caller, the inline session policy carried by sessionToken. Unlike
// IsActionAllowed it does not require an allow — the absence of a matching
// statement is not a denial. Used to enforce AWS deny-always-wins when the allow
// is granted elsewhere (e.g. a role trust policy for sts:AssumeRole).
//
// A chained session that fails validation or has been revoked yields an error so
// callers fail closed. Raw OIDC tokens are skipped here — they are validated on
// the JWT path, not by the STS service.
func (m *IAMManager) IsPrincipalActionExplicitlyDenied(ctx context.Context, principal, action, resource string, policyNames []string, sessionToken string, requestContext map[string]interface{}) (bool, error) {
if !m.initialized {
return false, fmt.Errorf("IAM manager not initialized")
}
if requestContext == nil {
requestContext = make(map[string]interface{})
}
requestContext["principal"] = principal
requestContext["aws:PrincipalArn"] = principal
evalCtx := &policy.EvaluationContext{
Principal: principal,
Action: action,
Resource: resource,
RequestContext: requestContext,
}
// Base policies: the caller's attached identity policies, or for a chained
// caller the assumed role's attached policies.
if len(policyNames) > 0 {
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policyNames)
if err != nil {
return false, fmt.Errorf("policy evaluation failed: %w", err)
}
if hasExplicitDeny(result.MatchingStatements) {
return true, nil
}
}
// A chained STS caller's session restricts what it may do. Skip raw OIDC
// tokens (validated on the JWT path); for our own session tokens, reject a
// revoked session and honor an explicit Deny in the inline session policy.
if sessionToken != "" && m.stsService != nil && !isOIDCToken(sessionToken) {
sessionInfo, err := m.stsService.ValidateSessionToken(ctx, sessionToken)
if err != nil {
return false, fmt.Errorf("session validation failed: %w", err)
}
if sessionInfo != nil && sessionInfo.SessionId != "" {
revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId)
if rerr != nil {
return false, fmt.Errorf("revocation check failed: %w", rerr)
}
if revoked {
return false, fmt.Errorf("session has been revoked")
}
}
if sessionInfo != nil && sessionInfo.SessionPolicy != "" {
var sessionPolicy policy.PolicyDocument
if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil {
return false, fmt.Errorf("invalid session policy JSON: %w", err)
}
result, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny)
if err != nil {
return false, fmt.Errorf("session policy evaluation failed: %w", err)
}
if hasExplicitDeny(result.MatchingStatements) {
return true, nil
}
}
}
return false, nil
}
// hasExplicitDeny reports whether any matched statement is a Deny.
func hasExplicitDeny(matches []policy.StatementMatch) bool {
for _, stmt := range matches {
if stmt.Effect == policy.EffectDeny {
return true
}
}
return false
}
// ValidateTrustPolicy validates if a principal can assume a role (for testing)
func (m *IAMManager) ValidateTrustPolicy(ctx context.Context, roleArn, provider, userID string) bool {
roleName := utils.ExtractRoleNameFromArn(roleArn)
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return false
}
// Simple validation based on provider in trust policy
if roleDef.TrustPolicy != nil {
for _, statement := range roleDef.TrustPolicy.Statement {
if statement.Effect == "Allow" {
if principal, ok := statement.Principal.(map[string]interface{}); ok {
if federated, ok := principal["Federated"].(string); ok {
// For OIDC, check against issuer URL
if provider == "oidc" && federated == "test-oidc" {
return true
}
// For LDAP, check against test-ldap
if provider == "ldap" && federated == "test-ldap" {
return true
}
// Also check for wildcard
if federated == "*" {
return true
}
}
}
}
}
}
return false
}
// validateTrustPolicyForWebIdentity validates trust policy for OIDC assumption
func (m *IAMManager) validateTrustPolicyForWebIdentity(ctx context.Context, roleDef *RoleDefinition, webIdentityToken string, durationSeconds *int64) error {
if roleDef.TrustPolicy == nil {
return fmt.Errorf("role has no trust policy")
}
// Create evaluation context for trust policy validation
requestContext := make(map[string]interface{})
// Try to parse as JWT first, fallback to mock token handling
tokenClaims, err := parseJWTTokenForTrustPolicy(webIdentityToken)
if err != nil {
// If JWT parsing fails, this might be a mock token (like "valid-oidc-token")
// For mock tokens, we'll use default values that match the trust policy expectations
requestContext["aws:FederatedProvider"] = "test-oidc"
requestContext["oidc:iss"] = "test-oidc"
// This ensures aws:userid key is populated even for mock tokens if needed
requestContext["aws:userid"] = "mock-user"
requestContext["oidc:sub"] = "mock-user"
} else {
// Add standard context values from JWT claims that trust policies might check
// See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html#condition-keys-web-identity-federation
// The issuer is the federated provider for OIDC
if iss, ok := tokenClaims["iss"].(string); ok {
// Default to issuer URL
requestContext["aws:FederatedProvider"] = iss
requestContext["oidc:iss"] = iss
// Try to resolve provider name from issuer for better policy matching
// This allows policies to reference the provider name (e.g. "keycloak") instead of the full issuer URL
if m.stsService != nil {
for name, provider := range m.stsService.GetProviders() {
if oidcProvider, ok := provider.(interface{ GetIssuer() string }); ok {
confIssuer := oidcProvider.GetIssuer()
if confIssuer == iss {
requestContext["aws:FederatedProvider"] = name
break
}
}
}
}
}
if sub, ok := tokenClaims["sub"].(string); ok {
requestContext["oidc:sub"] = sub
// Map subject to aws:userid as well for compatibility
requestContext["aws:userid"] = sub
}
if aud, ok := tokenClaims["aud"].(string); ok {
requestContext["oidc:aud"] = aud
}
// Custom claims can be prefixed if needed, but for "be 100% compatible with AWS",
// we should rely on standard OIDC claims.
// Add all other claims with oidc: prefix to support custom claims in trust policies
// This enables checking claims like "oidc:roles", "oidc:groups", "oidc:email", etc.
for k, v := range tokenClaims {
// Skip claims we've already handled explicitly or shouldn't expose
if k == "iss" || k == "sub" || k == "aud" {
continue
}
// Add with oidc: prefix
requestContext["oidc:"+k] = v
}
}
// Add DurationSeconds to context if provided
if durationSeconds != nil {
requestContext["sts:DurationSeconds"] = *durationSeconds
}
// Create evaluation context for trust policy
evalCtx := &policy.EvaluationContext{
Principal: "web-identity-user", // Placeholder principal for trust policy evaluation
Action: "sts:AssumeRoleWithWebIdentity",
Resource: roleDef.RoleArn,
RequestContext: requestContext,
}
// Evaluate the trust policy directly
if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) {
return fmt.Errorf("trust policy denies web identity assumption")
}
return nil
}
// validateTrustPolicyForCredentials validates trust policy for credential assumption
func (m *IAMManager) validateTrustPolicyForCredentials(ctx context.Context, roleDef *RoleDefinition, request *sts.AssumeRoleWithCredentialsRequest) error {
if roleDef.TrustPolicy == nil {
return fmt.Errorf("role has no trust policy")
}
// Check if trust policy allows credential assumption for the specific provider
for _, statement := range roleDef.TrustPolicy.Statement {
if statement.Effect == "Allow" {
for _, action := range statement.Action {
if action == "sts:AssumeRoleWithCredentials" {
if principal, ok := statement.Principal.(map[string]interface{}); ok {
if federated, ok := principal["Federated"].(string); ok {
if federated == request.ProviderName {
return nil // Allow
}
}
}
}
}
}
}
return fmt.Errorf("trust policy does not allow credential assumption for provider: %s", request.ProviderName)
}
// Helper functions
// ExpireSessionForTesting manually expires a session for testing purposes
func (m *IAMManager) ExpireSessionForTesting(ctx context.Context, sessionToken string) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
return m.stsService.ExpireSessionForTesting(ctx, sessionToken)
}
// GetPoliciesForUser returns the policy names attached to an IAM user.
// Returns an error if the user store is not configured or the lookup fails,
// so callers can fail closed on policy-resolution failures.
func (m *IAMManager) GetPoliciesForUser(ctx context.Context, username string) ([]string, error) {
if m.userStore == nil {
return nil, fmt.Errorf("user store not configured")
}
user, err := m.userStore.GetUser(ctx, username)
if err != nil {
return nil, fmt.Errorf("failed to look up user %q: %w", username, err)
}
if user == nil {
return nil, nil
}
return user.PolicyNames, nil
}
// GetSTSService returns the STS service instance
func (m *IAMManager) GetSTSService() *sts.STSService {
return m.stsService
}
// DefaultAllow returns whether the default effect is Allow
func (m *IAMManager) DefaultAllow() bool {
if !m.initialized || m.policyEngine == nil {
return true // Default to true if not initialized
}
return m.policyEngine.DefaultAllow()
}
// parseJWTTokenForTrustPolicy parses a JWT token to extract claims for trust policy evaluation
func parseJWTTokenForTrustPolicy(tokenString string) (map[string]interface{}, error) {
// Simple JWT parsing without verification (for trust policy context only)
// In production, this should use proper JWT parsing with signature verification
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Decode the payload (second part)
payload := parts[1]
// Add padding if needed
for len(payload)%4 != 0 {
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err)
}
return claims, nil
}
// evaluateTrustPolicy evaluates a trust policy against the evaluation context
// Now delegates to PolicyEngine for unified policy evaluation
func (m *IAMManager) evaluateTrustPolicy(trustPolicy *policy.PolicyDocument, evalCtx *policy.EvaluationContext) bool {
if trustPolicy == nil {
return false
}
// Use the PolicyEngine to evaluate the trust policy
// The PolicyEngine now handles Principal, Action, Resource, and Condition matching
result, err := m.policyEngine.EvaluateTrustPolicy(context.Background(), trustPolicy, evalCtx)
if err != nil {
return false
}
return result.Effect == policy.EffectAllow
}
// evaluateTrustPolicyConditions and evaluatePrincipalValue have been removed
// Trust policy evaluation is now handled entirely by PolicyEngine.EvaluateTrustPolicy()
// isOIDCToken checks if a token is an OIDC JWT token (vs STS session token)
func isOIDCToken(token string) bool {
// JWT tokens have three parts separated by dots and start with base64-encoded JSON
parts := strings.Split(token, ".")
if len(parts) != 3 {
return false
}
// JWT tokens typically start with "eyJ" (base64 encoded JSON starting with "{")
if !strings.HasPrefix(token, "eyJ") {
return false
}
parsed, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return false
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return false
}
if typ, ok := claims["typ"].(string); ok && typ == sts.TokenTypeSession {
return false
}
if typ, ok := claims[sts.JWTClaimTokenType].(string); ok && typ == sts.TokenTypeSession {
return false
}
return true
}
// TrustPolicyValidator interface implementation
// These methods allow the IAMManager to serve as the trust policy validator for the STS service
// ValidateTrustPolicyForWebIdentity implements the TrustPolicyValidator interface
func (m *IAMManager) ValidateTrustPolicyForWebIdentity(ctx context.Context, roleArn string, webIdentityToken string, durationSeconds *int64) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
// Extract role name from ARN
roleName := utils.ExtractRoleNameFromArn(roleArn)
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return fmt.Errorf("role not found: %s", roleName)
}
// Use existing trust policy validation logic
return m.validateTrustPolicyForWebIdentity(ctx, roleDef, webIdentityToken, durationSeconds)
}
// ValidateTrustPolicyForCredentials implements the TrustPolicyValidator interface
func (m *IAMManager) ValidateTrustPolicyForCredentials(ctx context.Context, roleArn string, identity *providers.ExternalIdentity) error {
if !m.initialized {
return fmt.Errorf("IAM manager not initialized")
}
// Extract role name from ARN
roleName := utils.ExtractRoleNameFromArn(roleArn)
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return fmt.Errorf("role not found: %s", roleName)
}
// For credentials, we need to create a mock request to reuse existing validation
// This is a bit of a hack, but it allows us to reuse the existing logic
mockRequest := &sts.AssumeRoleWithCredentialsRequest{
ProviderName: identity.Provider, // Use the provider name from the identity
}
// Use existing trust policy validation logic
return m.validateTrustPolicyForCredentials(ctx, roleDef, mockRequest)
}
// PrepareOIDCProviderRecord builds and validates the record that
// CreateOpenIDConnectProvider stores for an issuer, deriving its ARN from the
// account ID and issuer URL.
func PrepareOIDCProviderRecord(accountID, issuerURL string, clientIDs, thumbprints []string) (*OIDCProviderRecord, error) {
arn, err := DeriveOIDCProviderARN(accountID, issuerURL)
if err != nil {
return nil, err
}
rec := &OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: issuerURL,
ClientIDs: append([]string(nil), clientIDs...),
Thumbprints: append([]string(nil), thumbprints...),
}
if err := validateOIDCProviderRecord(rec); err != nil {
return nil, err
}
return rec, nil
}