mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-19 12:34:29 +02:00
* 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>
* 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>
* 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>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1208 lines
45 KiB
Go
1208 lines
45 KiB
Go
package sts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
|
)
|
|
|
|
// TrustPolicyValidator interface for validating trust policies during role assumption
|
|
type TrustPolicyValidator interface {
|
|
// ValidateTrustPolicyForWebIdentity validates if a web identity token can assume a role
|
|
// durationSeconds is optional and can be nil
|
|
ValidateTrustPolicyForWebIdentity(ctx context.Context, roleArn string, webIdentityToken string, durationSeconds *int64) error
|
|
|
|
// ValidateTrustPolicyForCredentials validates if credentials can assume a role
|
|
ValidateTrustPolicyForCredentials(ctx context.Context, roleArn string, identity *providers.ExternalIdentity) error
|
|
}
|
|
|
|
// FlexibleDuration wraps time.Duration to support both integer nanoseconds and duration strings in JSON
|
|
type FlexibleDuration struct {
|
|
time.Duration
|
|
}
|
|
|
|
// UnmarshalJSON implements JSON unmarshaling for FlexibleDuration
|
|
// Supports both: 3600000000000 (nanoseconds) and "1h" (duration string)
|
|
func (fd *FlexibleDuration) UnmarshalJSON(data []byte) error {
|
|
// Try to unmarshal as a duration string first (e.g., "1h", "30m")
|
|
var durationStr string
|
|
if err := json.Unmarshal(data, &durationStr); err == nil {
|
|
duration, parseErr := time.ParseDuration(durationStr)
|
|
if parseErr != nil {
|
|
return fmt.Errorf("invalid duration string %q: %w", durationStr, parseErr)
|
|
}
|
|
fd.Duration = duration
|
|
return nil
|
|
}
|
|
|
|
// If that fails, try to unmarshal as an integer (nanoseconds for backward compatibility)
|
|
var nanoseconds int64
|
|
if err := json.Unmarshal(data, &nanoseconds); err == nil {
|
|
fd.Duration = time.Duration(nanoseconds)
|
|
return nil
|
|
}
|
|
|
|
// If both fail, try unmarshaling as a quoted number string (edge case)
|
|
var numberStr string
|
|
if err := json.Unmarshal(data, &numberStr); err == nil {
|
|
if nanoseconds, parseErr := strconv.ParseInt(numberStr, 10, 64); parseErr == nil {
|
|
fd.Duration = time.Duration(nanoseconds)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("unable to parse duration from %s (expected duration string like \"1h\" or integer nanoseconds)", data)
|
|
}
|
|
|
|
// MarshalJSON implements JSON marshaling for FlexibleDuration
|
|
// Always marshals as a human-readable duration string
|
|
func (fd FlexibleDuration) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(fd.Duration.String())
|
|
}
|
|
|
|
// ClaimBasedPolicyRoleArn is the AWS-shaped sentinel ARN that callers pass
|
|
// in the AssumeRoleWithWebIdentity RoleArn field to opt into claim-based
|
|
// policy resolution. Using a recognisable role-style ARN here (rather than
|
|
// requiring an empty RoleArn) lets SDKs and AWS CLI builds that always
|
|
// require RoleArn to be set still reach this code path.
|
|
const ClaimBasedPolicyRoleArn = "arn:aws:iam:::role/sts-claim-based"
|
|
|
|
// IsClaimBasedPolicyRoleArn reports whether the supplied RoleArn opts the
|
|
// caller into claim-based policy mode. Either the empty string (caller
|
|
// omitted RoleArn entirely; common for SDKs that didn't expect to need one)
|
|
// or the explicit sentinel triggers the mode.
|
|
func IsClaimBasedPolicyRoleArn(arn string) bool {
|
|
return arn == "" || arn == ClaimBasedPolicyRoleArn
|
|
}
|
|
|
|
// STSService provides Security Token Service functionality
|
|
// This service is now completely stateless - all session information is embedded
|
|
// in JWT tokens, eliminating the need for session storage and enabling true
|
|
// distributed operation without shared state
|
|
type STSService struct {
|
|
Config *STSConfig // Public for access by other components
|
|
initialized bool
|
|
providers map[string]providers.IdentityProvider
|
|
issuerToProvider map[string]providers.IdentityProvider // Efficient issuer-based provider lookup
|
|
tokenGenerator *TokenGenerator
|
|
credGenerator *CredentialGenerator
|
|
trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation
|
|
|
|
// iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view
|
|
// of providers persisted in the IAM-managed OIDCProviderStore; it is
|
|
// atomically replaced by SetIAMManagedOIDCProviders whenever the store
|
|
// changes (either via a local IAM API call or a metadata-subscribe event
|
|
// from a peer). Lookups consult this map first and fall back to the
|
|
// static-config issuerToProvider so admin-managed entries always take
|
|
// precedence over the bootstrap config.
|
|
//
|
|
// The slice value lets multiple records share an issuer when each is
|
|
// scoped to a different account; lookup picks the entry whose AccountID
|
|
// matches the role being assumed (or the global, AccountID="" entry as a
|
|
// fallback). Without this, two accounts' records for the same issuer
|
|
// would race for one map slot and a token could be validated by a
|
|
// provider that wasn't scoped to the role's account.
|
|
iamManagedOIDCMu sync.RWMutex
|
|
iamManagedOIDCByIssuer map[string][]ScopedOIDCProvider
|
|
}
|
|
|
|
// ScopedOIDCProvider pairs an OIDC IdentityProvider with the account it is
|
|
// scoped to. AccountID="" means the provider is global (usable from any
|
|
// account).
|
|
type ScopedOIDCProvider struct {
|
|
AccountID string
|
|
Provider providers.IdentityProvider
|
|
}
|
|
|
|
// GetTokenGenerator returns the token generator used by the STS service.
|
|
// This keeps the underlying field unexported while still allowing read-only access.
|
|
func (s *STSService) GetTokenGenerator() *TokenGenerator {
|
|
return s.tokenGenerator
|
|
}
|
|
|
|
// GetCredentialGenerator returns the credential generator used by the STS service.
|
|
func (s *STSService) GetCredentialGenerator() *CredentialGenerator {
|
|
return s.credGenerator
|
|
}
|
|
|
|
// STSConfig holds STS service configuration
|
|
type STSConfig struct {
|
|
// TokenDuration is the default duration for issued tokens
|
|
TokenDuration FlexibleDuration `json:"tokenDuration"`
|
|
|
|
// MaxSessionLength is the maximum duration for any session
|
|
MaxSessionLength FlexibleDuration `json:"maxSessionLength"`
|
|
|
|
// Issuer is the STS issuer identifier
|
|
Issuer string `json:"issuer"`
|
|
|
|
// SigningKey is used to sign session tokens
|
|
SigningKey []byte `json:"signingKey"`
|
|
|
|
// AccountId is the AWS account ID used for federated user ARNs
|
|
// Defaults to "111122223333" if not specified
|
|
AccountId string `json:"accountId,omitempty"`
|
|
|
|
// Providers configuration - enables automatic provider loading
|
|
Providers []*ProviderConfig `json:"providers,omitempty"`
|
|
}
|
|
|
|
// ProviderConfig holds identity provider configuration
|
|
type ProviderConfig struct {
|
|
// Name is the unique identifier for the provider
|
|
Name string `json:"name"`
|
|
|
|
// Type specifies the provider type (oidc, ldap, etc.)
|
|
Type string `json:"type"`
|
|
|
|
// Config contains provider-specific configuration
|
|
Config map[string]interface{} `json:"config"`
|
|
|
|
// Enabled indicates if this provider should be active
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// AssumeRoleWithWebIdentityRequest represents a request to assume role with web identity
|
|
type AssumeRoleWithWebIdentityRequest struct {
|
|
// RoleArn is the ARN of the role to assume
|
|
RoleArn string `json:"RoleArn"`
|
|
|
|
// WebIdentityToken is the OIDC token from the identity provider
|
|
WebIdentityToken string `json:"WebIdentityToken"`
|
|
|
|
// RoleSessionName is a name for the assumed role session
|
|
RoleSessionName string `json:"RoleSessionName"`
|
|
|
|
// DurationSeconds is the duration of the role session (optional)
|
|
DurationSeconds *int64 `json:"DurationSeconds,omitempty"`
|
|
|
|
// Policy is an optional session policy (optional)
|
|
Policy *string `json:"Policy,omitempty"`
|
|
|
|
// RoleId is the assumed role's unique ID, set by the IAM manager after it
|
|
// resolves the role. It is embedded in the session so the session is bound
|
|
// to this role and not to whatever role later holds the same name.
|
|
RoleId string `json:"-"`
|
|
}
|
|
|
|
// AssumeRoleWithCredentialsRequest represents a request to assume role with username/password
|
|
type AssumeRoleWithCredentialsRequest struct {
|
|
// RoleArn is the ARN of the role to assume
|
|
RoleArn string `json:"RoleArn"`
|
|
|
|
// Username is the username for authentication
|
|
Username string `json:"Username"`
|
|
|
|
// Password is the password for authentication
|
|
Password string `json:"Password"`
|
|
|
|
// RoleSessionName is a name for the assumed role session
|
|
RoleSessionName string `json:"RoleSessionName"`
|
|
|
|
// ProviderName is the name of the identity provider to use
|
|
ProviderName string `json:"ProviderName"`
|
|
|
|
// DurationSeconds is the duration of the role session (optional)
|
|
DurationSeconds *int64 `json:"DurationSeconds,omitempty"`
|
|
|
|
// Policy is an optional session policy (optional)
|
|
Policy *string `json:"Policy,omitempty"`
|
|
|
|
// RoleId is set by the IAM manager; see AssumeRoleWithWebIdentityRequest.
|
|
RoleId string `json:"-"`
|
|
}
|
|
|
|
// AssumeRoleResponse represents the response from assume role operations
|
|
type AssumeRoleResponse struct {
|
|
// Credentials contains the temporary security credentials
|
|
Credentials *Credentials `json:"Credentials"`
|
|
|
|
// AssumedRoleUser contains information about the assumed role user
|
|
AssumedRoleUser *AssumedRoleUser `json:"AssumedRoleUser"`
|
|
|
|
// PackedPolicySize is the percentage of max policy size used (AWS compatibility)
|
|
PackedPolicySize *int64 `json:"PackedPolicySize,omitempty"`
|
|
}
|
|
|
|
// Credentials represents temporary security credentials
|
|
type Credentials struct {
|
|
// AccessKeyId is the access key ID
|
|
AccessKeyId string `json:"AccessKeyId"`
|
|
|
|
// SecretAccessKey is the secret access key
|
|
SecretAccessKey string `json:"SecretAccessKey"`
|
|
|
|
// SessionToken is the session token
|
|
SessionToken string `json:"SessionToken"`
|
|
|
|
// Expiration is when the credentials expire
|
|
Expiration time.Time `json:"Expiration"`
|
|
}
|
|
|
|
// AssumedRoleUser contains information about the assumed role user
|
|
type AssumedRoleUser struct {
|
|
// AssumedRoleId is the unique identifier of the assumed role
|
|
AssumedRoleId string `json:"AssumedRoleId"`
|
|
|
|
// Arn is the ARN of the assumed role user
|
|
Arn string `json:"Arn"`
|
|
|
|
// Subject is the subject identifier from the identity provider
|
|
Subject string `json:"Subject,omitempty"`
|
|
}
|
|
|
|
// SessionInfo represents information about an active session
|
|
type SessionInfo struct {
|
|
// SessionId is the unique identifier for the session
|
|
SessionId string `json:"sessionId"`
|
|
|
|
// SessionName is the name of the role session
|
|
SessionName string `json:"sessionName"`
|
|
|
|
// RoleArn is the ARN of the assumed role
|
|
RoleArn string `json:"roleArn"`
|
|
|
|
// AssumedRoleUser contains information about the assumed role user
|
|
AssumedRoleUser string `json:"assumedRoleUser"`
|
|
|
|
// Principal is the principal ARN
|
|
Principal string `json:"principal"`
|
|
|
|
// Subject is the subject identifier from the identity provider
|
|
Subject string `json:"subject"`
|
|
|
|
// Provider is the identity provider used (legacy field)
|
|
Provider string `json:"provider"`
|
|
|
|
// IdentityProvider is the identity provider used
|
|
IdentityProvider string `json:"identityProvider"`
|
|
|
|
// ExternalUserId is the external user identifier from the provider
|
|
ExternalUserId string `json:"externalUserId"`
|
|
|
|
// ProviderIssuer is the issuer from the identity provider
|
|
ProviderIssuer string `json:"providerIssuer"`
|
|
|
|
// Policies are the policies associated with this session
|
|
Policies []string `json:"policies"`
|
|
|
|
// SessionPolicy is the inline session policy JSON (optional)
|
|
SessionPolicy string `json:"sessionPolicy,omitempty"`
|
|
|
|
// RequestContext contains additional request context for policy evaluation
|
|
RequestContext map[string]interface{} `json:"requestContext,omitempty"`
|
|
|
|
// CreatedAt is when the session was created
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
|
|
// ExpiresAt is when the session expires
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
|
|
// Credentials are the temporary credentials for this session
|
|
Credentials *Credentials `json:"credentials"`
|
|
|
|
// ParentUser is the stable hashed identity (sub+iss) derived at federation time.
|
|
ParentUser string `json:"parentUser,omitempty"`
|
|
|
|
// RoleId is the unique ID of the role the session was issued for; empty
|
|
// for sessions issued before role IDs were recorded.
|
|
RoleId string `json:"roleId,omitempty"`
|
|
}
|
|
|
|
// NewSTSService creates a new STS service
|
|
func NewSTSService() *STSService {
|
|
return &STSService{
|
|
providers: make(map[string]providers.IdentityProvider),
|
|
issuerToProvider: make(map[string]providers.IdentityProvider),
|
|
}
|
|
}
|
|
|
|
// Initialize initializes the STS service with configuration
|
|
func (s *STSService) Initialize(config *STSConfig) error {
|
|
if config == nil {
|
|
return fmt.Errorf(ErrConfigCannotBeNil)
|
|
}
|
|
|
|
// Apply defaults before validation
|
|
config.ApplyDefaults()
|
|
|
|
if err := s.validateConfig(config); err != nil {
|
|
return fmt.Errorf("invalid STS configuration: %w", err)
|
|
}
|
|
|
|
s.Config = config
|
|
|
|
// Initialize token generator for stateless JWT operations
|
|
s.tokenGenerator = NewTokenGenerator(config.SigningKey, config.Issuer)
|
|
s.credGenerator = NewCredentialGenerator(config.SigningKey)
|
|
|
|
// Load identity providers from configuration
|
|
if err := s.loadProvidersFromConfig(config); err != nil {
|
|
return fmt.Errorf("failed to load identity providers: %w", err)
|
|
}
|
|
|
|
s.initialized = true
|
|
return nil
|
|
}
|
|
|
|
// ApplyDefaults applies default values to the STS configuration
|
|
func (c *STSConfig) ApplyDefaults() {
|
|
if c.TokenDuration.Duration <= 0 {
|
|
c.TokenDuration.Duration = time.Duration(DefaultTokenDuration) * time.Second
|
|
}
|
|
|
|
if c.MaxSessionLength.Duration <= 0 {
|
|
c.MaxSessionLength.Duration = time.Duration(DefaultMaxSessionLength) * time.Second
|
|
}
|
|
|
|
if c.Issuer == "" {
|
|
c.Issuer = DefaultIssuer
|
|
}
|
|
}
|
|
|
|
// validateConfig validates the STS configuration
|
|
func (s *STSService) validateConfig(config *STSConfig) error {
|
|
if config.TokenDuration.Duration <= 0 {
|
|
return fmt.Errorf(ErrInvalidTokenDuration)
|
|
}
|
|
|
|
if config.MaxSessionLength.Duration <= 0 {
|
|
return fmt.Errorf(ErrInvalidMaxSessionLength)
|
|
}
|
|
|
|
if config.Issuer == "" {
|
|
return fmt.Errorf(ErrIssuerRequired)
|
|
}
|
|
|
|
if len(config.SigningKey) < MinSigningKeyLength {
|
|
return fmt.Errorf(ErrSigningKeyTooShort, MinSigningKeyLength)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadProvidersFromConfig loads identity providers from configuration
|
|
func (s *STSService) loadProvidersFromConfig(config *STSConfig) error {
|
|
if len(config.Providers) == 0 {
|
|
glog.V(2).Infof("No providers configured in STS config")
|
|
return nil
|
|
}
|
|
|
|
factory := NewProviderFactory()
|
|
|
|
// Load all providers from configuration
|
|
providersMap, err := factory.LoadProvidersFromConfig(config.Providers)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load providers from config: %w", err)
|
|
}
|
|
|
|
// Replace current providers with new ones
|
|
s.providers = providersMap
|
|
|
|
// Also populate the issuerToProvider map for efficient and secure JWT validation
|
|
s.issuerToProvider = make(map[string]providers.IdentityProvider)
|
|
for name, provider := range s.providers {
|
|
issuer := s.extractIssuerFromProvider(provider)
|
|
if issuer != "" {
|
|
if _, exists := s.issuerToProvider[issuer]; exists {
|
|
glog.Warningf("Duplicate issuer %s found for provider %s. Overwriting.", issuer, name)
|
|
}
|
|
s.issuerToProvider[issuer] = provider
|
|
glog.V(2).Infof("Registered provider %s with issuer %s for efficient lookup", name, issuer)
|
|
}
|
|
}
|
|
|
|
glog.V(1).Infof("Successfully loaded %d identity providers: %v",
|
|
len(s.providers), s.getProviderNames())
|
|
|
|
return nil
|
|
}
|
|
|
|
// getProviderNames returns list of loaded provider names
|
|
func (s *STSService) getProviderNames() []string {
|
|
names := make([]string, 0, len(s.providers))
|
|
for name := range s.providers {
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// IsInitialized returns whether the service is initialized
|
|
func (s *STSService) IsInitialized() bool {
|
|
return s.initialized
|
|
}
|
|
|
|
// RegisterProvider registers an identity provider
|
|
func (s *STSService) RegisterProvider(provider providers.IdentityProvider) error {
|
|
if provider == nil {
|
|
return fmt.Errorf(ErrProviderCannotBeNil)
|
|
}
|
|
|
|
name := provider.Name()
|
|
if name == "" {
|
|
return fmt.Errorf(ErrProviderNameEmpty)
|
|
}
|
|
|
|
s.providers[name] = provider
|
|
|
|
// Try to extract issuer information for efficient lookup
|
|
// This is a best-effort approach for different provider types
|
|
issuer := s.extractIssuerFromProvider(provider)
|
|
if issuer != "" {
|
|
s.issuerToProvider[issuer] = provider
|
|
glog.V(2).Infof("Registered provider %s with issuer %s for efficient lookup", name, issuer)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractIssuerFromProvider attempts to extract issuer information from different provider types
|
|
func (s *STSService) extractIssuerFromProvider(provider providers.IdentityProvider) string {
|
|
// Handle different provider types
|
|
switch p := provider.(type) {
|
|
case interface{ GetIssuer() string }:
|
|
// For providers that implement GetIssuer() method
|
|
return p.GetIssuer()
|
|
default:
|
|
// For other provider types, we'll rely on JWT parsing during validation
|
|
// This is still more efficient than the current brute-force approach
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// GetProviders returns all registered identity providers
|
|
func (s *STSService) GetProviders() map[string]providers.IdentityProvider {
|
|
return s.providers
|
|
}
|
|
|
|
// SetIAMManagedOIDCProviders atomically replaces the IAM-managed OIDC
|
|
// provider map. Pass nil or an empty map to clear all managed entries. The
|
|
// caller passes a fully-built map keyed by issuer URL; the slice value lets
|
|
// per-account records coexist under the same issuer.
|
|
func (s *STSService) SetIAMManagedOIDCProviders(byIssuer map[string][]ScopedOIDCProvider) {
|
|
// Defensively copy so callers can keep mutating their map without affecting
|
|
// in-flight lookups. A nil input becomes an empty map for cheap reads.
|
|
cp := make(map[string][]ScopedOIDCProvider, len(byIssuer))
|
|
for issuer, scoped := range byIssuer {
|
|
if issuer == "" {
|
|
continue
|
|
}
|
|
entries := make([]ScopedOIDCProvider, 0, len(scoped))
|
|
for _, sp := range scoped {
|
|
if sp.Provider == nil {
|
|
continue
|
|
}
|
|
entries = append(entries, sp)
|
|
}
|
|
if len(entries) > 0 {
|
|
cp[issuer] = entries
|
|
}
|
|
}
|
|
s.iamManagedOIDCMu.Lock()
|
|
s.iamManagedOIDCByIssuer = cp
|
|
s.iamManagedOIDCMu.Unlock()
|
|
}
|
|
|
|
// lookupOIDCProviderForAccount returns the provider that should validate
|
|
// tokens from `issuer` when the caller is assuming a role in `accountID`.
|
|
// Selection order:
|
|
// 1. IAM-managed record exactly scoped to accountID (when accountID != "");
|
|
// 2. IAM-managed record with empty AccountID (global);
|
|
// 3. static-config issuerToProvider (legacy path; account-agnostic).
|
|
//
|
|
// Without the (issuer, account) key, two records for the same issuer (e.g.
|
|
// account A with clientIDs=[a] and account B with clientIDs=[b]) would race
|
|
// for one map slot, and a token destined for account B could be validated by
|
|
// account A's record. The role-account check in
|
|
// IAMManager.enforceProviderAccountScope blocks the cross-account assumption
|
|
// itself, but the validation must use the right record's clientIDs and
|
|
// thumbprints in the first place.
|
|
func (s *STSService) lookupOIDCProviderForAccount(issuer, accountID string) (providers.IdentityProvider, bool) {
|
|
s.iamManagedOIDCMu.RLock()
|
|
scoped := s.iamManagedOIDCByIssuer[issuer]
|
|
var globalMatch providers.IdentityProvider
|
|
for _, sp := range scoped {
|
|
if accountID != "" && sp.AccountID == accountID {
|
|
s.iamManagedOIDCMu.RUnlock()
|
|
return sp.Provider, true
|
|
}
|
|
if sp.AccountID == "" && globalMatch == nil {
|
|
globalMatch = sp.Provider
|
|
}
|
|
}
|
|
s.iamManagedOIDCMu.RUnlock()
|
|
if globalMatch != nil {
|
|
return globalMatch, true
|
|
}
|
|
p, ok := s.issuerToProvider[issuer]
|
|
return p, ok
|
|
}
|
|
|
|
// SetTrustPolicyValidator sets the trust policy validator for role assumption validation
|
|
func (s *STSService) SetTrustPolicyValidator(validator TrustPolicyValidator) {
|
|
s.trustPolicyValidator = validator
|
|
}
|
|
|
|
// AssumeRoleWithWebIdentity assumes a role using a web identity token (OIDC)
|
|
// This method is now completely stateless - all session information is embedded in the JWT token
|
|
func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf(ErrSTSServiceNotInitialized)
|
|
}
|
|
|
|
if request == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
// Validate request parameters
|
|
if err := s.validateAssumeRoleWithWebIdentityRequest(request); err != nil {
|
|
return nil, fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
|
|
sessionPolicy := ""
|
|
if request.Policy != nil {
|
|
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid session policy: %w", err)
|
|
}
|
|
sessionPolicy = normalized
|
|
}
|
|
|
|
// 1. Validate the web identity token with appropriate provider. The role
|
|
// ARN's account scopes which IAM-managed record may validate the token —
|
|
// see lookupOIDCProviderForAccount. ParseRoleARN returns "" when the
|
|
// caller passed a legacy or claim-based ARN, in which case lookup falls
|
|
// back to a global (account-less) record only.
|
|
roleAccountID := utils.ParseRoleARN(request.RoleArn).AccountID
|
|
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken, roleAccountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to validate web identity token: %w", err)
|
|
}
|
|
|
|
// 2. Decide between concrete-role mode and claim-based policy mode.
|
|
// Claim-based mode requires both the sentinel RoleArn and a non-empty
|
|
// ClaimPolicies list — the second check guards against an IDP that just
|
|
// happens not to emit the policy claim today.
|
|
claimMode := IsClaimBasedPolicyRoleArn(request.RoleArn) && len(externalIdentity.ClaimPolicies) > 0
|
|
effectiveRoleArn := request.RoleArn
|
|
if claimMode {
|
|
// Replace an empty RoleArn with the constant sentinel so the assumed-
|
|
// role ARN GenerateAssumedRoleArn produces below carries a stable,
|
|
// session-name-keyed principal that policy evaluation can log and
|
|
// rate-limit on.
|
|
effectiveRoleArn = ClaimBasedPolicyRoleArn
|
|
} else if request.RoleArn == "" {
|
|
return nil, fmt.Errorf("RoleArn is required when claim-based policy mode is not configured")
|
|
} else if request.RoleArn == ClaimBasedPolicyRoleArn {
|
|
return nil, fmt.Errorf("claim-based policy mode requires the IDP to emit policies via the configured policyClaim")
|
|
}
|
|
|
|
// 3. Trust-policy validation only runs in concrete-role mode. In
|
|
// claim-mode the IDP is the sole authority for both authentication and
|
|
// authorization, so there is no role definition to consult.
|
|
if !claimMode {
|
|
if err := s.validateRoleAssumptionForWebIdentity(ctx, request.RoleArn, request.WebIdentityToken, request.DurationSeconds); err != nil {
|
|
return nil, fmt.Errorf("role assumption denied: %w", err)
|
|
}
|
|
}
|
|
|
|
// 4. Calculate session duration
|
|
sessionDuration := s.CalculateSessionDuration(request.DurationSeconds)
|
|
expiresAt := time.Now().Add(sessionDuration)
|
|
|
|
// 5. Generate session ID and credentials
|
|
sessionId, err := GenerateSessionId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
|
}
|
|
|
|
credentials, err := s.credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate credentials: %w", err)
|
|
}
|
|
|
|
// 6. Create comprehensive JWT session token with all session information embedded
|
|
assumedRoleUser := &AssumedRoleUser{
|
|
AssumedRoleId: effectiveRoleArn,
|
|
Arn: GenerateAssumedRoleArn(effectiveRoleArn, request.RoleSessionName),
|
|
Subject: externalIdentity.UserID,
|
|
}
|
|
|
|
// Create request context from identity attributes for policy evaluation
|
|
requestContext := make(map[string]interface{}, len(externalIdentity.Attributes)+3)
|
|
|
|
// Add generic attributes (including preferred_username, etc.)
|
|
if externalIdentity.Attributes != nil {
|
|
for k, v := range externalIdentity.Attributes {
|
|
requestContext[k] = v
|
|
}
|
|
}
|
|
|
|
// Add standard OIDC fields if not already present
|
|
if _, ok := requestContext["email"]; !ok && externalIdentity.Email != "" {
|
|
requestContext["email"] = externalIdentity.Email
|
|
}
|
|
if _, ok := requestContext["name"]; !ok && externalIdentity.DisplayName != "" {
|
|
requestContext["name"] = externalIdentity.DisplayName
|
|
}
|
|
// Add sub as well since it's commonly used
|
|
requestContext["sub"] = externalIdentity.UserID
|
|
|
|
// Surface federated group memberships so resource (permission) policies can do
|
|
// group-based ABAC - not just role trust policies. Stored as a []string; the
|
|
// string-condition evaluator already handles multi-valued context keys, and the
|
|
// S3 middleware additionally exposes it as jwt:groups. Without this, "groups"
|
|
// was excluded from the OIDC attributes (see oidc_provider processedClaims) and
|
|
// so only usable at assume-time (trust policy), never at request-time - meaning
|
|
// a single role's permission policy could not scope access by the caller's
|
|
// groups (aggregate/ABAC).
|
|
if len(externalIdentity.Groups) > 0 {
|
|
requestContext["groups"] = externalIdentity.Groups
|
|
}
|
|
|
|
// Same for the caller's roles (the `roles` claim). Surfaced as a []string so a
|
|
// resource policy can gate on jwt:roles - the S3 middleware exposes it that way.
|
|
// Without this, "roles" was excluded from the OIDC attributes (processedClaims)
|
|
// and so unusable in a request-time permission policy.
|
|
if len(externalIdentity.Roles) > 0 {
|
|
requestContext["roles"] = externalIdentity.Roles
|
|
}
|
|
|
|
// Compute a stable parent-user hash from (sub, iss). Only this tuple is
|
|
// guaranteed stable across token refresh per OIDC Core 1.0, so this is the
|
|
// right key for any per-identity state (audit trail, future quotas).
|
|
parentUser := ComputeParentUser(externalIdentity.UserID, externalIdentity.Issuer)
|
|
if parentUser != "" {
|
|
// Surface as aws:userid so policies can reference it directly without
|
|
// caring about token-rotation churn.
|
|
requestContext["aws:userid"] = parentUser
|
|
}
|
|
|
|
// Surface principal session tags as aws:PrincipalTag/<key>. The OIDC
|
|
// provider has already filtered the claim namespace through its
|
|
// AllowedPrincipalTagKeys list (see filterPrincipalTags), so anything
|
|
// reaching us here is on the operator's opt-in list. The full claim
|
|
// is dropped if the allowlist is empty, which is the secure default.
|
|
for k, v := range externalIdentity.PrincipalTags {
|
|
requestContext["aws:PrincipalTag/"+k] = v
|
|
}
|
|
|
|
// Create rich JWT claims with all session information
|
|
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
|
|
WithSessionName(request.RoleSessionName).
|
|
WithRoleInfo(effectiveRoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
|
WithRoleId(request.RoleId).
|
|
WithIdentityProvider(provider.Name(), externalIdentity.UserID, externalIdentity.Issuer).
|
|
WithMaxDuration(sessionDuration).
|
|
WithRequestContext(requestContext)
|
|
if claimMode {
|
|
sessionClaims.WithPolicies(externalIdentity.ClaimPolicies)
|
|
}
|
|
if parentUser != "" {
|
|
sessionClaims.WithParentUser(parentUser)
|
|
}
|
|
if sessionPolicy != "" {
|
|
sessionClaims.WithSessionPolicy(sessionPolicy)
|
|
}
|
|
|
|
// Generate self-contained JWT token with all session information
|
|
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate JWT session token: %w", err)
|
|
}
|
|
credentials.SessionToken = jwtToken
|
|
|
|
// 6. Build and return response (no session storage needed!)
|
|
|
|
return &AssumeRoleResponse{
|
|
Credentials: credentials,
|
|
AssumedRoleUser: assumedRoleUser,
|
|
}, nil
|
|
}
|
|
|
|
// AssumeRoleWithCredentials assumes a role using username/password credentials
|
|
// This method is now completely stateless - all session information is embedded in the JWT token
|
|
func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *AssumeRoleWithCredentialsRequest) (*AssumeRoleResponse, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf("STS service not initialized")
|
|
}
|
|
|
|
if request == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
|
|
// Validate request parameters
|
|
if err := s.validateAssumeRoleWithCredentialsRequest(request); err != nil {
|
|
return nil, fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
|
|
sessionPolicy := ""
|
|
if request.Policy != nil {
|
|
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid session policy: %w", err)
|
|
}
|
|
sessionPolicy = normalized
|
|
}
|
|
|
|
// 1. Get the specified provider
|
|
provider, exists := s.providers[request.ProviderName]
|
|
if !exists {
|
|
return nil, fmt.Errorf("identity provider not found: %s", request.ProviderName)
|
|
}
|
|
|
|
// 2. Validate credentials with the specified provider
|
|
credentials := request.Username + ":" + request.Password
|
|
externalIdentity, err := provider.Authenticate(ctx, credentials)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to authenticate credentials: %w", err)
|
|
}
|
|
|
|
// 3. Check if the role exists and can be assumed (includes trust policy validation)
|
|
if err := s.validateRoleAssumptionForCredentials(ctx, request.RoleArn, externalIdentity); err != nil {
|
|
return nil, fmt.Errorf("role assumption denied: %w", err)
|
|
}
|
|
|
|
// 4-7. Mint the session
|
|
return s.issueSession(request.RoleArn, request.RoleId, request.RoleSessionName, sessionPolicy,
|
|
request.DurationSeconds, provider.Name(), externalIdentity.UserID)
|
|
}
|
|
|
|
// issueSession mints temporary credentials and the self-contained JWT that
|
|
// carries the whole session, shared by every assume-role entry point.
|
|
func (s *STSService) issueSession(roleArn, roleId, roleSessionName, sessionPolicy string,
|
|
durationSeconds *int64, providerName, subject string) (*AssumeRoleResponse, error) {
|
|
|
|
sessionDuration := s.CalculateSessionDuration(durationSeconds)
|
|
expiresAt := time.Now().Add(sessionDuration)
|
|
|
|
sessionId, err := GenerateSessionId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
|
}
|
|
|
|
tempCredentials, err := s.credGenerator.GenerateTemporaryCredentials(sessionId, expiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate credentials: %w", err)
|
|
}
|
|
|
|
assumedRoleUser := &AssumedRoleUser{
|
|
AssumedRoleId: roleArn,
|
|
Arn: GenerateAssumedRoleArn(roleArn, roleSessionName),
|
|
Subject: subject,
|
|
}
|
|
|
|
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
|
|
WithSessionName(roleSessionName).
|
|
WithRoleInfo(roleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
|
WithRoleId(roleId).
|
|
WithIdentityProvider(providerName, subject, "").
|
|
WithMaxDuration(sessionDuration)
|
|
if sessionPolicy != "" {
|
|
sessionClaims.WithSessionPolicy(sessionPolicy)
|
|
}
|
|
|
|
jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate JWT session token: %w", err)
|
|
}
|
|
tempCredentials.SessionToken = jwtToken
|
|
|
|
return &AssumeRoleResponse{
|
|
Credentials: tempCredentials,
|
|
AssumedRoleUser: assumedRoleUser,
|
|
}, nil
|
|
}
|
|
|
|
// validateSessionDurationSeconds bounds a requested session lifetime the way
|
|
// AWS STS does. Every assume-role entry point runs it, so a duration that came
|
|
// from configuration is checked the same as one from a request.
|
|
func (s *STSService) validateSessionDurationSeconds(durationSeconds *int64) error {
|
|
if durationSeconds == nil {
|
|
return nil
|
|
}
|
|
maxSec := int64(DefaultMaxSessionLength)
|
|
if s.Config != nil && s.Config.MaxSessionLength.Duration > 0 {
|
|
configuredMax := int64(s.Config.MaxSessionLength.Duration / time.Second)
|
|
if configuredMax >= 900 {
|
|
maxSec = configuredMax
|
|
}
|
|
}
|
|
if *durationSeconds < 900 || *durationSeconds > maxSec {
|
|
return fmt.Errorf("DurationSeconds must be between 900 and %d seconds", maxSec)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AssumeRoleForPrincipalRequest asks for a session on behalf of a principal the
|
|
// calling service has already authenticated.
|
|
type AssumeRoleForPrincipalRequest struct {
|
|
// RoleArn is the ARN of the role to assume.
|
|
RoleArn string
|
|
|
|
// Principal identifies the already-authenticated caller.
|
|
Principal string
|
|
|
|
// RoleSessionName names the session.
|
|
RoleSessionName string
|
|
|
|
// ProviderName records how the caller was authenticated.
|
|
ProviderName string
|
|
|
|
// Policy optionally narrows the session below the role's own permissions.
|
|
Policy *string
|
|
|
|
// DurationSeconds requests a session lifetime.
|
|
DurationSeconds *int64
|
|
}
|
|
|
|
// AssumeRoleForPrincipal issues session credentials for a caller that a
|
|
// SeaweedFS service authenticated itself, such as the Iceberg catalog vending
|
|
// scoped credentials for a table it has already authorized. There is no
|
|
// external token left to verify at this point, so the role's trust policy is
|
|
// the control point and is still enforced.
|
|
func (s *STSService) AssumeRoleForPrincipal(ctx context.Context, request *AssumeRoleForPrincipalRequest) (*AssumeRoleResponse, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf(ErrSTSServiceNotInitialized)
|
|
}
|
|
if request == nil {
|
|
return nil, fmt.Errorf("request cannot be nil")
|
|
}
|
|
if request.RoleArn == "" {
|
|
return nil, fmt.Errorf("role ARN cannot be empty")
|
|
}
|
|
if request.Principal == "" {
|
|
return nil, fmt.Errorf("principal cannot be empty")
|
|
}
|
|
if err := s.validateSessionDurationSeconds(request.DurationSeconds); err != nil {
|
|
return nil, fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
|
|
sessionPolicy := ""
|
|
if request.Policy != nil {
|
|
normalized, err := NormalizeSessionPolicy(*request.Policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid session policy: %w", err)
|
|
}
|
|
sessionPolicy = normalized
|
|
}
|
|
|
|
identity := &providers.ExternalIdentity{
|
|
UserID: request.Principal,
|
|
DisplayName: request.Principal,
|
|
Provider: request.ProviderName,
|
|
}
|
|
if err := s.validateRoleAssumptionForCredentials(ctx, request.RoleArn, identity); err != nil {
|
|
return nil, fmt.Errorf("role assumption denied: %w", err)
|
|
}
|
|
|
|
// Iceberg credential vending resolves no role definition, so its sessions
|
|
// carry no role ID and stay bound by name.
|
|
return s.issueSession(request.RoleArn, "", request.RoleSessionName, sessionPolicy,
|
|
request.DurationSeconds, request.ProviderName, request.Principal)
|
|
}
|
|
|
|
// ValidateSessionToken validates a session token and returns session information
|
|
// This method is now completely stateless - all session information is extracted from the JWT token
|
|
func (s *STSService) ValidateSessionToken(ctx context.Context, sessionToken string) (*SessionInfo, error) {
|
|
if !s.initialized {
|
|
return nil, fmt.Errorf(ErrSTSServiceNotInitialized)
|
|
}
|
|
|
|
if sessionToken == "" {
|
|
return nil, fmt.Errorf(ErrSessionTokenCannotBeEmpty)
|
|
}
|
|
|
|
// Validate JWT and extract comprehensive session claims
|
|
claims, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(ErrSessionValidationFailed, err)
|
|
}
|
|
|
|
// Convert JWT claims back to SessionInfo
|
|
// All session information is embedded in the JWT token itself
|
|
return claims.ToSessionInfo(s.credGenerator), nil
|
|
}
|
|
|
|
// NOTE: Session revocation is not supported in the stateless JWT design.
|
|
//
|
|
// In a stateless JWT system, tokens cannot be revoked without implementing a token blacklist,
|
|
// which would break the stateless architecture. Tokens remain valid until their natural
|
|
// expiration time.
|
|
//
|
|
// For applications requiring token revocation, consider:
|
|
// 1. Using shorter token lifespans (e.g., 15-30 minutes)
|
|
// 2. Implementing a distributed token blacklist (breaks stateless design)
|
|
// 3. Including a "jti" (JWT ID) claim for tracking specific tokens
|
|
//
|
|
// Use ValidateSessionToken() to verify if a token is valid and not expired.
|
|
|
|
// Helper methods for AssumeRoleWithWebIdentity
|
|
|
|
// validateAssumeRoleWithWebIdentityRequest validates the request parameters.
|
|
//
|
|
// RoleArn validation lives in AssumeRoleWithWebIdentity itself rather than
|
|
// here: once we've parsed the JWT we know whether the caller has
|
|
// ClaimPolicies and can decide between concrete-role mode and claim-mode,
|
|
// which in turn determines whether an empty/sentinel RoleArn is acceptable.
|
|
func (s *STSService) validateAssumeRoleWithWebIdentityRequest(request *AssumeRoleWithWebIdentityRequest) error {
|
|
if request.WebIdentityToken == "" {
|
|
return fmt.Errorf("WebIdentityToken is required")
|
|
}
|
|
|
|
if request.RoleSessionName == "" {
|
|
return fmt.Errorf("RoleSessionName is required")
|
|
}
|
|
|
|
return s.validateSessionDurationSeconds(request.DurationSeconds)
|
|
}
|
|
|
|
// validateWebIdentityToken validates the web identity token with strict issuer-to-provider mapping
|
|
// SECURITY: JWT tokens with a specific issuer claim MUST only be validated by the provider for that issuer
|
|
// SECURITY: This method only accepts JWT tokens. Non-JWT authentication must use AssumeRoleWithCredentials with explicit ProviderName.
|
|
func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
|
// Try to extract issuer from JWT token for strict validation
|
|
issuer, err := s.extractIssuerFromJWT(token)
|
|
if err != nil {
|
|
// Token is not a valid JWT or cannot be parsed
|
|
// SECURITY: Web identity tokens MUST be JWT tokens. Non-JWT authentication flows
|
|
// should use AssumeRoleWithCredentials with explicit ProviderName to prevent
|
|
// security vulnerabilities from non-deterministic provider selection.
|
|
return nil, nil, fmt.Errorf("web identity token must be a valid JWT token: %w", err)
|
|
}
|
|
|
|
// Look up the specific provider for this issuer, scoped to the role's
|
|
// account when known. IAM-managed records (admin-controlled, mutable at
|
|
// runtime) take precedence over the static-config map so an operator's
|
|
// CreateOpenIDConnectProvider call can shadow a bootstrap entry without
|
|
// requiring a restart.
|
|
provider, exists := s.lookupOIDCProviderForAccount(issuer, roleAccountID)
|
|
if !exists {
|
|
// SECURITY: If no provider is registered for this issuer, fail immediately
|
|
// This prevents JWT tokens from being validated by unintended providers
|
|
return nil, nil, fmt.Errorf("no identity provider registered for issuer: %s", issuer)
|
|
}
|
|
|
|
// Authenticate with the correct provider for this issuer
|
|
identity, err := provider.Authenticate(ctx, token)
|
|
if err != nil {
|
|
// Map provider errors to STS errors using errors.Is() for robust error checking
|
|
// This eliminates fragile string matching and provides reliable error classification
|
|
if errors.Is(err, providers.ErrProviderTokenExpired) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedTokenExpired, err)
|
|
} else if errors.Is(err, providers.ErrProviderInvalidToken) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidToken, err)
|
|
} else if errors.Is(err, providers.ErrProviderInvalidIssuer) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidIssuer, err)
|
|
} else if errors.Is(err, providers.ErrProviderInvalidAudience) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedInvalidAudience, err)
|
|
} else if errors.Is(err, providers.ErrProviderMissingClaims) {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTypedMissingClaims, err)
|
|
}
|
|
// For other errors, return with context
|
|
return nil, nil, fmt.Errorf("token validation failed with provider for issuer %s: %w", issuer, err)
|
|
}
|
|
|
|
if identity == nil {
|
|
return nil, nil, fmt.Errorf("authentication succeeded but no identity returned for issuer %s", issuer)
|
|
}
|
|
|
|
return identity, provider, nil
|
|
}
|
|
|
|
// ValidateWebIdentityToken is a public method that exposes secure token validation for external use
|
|
// This method uses issuer-based lookup to select the correct provider, ensuring security and efficiency.
|
|
// External callers without role context get the account-agnostic lookup (global IAM-managed records
|
|
// only, then static-config); call ValidateWebIdentityTokenForAccount when the assumed-role account
|
|
// is known.
|
|
func (s *STSService) ValidateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
|
return s.validateWebIdentityToken(ctx, token, "")
|
|
}
|
|
|
|
// ValidateWebIdentityTokenForAccount mirrors ValidateWebIdentityToken but
|
|
// scopes the IAM-managed provider lookup to roleAccountID. Pass "" for
|
|
// callers that don't yet know the account (e.g. claim-based mode).
|
|
func (s *STSService) ValidateWebIdentityTokenForAccount(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
|
return s.validateWebIdentityToken(ctx, token, roleAccountID)
|
|
}
|
|
|
|
// extractIssuerFromJWT extracts the issuer (iss) claim from a JWT token without verification
|
|
func (s *STSService) extractIssuerFromJWT(token string) (string, error) {
|
|
// Parse token without verification to get claims
|
|
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse JWT token: %v", err)
|
|
}
|
|
|
|
// Extract claims
|
|
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid token claims")
|
|
}
|
|
|
|
// Get issuer claim
|
|
issuer, ok := claims["iss"].(string)
|
|
if !ok || issuer == "" {
|
|
return "", fmt.Errorf("missing or invalid issuer claim")
|
|
}
|
|
|
|
return issuer, nil
|
|
}
|
|
|
|
// validateRoleAssumptionForWebIdentity validates role assumption for web identity tokens
|
|
// This method performs complete trust policy validation to prevent unauthorized role assumptions
|
|
func (s *STSService) validateRoleAssumptionForWebIdentity(ctx context.Context, roleArn string, webIdentityToken string, durationSeconds *int64) error {
|
|
if roleArn == "" {
|
|
return fmt.Errorf("role ARN cannot be empty")
|
|
}
|
|
|
|
if webIdentityToken == "" {
|
|
return fmt.Errorf("web identity token cannot be empty")
|
|
}
|
|
|
|
// Validate role ARN and extract role information
|
|
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
|
|
arnInfo := utils.ParseRoleARN(roleArn)
|
|
if arnInfo.RoleName == "" {
|
|
return fmt.Errorf("invalid role ARN format: %s, expected format: arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME", roleArn)
|
|
}
|
|
|
|
// Log ARN details for debugging
|
|
if arnInfo.AccountID != "" {
|
|
glog.V(4).Infof("Role ARN validation: role=%s, account=%s (standard format)", arnInfo.RoleName, arnInfo.AccountID)
|
|
} else {
|
|
glog.V(4).Infof("Role ARN validation: role=%s (legacy format)", arnInfo.RoleName)
|
|
}
|
|
|
|
// CRITICAL SECURITY: Perform trust policy validation
|
|
if s.trustPolicyValidator != nil {
|
|
if err := s.trustPolicyValidator.ValidateTrustPolicyForWebIdentity(ctx, roleArn, webIdentityToken, durationSeconds); err != nil {
|
|
return fmt.Errorf("trust policy validation failed: %w", err)
|
|
}
|
|
} else {
|
|
// If no trust policy validator is configured, fail closed for security
|
|
glog.Errorf("SECURITY WARNING: No trust policy validator configured - denying role assumption for security")
|
|
return fmt.Errorf("trust policy validation not available - role assumption denied for security")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateRoleAssumptionForCredentials validates role assumption for credential-based authentication
|
|
// This method performs complete trust policy validation to prevent unauthorized role assumptions
|
|
func (s *STSService) validateRoleAssumptionForCredentials(ctx context.Context, roleArn string, identity *providers.ExternalIdentity) error {
|
|
if roleArn == "" {
|
|
return fmt.Errorf("role ARN cannot be empty")
|
|
}
|
|
|
|
if identity == nil {
|
|
return fmt.Errorf("identity cannot be nil")
|
|
}
|
|
|
|
// Validate role ARN and extract role information
|
|
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
|
|
arnInfo := utils.ParseRoleARN(roleArn)
|
|
if arnInfo.RoleName == "" {
|
|
return fmt.Errorf("invalid role ARN format: %s, expected format: arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME", roleArn)
|
|
}
|
|
|
|
// Log ARN details for debugging
|
|
if arnInfo.AccountID != "" {
|
|
glog.V(4).Infof("Role ARN validation: role=%s, account=%s (standard format)", arnInfo.RoleName, arnInfo.AccountID)
|
|
} else {
|
|
glog.V(4).Infof("Role ARN validation: role=%s (legacy format)", arnInfo.RoleName)
|
|
}
|
|
|
|
// CRITICAL SECURITY: Perform trust policy validation
|
|
if s.trustPolicyValidator != nil {
|
|
if err := s.trustPolicyValidator.ValidateTrustPolicyForCredentials(ctx, roleArn, identity); err != nil {
|
|
return fmt.Errorf("trust policy validation failed: %w", err)
|
|
}
|
|
} else {
|
|
// If no trust policy validator is configured, fail closed for security
|
|
glog.Errorf("SECURITY WARNING: No trust policy validator configured - denying role assumption for security")
|
|
return fmt.Errorf("trust policy validation not available - role assumption denied for security")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CalculateSessionDuration returns the requested DurationSeconds, or the
|
|
// configured TokenDuration default, capped at MaxSessionLength. The source
|
|
// token's exp deliberately plays no part: per AWS semantics the session
|
|
// outlives the (already verified) web identity token.
|
|
func (s *STSService) CalculateSessionDuration(durationSeconds *int64) time.Duration {
|
|
var duration time.Duration
|
|
if durationSeconds != nil {
|
|
duration = time.Duration(*durationSeconds) * time.Second
|
|
} else {
|
|
duration = s.Config.TokenDuration.Duration
|
|
}
|
|
|
|
// Cap at MaxSessionLength if configured
|
|
if s.Config.MaxSessionLength.Duration > 0 && duration > s.Config.MaxSessionLength.Duration {
|
|
glog.V(2).Infof("Limiting session duration from %v to %v based on MaxSessionLength config",
|
|
duration, s.Config.MaxSessionLength.Duration)
|
|
duration = s.Config.MaxSessionLength.Duration
|
|
}
|
|
|
|
return duration
|
|
}
|
|
|
|
// validateAssumeRoleWithCredentialsRequest validates the credentials request parameters
|
|
func (s *STSService) validateAssumeRoleWithCredentialsRequest(request *AssumeRoleWithCredentialsRequest) error {
|
|
if request.RoleArn == "" {
|
|
return fmt.Errorf("RoleArn is required")
|
|
}
|
|
|
|
if request.Username == "" {
|
|
return fmt.Errorf("Username is required")
|
|
}
|
|
|
|
if request.Password == "" {
|
|
return fmt.Errorf("Password is required")
|
|
}
|
|
|
|
if request.RoleSessionName == "" {
|
|
return fmt.Errorf("RoleSessionName is required")
|
|
}
|
|
|
|
if request.ProviderName == "" {
|
|
return fmt.Errorf("ProviderName is required")
|
|
}
|
|
|
|
return s.validateSessionDurationSeconds(request.DurationSeconds)
|
|
}
|
|
|
|
// ExpireSessionForTesting manually expires a session for testing purposes
|
|
func (s *STSService) ExpireSessionForTesting(ctx context.Context, sessionToken string) error {
|
|
if !s.initialized {
|
|
return fmt.Errorf("STS service not initialized")
|
|
}
|
|
|
|
if sessionToken == "" {
|
|
return fmt.Errorf("session token cannot be empty")
|
|
}
|
|
|
|
// Just validate the signature
|
|
_, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid session token format: %w", err)
|
|
}
|
|
|
|
// In a stateless system, we cannot manually expire JWT tokens
|
|
// The token expiration is embedded in the token itself and handled by JWT validation
|
|
glog.V(1).Infof("Manual session expiration requested for stateless token - cannot expire JWT tokens manually")
|
|
|
|
return fmt.Errorf("manual session expiration not supported in stateless JWT system")
|
|
}
|