Files
seaweedfs/weed/iam/integration/iam_manager.go
T
Chris Lu 69cc2869ad Fixes from the review of the admin bucket policy UI (#10907)
* admin: treat a missing S3 Tables policy as an empty load, not an error

The bucket/table policy GET relayed the backend's 404 NoSuchPolicy to the
dialog, whose loader treats any non-OK response as a load failure and
keeps Save and Delete blocked. A bucket or table without a policy could
never be given one. Return policy null instead, the same contract
ShowBucketPolicy uses for classic buckets.

* admin: reject policy documents the structured editor would misread

A top-level JSON array passed the object guard (typeof [] is 'object')
and loaded as a zero-statement policy, which the next commit would
rewrite to an empty document. Object elements in Action/Resource were
coerced to '[object Object]' and saved that way on the s3tables surface,
which stores policies verbatim. Both now throw, which routes the
document to the JSON tab like other unrepresentable shapes.

* admin: let the JSON tab save documents the structured editor can't model

Save with the JSON tab active required a round-trip through
policyDocToEditorState, so exactly the documents the dialogs shunt to
'JSON tab only' mode (unrepresentable Effect, Resource+NotResource, and
the like) could never be saved - Delete was the only mutation left.
Invalid JSON still blocks; an unrepresentable document now saves and the
editor state stays marked unparsed.

* admin: pin the policy editor to what each consumer's backend supports

The s3tables evaluator has no NotResource/NotPrincipal fields - it
silently drops them, turning Allow+NotResource into allow-everything and
making Deny+NotPrincipal inert - and it only matches s3tables: actions
against s3tables ARNs, while the editor suggested s3: actions and
arn:aws:s3::: resources. New registerPolicyEditor knobs: allowNegation
hides the Not* modes and routes documents using them to the JSON tab;
resourceSuggestions pins the Resource autocomplete to the open
resource's ARN; the S3 Tables dialogs get an s3tables-only action
datalist. requirePrincipal now also hides NotPrincipal, which
policy_engine.ValidateBucketPolicy always rejects, and the client-side
check requires Principal specifically to match that server rule.

* admin: save S3 Tables policies from a button, not form submission

The multi-input structured editor sits inside a form whose Save button
was type=submit, so Enter in any single-line editor input - accepting an
autocomplete suggestion, say - implicitly submitted whatever half-built
statement the editor held, and the backend stores the document verbatim.
A lone statement with no Principal matches nobody, locking out every
non-owner. Save is now an ordinary button and the form ignores
submission.

* admin: block zero-statement policy saves

Committing the active tab before the emptiness check made 'Policy JSON
is required' dead code: an empty editor serializes to {"Statement":[]},
which the s3tables backend stores verbatim - evaluated default-deny for
every non-owner, while the statement-count column keeps showing 'Not
configured'. All three policy dialogs now refuse a save with no
statements and point at Delete instead. The classic bucket modal only
gained a clearer message; the server already rejected the document.

* admin: guard S3 Tables policy mutations against stale and overlapping requests

The save/delete completions ran against whatever resource the shared
modal happened to show by then: a slow PUT for one bucket would hide the
modal mid-edit of another and misattribute its alerts, a late DELETE
cleared the shared textarea over the newly opened resource with its
loaded flag set, and nothing stopped a double-click from firing two
overlapping mutations. Ported the classic modal's pattern: capture the
target on start, flag the mutation in flight with the buttons disabled,
and only touch the UI when the completion still matches the open
resource. Success now reloads the page, which also keeps the Policy
column's statement count honest.

* admin: confirm before deleting an S3 Tables policy

Delete Policy sat next to Save and fired on a single click; with
default-allow enabled one stray click silently dropped the resource
policy and left the bucket open to every principal. Same confirmation
the classic bucket modal already has.

* admin: let a corrupt stored bucket policy be shown, fixed, and deleted

A stored document the decoder rejects made the policy GET 500, and with
the loaded flag never set the modal blocked both Save and Delete - the
one policy an operator most needs to remove was the one they couldn't,
even though the delete path never reads the document. The GET now
returns the raw bytes alongside a null policy; the dialog hands them to
the JSON tab and unblocks the buttons.

* admin: url-encode the bucket name in the policy API calls

The filer lists any directory under the buckets path, names S3 would
never allow included; one carrying '#' or '%' broke the fetch URL or
addressed a different name than the modal shows.

* admin: drop stale edit-policy responses on the IAM policies page

The same race the bucket and S3 Tables dialogs already guard against:
open one policy's editor while its GET stalls, open another, and the
late response populates the editor under the second policy's name -
Update then saves the first policy's statements over the second.

* admin: warn before a bucket policy save drops unsupported fields

The editor tracks unmodeled top-level keys precisely so
confirmPolicyFieldDiscard can warn before the server's Version+Statement
decode discards them, but only the IAM page called it; the bucket modal
saved a pasted document with e.g. a console-generated Id without a word
while the editor kept displaying the field.

* s3: enforce the bucket policy size cap on both surfaces

The 20KB cap lived only in the admin UI, so a larger policy stored via
the S3 API displayed there but could never be re-saved, desyncing the
two writers the cap comment claimed could not desync. The constant now
lives in policy_engine next to the shared validator and PutBucketPolicy
rejects oversized documents with PolicyTooLarge, matching AWS.

* admin: ship the policy editor's fieldset styles with the editor

The .policy-stmt-* rules that undo Bootstrap's full-width legend reset
stayed behind in policies.templ when the editor markup moved to the
shared script, so the bucket and S3 Tables dialogs rendered Actions/
Resource/Principal as full-width jumbo headings. PolicyDatalists is the
component every consumer already renders once; the styles live there
now.

* s3: mirror bucket policy changes into the IAM store from the metadata subscription

The advanced-IAM path appends the bucket-policy:<bucket> document to
every STS/session evaluation, but only this gateway's own PutBucketPolicy
maintained that mirror - a policy tightened or created through the admin
UI (or another gateway) never reached it, so revoked access stayed live
indefinitely, and the delete side was an unimplemented TODO in any case.
The metadata subscription now diffs the stored policy on every bucket
entry change and updates or removes the mirror, covering all writers and
deletion with one mechanism; IAMManager gains the missing
RemoveBucketPolicy.

* admin: deduplicate the bucket policy write path

Set and Delete carried line-for-line identical filer closures;
bucketPolicyMutation already treats nil as clear-the-key. The shared
helper sits below Set's validation, since ValidatePolicy cannot take the
nil document Delete passes.

* s3: drop ValidateBucketPolicy's re-checks of ValidatePolicy rules

Both callers run ValidatePolicy first, which already enforces the
version and at-least-one-statement rules; the duplicates were dead code
with drifted error text.

* admin: seed a new statement's Resource from the pinned suggestions

A fresh statement on the S3 Tables dialogs started with no resource row
at all; seed it with the broadest pinned ARN the same way cfg.bucket
already seeds the classic modal.

* admin: refuse to save Not* fields the backend would silently drop

Hiding the NotResource/NotPrincipal modes was not enough where negation
is disallowed: the JSON tab accepts any valid document (that is its
job), and a statement's Advanced-fields box can reintroduce the keys, so
an s3tables save could still store fields the evaluator drops - turning
Allow+NotResource into allow-everything. commitPolicyActiveTab now runs
a final document-level check over what would actually be saved; Delete
stays available for cleanup.

* s3: move the IAM bucket policy mirror on a bucket rename

A same-directory rename delivers one event carrying both entries, and
the byte-equality short-circuit skipped the new name's mirror when the
policy was unchanged - while the replayed delete for the old name
removed its mirror, leaving the renamed bucket unmirrored. The mirror
decision is now a pure function that removes the old name and writes the
new one regardless of byte equality, with the rename cases unit tested.

* s3: backfill the IAM bucket policy mirror on lazy bucket loads

The metadata subscription only mirrors changes, so a policy that
predates the IAM integration never reached the bucket-policy:<bucket>
mirror and its grants did not bind on the IAM path until the policy was
next modified. The gateway is deliberately lazy at startup (nothing
lists all buckets), so the backfill hooks the same place a bucket's
policy first becomes known: the cold bucket-config load. EnsureBucketPolicy
writes only when no mirror is stored, so repeat loads cost one cached
read.

* s3: reconcile the bucket policy backfill against concurrent changes

The backfill's check-then-write could race an event-driven mirror update
or removal and re-store bytes that were already stale, with no later
event to heal it. EnsureBucketPolicy now reports whether it wrote, and a
write is reconciled against a fresh authoritative entry read: a changed
policy is re-mirrored, a removed one is removed. Anything changing after
that read fires its own event, which finds the backfill's write already
present and supersedes it. The backfill also carries the entry's raw
bytes rather than a re-marshaled document, so the reconcile can
byte-compare.

* s3: prime the bucket policy mirror before advanced-IAM authorization

The backfill ran from the lazy bucket-config load, but IAM authorization
evaluates the bucket-policy:<bucket> mirror before any handler runs - a
grant carried only by a not-yet-mirrored policy denied forever, and the
denied request never reached the code that would have loaded the bucket.
authorizeWithIAM now primes the bucket config first (an in-memory cache
hit once warm), and the backfill runs synchronously on the cold load so
the very first authorization already sees the mirror.
2026-08-24 00:52:01 -07:00

1657 lines
59 KiB
Go

package integration
import (
"context"
"encoding/base64"
"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
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. The store is the
// source of truth for AssumeRoleWithWebIdentity provider resolution once
// Phase 2b lands; in Phase 2a it is read-only and populated from static
// configuration at boot.
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
m.oidcProviderStore = store
}
// GetOIDCProviderStore returns the configured store (may be nil).
func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
return m.oidcProviderStore
}
// 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")
}
return m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
}
// 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")
}
return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
}
// 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
}
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.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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), 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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), 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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), 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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), 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.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), 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"`
}
// 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.oidcProviderStore = store
if config.STS == nil {
return nil
}
for _, pc := range config.STS.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 mirror to store", pc.Name)
continue
}
accountID := ""
if config.STS != nil {
accountID = config.STS.AccountId
}
arn, err := DeriveOIDCProviderARN(accountID, issuer)
if err != nil {
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
continue
}
clientIDs := extractClientIDs(pc.Config)
ctx := context.Background()
// Preserve CreatedAt across reboots when a persistent store already
// has this provider — IAM's GetOpenIDConnectProvider response
// shouldn't shift its CreateDate every time the server restarts.
now := time.Now().UTC()
createdAt := now
if existing, err := store.GetProviderByARN(ctx, m.getFilerAddress(), arn); err == nil && existing != nil && !existing.CreatedAt.IsZero() {
createdAt = existing.CreatedAt
}
rec := &OIDCProviderRecord{
AccountID: accountID,
ARN: arn,
URL: issuer,
ClientIDs: clientIDs,
Thumbprints: extractStringList(pc.Config, "thumbprints"),
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
PolicyClaim: extractString(pc.Config, "policyClaim"),
CreatedAt: createdAt,
UpdatedAt: now,
}
if err := store.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
glog.Warningf("mirror static OIDC provider %s into store: %v", pc.Name, err)
}
}
return nil
}
// 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.
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
if m.oidcProviderStore == nil || m.stsService == nil {
return nil
}
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
if err != nil {
return fmt.Errorf("list OIDC providers: %w", err)
}
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...),
}
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 roleName == "" {
return fmt.Errorf("role name cannot be empty")
}
if roleDef == nil {
return fmt.Errorf("role definition cannot be nil")
}
// 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)
}
}
// Store role definition
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
}
// GetRole retrieves a role definition by name.
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)
// 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 requested duration clamped to the role's
// MaxSessionDuration. A nil requested duration is left nil so the STS
// service's calculateSessionDuration applies the global default (typically
// 1 hour) — substituting the role's max here would silently mint a 12h
// session for any caller who omitted DurationSeconds, which AWS does not
// do. The role-max upper bound still applies in the downstream cap chain
// once the request has a concrete duration.
func capDurationByRole(requested *int64, roleMax int64) *int64 {
if roleMax <= 0 || requested == nil {
return requested
}
if *requested > roleMax {
v := roleMax
return &v
}
return requested
}
// 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)
// Use STS service to assume the role
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
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")
}
}
}
}
// 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, 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)
}