mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-06 06:22:05 +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>
1228 lines
45 KiB
Go
1228 lines
45 KiB
Go
package s3api
|
|
|
|
// This file provides STS (Security Token Service) HTTP endpoints for AWS SDK compatibility.
|
|
// It exposes AssumeRoleWithWebIdentity as an HTTP endpoint that can be used with
|
|
// AWS SDKs to obtain temporary credentials using OIDC/JWT tokens.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/credential"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
|
)
|
|
|
|
// STS API constants matching AWS STS specification
|
|
const (
|
|
stsAPIVersion = "2011-06-15"
|
|
stsAction = "Action"
|
|
stsVersion = "Version"
|
|
stsWebIdentityToken = "WebIdentityToken"
|
|
stsRoleArn = "RoleArn"
|
|
stsRoleSessionName = "RoleSessionName"
|
|
stsDurationSeconds = "DurationSeconds"
|
|
|
|
// STS Action names
|
|
actionAssumeRole = "AssumeRole"
|
|
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
|
|
actionAssumeRoleWithLDAPIdentity = "AssumeRoleWithLDAPIdentity"
|
|
actionGetCallerIdentity = "GetCallerIdentity"
|
|
actionGetFederationToken = "GetFederationToken"
|
|
|
|
// GetFederationToken-specific parameters
|
|
stsFederationName = "Name"
|
|
|
|
// LDAP parameter names
|
|
stsLDAPUsername = "LDAPUsername"
|
|
stsLDAPPassword = "LDAPPassword"
|
|
stsLDAPProviderName = "LDAPProviderName"
|
|
)
|
|
|
|
// federationNameRegex validates the Name parameter for GetFederationToken per AWS spec
|
|
var federationNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
|
|
|
|
// roleSessionNameRegex validates RoleSessionName per AWS spec.
|
|
// Same character class as federation Name, but the length bounds differ
|
|
// (RoleSessionName is 2..64).
|
|
var roleSessionNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
|
|
|
|
const (
|
|
minRoleSessionNameLen = 2
|
|
maxRoleSessionNameLen = 64
|
|
)
|
|
|
|
// validateRoleSessionName enforces the AWS RoleSessionName contract:
|
|
// length 2..64, characters [\w+=,.@-]+. Returns the STS error code and a
|
|
// descriptive error suitable for callers to surface to the caller.
|
|
func validateRoleSessionName(name string) (STSErrorCode, error) {
|
|
if name == "" {
|
|
return STSErrMissingParameter, fmt.Errorf("RoleSessionName is required")
|
|
}
|
|
if len(name) < minRoleSessionNameLen || len(name) > maxRoleSessionNameLen {
|
|
return STSErrInvalidParameterValue,
|
|
fmt.Errorf("RoleSessionName must be between %d and %d characters", minRoleSessionNameLen, maxRoleSessionNameLen)
|
|
}
|
|
if !roleSessionNameRegex.MatchString(name) {
|
|
return STSErrInvalidParameterValue,
|
|
fmt.Errorf(`RoleSessionName contains invalid characters; allowed: [\w+=,.@-]`)
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// STS duration constants (AWS specification)
|
|
const (
|
|
minDurationSeconds = int64(900) // 15 minutes
|
|
maxDurationSeconds = int64(43200) // 12 hours (AssumeRole)
|
|
defaultFederationDurationSeconds = int64(43200) // 12 hours (GetFederationToken default)
|
|
maxFederationDurationSeconds = int64(129600) // 36 hours (GetFederationToken max)
|
|
)
|
|
|
|
// AWS limits inline session policies to 2048 characters for AssumeRole,
|
|
// AssumeRoleWithWebIdentity, and AssumeRoleWithSAML. PackedPolicySize is
|
|
// returned as a percentage of that budget so callers can detect how close
|
|
// they are to the limit.
|
|
const sessionPolicyBudgetBytes = 2048
|
|
|
|
// computePackedPolicySize returns the inline session policy size as a
|
|
// percentage of the per-action budget, or nil when no session policy was
|
|
// provided. Output is bounded to [0, 100] for AWS-compat reporting; the
|
|
// actual policy size validation happens upstream in NormalizeSessionPolicy.
|
|
func computePackedPolicySize(policyJSON string) *int64 {
|
|
if policyJSON == "" {
|
|
return nil
|
|
}
|
|
pct := int64(len(policyJSON)) * 100 / sessionPolicyBudgetBytes
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
return &pct
|
|
}
|
|
|
|
// parseDurationSecondsWithBounds parses and validates the DurationSeconds parameter
|
|
// against the given min and max bounds. Returns nil if the parameter is not provided.
|
|
func parseDurationSecondsWithBounds(r *http.Request, minSec, maxSec int64) (*int64, STSErrorCode, error) {
|
|
dsStr := r.FormValue("DurationSeconds")
|
|
if dsStr == "" {
|
|
return nil, "", nil
|
|
}
|
|
|
|
ds, err := strconv.ParseInt(dsStr, 10, 64)
|
|
if err != nil {
|
|
return nil, STSErrInvalidParameterValue, fmt.Errorf("invalid DurationSeconds: %w", err)
|
|
}
|
|
|
|
if ds < minSec || ds > maxSec {
|
|
return nil, STSErrInvalidParameterValue,
|
|
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minSec, maxSec)
|
|
}
|
|
|
|
return &ds, "", nil
|
|
}
|
|
|
|
// parseDurationSeconds parses DurationSeconds for AssumeRole (15 min to MaxSessionLength)
|
|
func (h *STSHandlers) parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
|
|
maxSec := maxDurationSeconds
|
|
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.MaxSessionLength.Duration > 0 {
|
|
configuredMax := int64(h.stsService.Config.MaxSessionLength.Duration / time.Second)
|
|
if configuredMax >= minDurationSeconds {
|
|
maxSec = configuredMax
|
|
}
|
|
}
|
|
return parseDurationSecondsWithBounds(r, minDurationSeconds, maxSec)
|
|
}
|
|
|
|
// Removed generateSecureCredentials - now using STS service's JWT token generation
|
|
// The STS service generates proper JWT tokens with embedded claims that can be validated
|
|
// across distributed instances without shared state.
|
|
|
|
// STSHandlers provides HTTP handlers for STS operations
|
|
type STSHandlers struct {
|
|
stsService *sts.STSService
|
|
iam *IdentityAccessManagement
|
|
}
|
|
|
|
// NewSTSHandlers creates a new STSHandlers instance
|
|
func NewSTSHandlers(stsService *sts.STSService, iam *IdentityAccessManagement) *STSHandlers {
|
|
return &STSHandlers{
|
|
stsService: stsService,
|
|
iam: iam,
|
|
}
|
|
}
|
|
|
|
func (h *STSHandlers) getAccountID() string {
|
|
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.AccountId != "" {
|
|
return h.stsService.Config.AccountId
|
|
}
|
|
return defaultAccountID
|
|
}
|
|
|
|
// callerPrincipalArn resolves the identity's principal ARN, synthesizing the
|
|
// canonical user ARN when one was not set (e.g. legacy static identities) so
|
|
// trust policies that name a concrete principal still match.
|
|
func (h *STSHandlers) callerPrincipalArn(identity *Identity) string {
|
|
if identity.PrincipalArn != "" {
|
|
return identity.PrincipalArn
|
|
}
|
|
return fmt.Sprintf("arn:aws:iam::%s:user/%s", h.getAccountID(), identity.Name)
|
|
}
|
|
|
|
// assumeRoleWithWebIdentity dispatches the request through the IAMManager
|
|
// wrapper when one is wired so its cross-account provider scope check and
|
|
// per-role MaxSessionDuration clamp run for the public AWS-SDK path. Without
|
|
// this dispatch, both checks are silently skipped because they live on the
|
|
// IAMManager, not on the bare STS service.
|
|
func (h *STSHandlers) assumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) {
|
|
if h.iam != nil && h.iam.iamIntegration != nil {
|
|
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
|
|
if mgr := provider.GetIAMManager(); mgr != nil {
|
|
return mgr.AssumeRoleWithWebIdentity(ctx, request)
|
|
}
|
|
}
|
|
}
|
|
return h.stsService.AssumeRoleWithWebIdentity(ctx, request)
|
|
}
|
|
|
|
// HandleSTSRequest is the main entry point for STS requests
|
|
// It routes requests based on the Action parameter
|
|
func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) {
|
|
r, _ = request_id.Ensure(r)
|
|
if err := r.ParseForm(); err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, err)
|
|
return
|
|
}
|
|
|
|
// Validate API version
|
|
version := r.Form.Get(stsVersion)
|
|
if version != "" && version != stsAPIVersion {
|
|
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
|
fmt.Errorf("invalid STS API version %s, expecting %s", version, stsAPIVersion))
|
|
return
|
|
}
|
|
|
|
// Route based on action
|
|
action := r.Form.Get(stsAction)
|
|
switch action {
|
|
case actionAssumeRole:
|
|
h.handleAssumeRole(w, r)
|
|
case actionAssumeRoleWithWebIdentity:
|
|
h.handleAssumeRoleWithWebIdentity(w, r)
|
|
case actionAssumeRoleWithLDAPIdentity:
|
|
h.handleAssumeRoleWithLDAPIdentity(w, r)
|
|
case actionGetCallerIdentity:
|
|
h.handleGetCallerIdentity(w, r)
|
|
case actionGetFederationToken:
|
|
h.handleGetFederationToken(w, r)
|
|
default:
|
|
h.writeSTSErrorResponse(w, r, STSErrInvalidAction,
|
|
fmt.Errorf("unsupported action: %s", action))
|
|
}
|
|
}
|
|
|
|
// handleAssumeRoleWithWebIdentity handles the AssumeRoleWithWebIdentity API action
|
|
func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Extract parameters from form (supports both query and POST body)
|
|
roleArn := r.FormValue("RoleArn")
|
|
webIdentityToken := r.FormValue("WebIdentityToken")
|
|
roleSessionName := r.FormValue("RoleSessionName")
|
|
|
|
// Validate required parameters
|
|
if webIdentityToken == "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
|
fmt.Errorf("WebIdentityToken is required"))
|
|
return
|
|
}
|
|
|
|
// RoleArn is intentionally optional here: claim-based policy mode
|
|
// (Phase 3b) advertises that callers MAY omit RoleArn so the STS
|
|
// service derives the assumed-role ARN from the configured policy
|
|
// claim. The bare-STS path validates this — when the IDP isn't
|
|
// configured for claim-based mode (or fails to emit policies) it
|
|
// returns a precise error that this handler maps to the right STS
|
|
// error code below.
|
|
|
|
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Parse and validate DurationSeconds using helper
|
|
durationSeconds, errCode, err := h.parseDurationSeconds(r)
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Check if STS service is initialized
|
|
if h.stsService == nil || !h.stsService.IsInitialized() {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("STS service not initialized"))
|
|
return
|
|
}
|
|
|
|
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
|
fmt.Errorf("invalid Policy document: %w", err))
|
|
return
|
|
}
|
|
|
|
var sessionPolicyPtr *string
|
|
if sessionPolicyJSON != "" {
|
|
sessionPolicyPtr = &sessionPolicyJSON
|
|
}
|
|
|
|
// Build request for STS service
|
|
request := &sts.AssumeRoleWithWebIdentityRequest{
|
|
RoleArn: roleArn,
|
|
WebIdentityToken: webIdentityToken,
|
|
RoleSessionName: roleSessionName,
|
|
DurationSeconds: durationSeconds,
|
|
Policy: sessionPolicyPtr,
|
|
}
|
|
|
|
// Prefer the IAMManager wrapper so the cross-account provider scope
|
|
// (enforceProviderAccountScope) and per-role MaxSessionDuration clamp
|
|
// run for SDK callers too. Falling back to the bare STS service keeps
|
|
// embedded test setups (no IAM integration wired) working.
|
|
response, err := h.assumeRoleWithWebIdentity(ctx, request)
|
|
if err != nil {
|
|
glog.V(2).Infof("AssumeRoleWithWebIdentity failed: %v", err)
|
|
|
|
// Use typed errors for robust error checking
|
|
// This decouples HTTP layer from service implementation details
|
|
errCode := STSErrAccessDenied
|
|
if errors.Is(err, sts.ErrTypedTokenExpired) {
|
|
errCode = STSErrExpiredToken
|
|
} else if errors.Is(err, sts.ErrTypedInvalidToken) {
|
|
errCode = STSErrInvalidParameterValue
|
|
} else if errors.Is(err, sts.ErrTypedInvalidIssuer) {
|
|
errCode = STSErrInvalidParameterValue
|
|
} else if errors.Is(err, sts.ErrTypedInvalidAudience) {
|
|
errCode = STSErrInvalidParameterValue
|
|
} else if errors.Is(err, sts.ErrTypedMissingClaims) {
|
|
errCode = STSErrInvalidParameterValue
|
|
}
|
|
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Build and return XML response
|
|
xmlResponse := &AssumeRoleWithWebIdentityResponse{
|
|
Result: WebIdentityResult{
|
|
Credentials: STSCredentials{
|
|
AccessKeyId: response.Credentials.AccessKeyId,
|
|
SecretAccessKey: response.Credentials.SecretAccessKey,
|
|
SessionToken: response.Credentials.SessionToken,
|
|
Expiration: response.Credentials.Expiration.Format(time.RFC3339),
|
|
},
|
|
SubjectFromWebIdentityToken: response.AssumedRoleUser.Subject,
|
|
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
|
|
},
|
|
}
|
|
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
|
|
|
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
|
}
|
|
|
|
// handleAssumeRole handles the AssumeRole API action
|
|
// This requires AWS Signature V4 authentication
|
|
// Inline session policies (Policy parameter) are supported for AssumeRole,
|
|
// AssumeRoleWithWebIdentity, and AssumeRoleWithLDAPIdentity.
|
|
func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
|
// Extract parameters from form
|
|
roleArn := r.FormValue("RoleArn")
|
|
roleSessionName := r.FormValue("RoleSessionName")
|
|
|
|
// Validate required parameters
|
|
// RoleArn is optional to support S3-compatible clients that omit it
|
|
|
|
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Parse and validate DurationSeconds using helper
|
|
durationSeconds, errCode, err := h.parseDurationSeconds(r)
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Check if STS service is initialized
|
|
if h.stsService == nil || !h.stsService.IsInitialized() {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("STS service not initialized"))
|
|
return
|
|
}
|
|
|
|
// Check if IAM is available for SigV4 verification
|
|
if h.iam == nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("IAM not configured for STS"))
|
|
return
|
|
}
|
|
|
|
// Validate AWS SigV4 authentication
|
|
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
|
|
if sigErrCode != s3err.ErrNone {
|
|
glog.V(2).Infof("AssumeRole SigV4 verification failed: %v", sigErrCode)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
|
|
return
|
|
}
|
|
|
|
if identity == nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("unable to identify caller"))
|
|
return
|
|
}
|
|
|
|
// Record the caller so the audit entry for the AssumeRole call itself names
|
|
// who asked for the session, not just the session it minted.
|
|
r = r.WithContext(recordIdentityInContext(r, identity))
|
|
|
|
glog.V(2).Infof("AssumeRole: caller identity=%s, roleArn=%s, sessionName=%s",
|
|
identity.Name, roleArn, roleSessionName)
|
|
|
|
assumesSelf := roleArn == ""
|
|
// trustedPrincipal is the caller a named role's trust policy admitted;
|
|
// issuance evaluates that trust again on the definition it binds.
|
|
var trustedPrincipal string
|
|
|
|
// A named role is authorized by its trust policy, which declares which
|
|
// principals may assume it, so no separate identity-side sts:AssumeRole allow
|
|
// is required. An explicit identity-side deny still wins (deny-always-wins).
|
|
// Without a RoleArn the caller assumes a session for itself.
|
|
if roleArn != "" {
|
|
// An ARN that names something other than a role can never resolve to one,
|
|
// and reporting that as "not authorized" sends the caller looking for a
|
|
// permission problem they do not have.
|
|
if utils.ExtractRoleNameFromArn(roleArn) == "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
|
fmt.Errorf("RoleArn %q is not an IAM role ARN, expected arn:aws:iam::<account>:role/<name>", roleArn))
|
|
return
|
|
}
|
|
callerArn := h.callerPrincipalArn(identity)
|
|
trustedPrincipal = callerArn
|
|
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, callerArn); err != nil {
|
|
glog.V(2).Infof("AssumeRole: %s not authorized to assume %s: %v", identity.Name, roleArn, err)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn))
|
|
return
|
|
}
|
|
if h.iam.isActionExplicitlyDeniedByIAM(r, identity, callerArn, sts.ActionAssumeRole, roleArn) {
|
|
glog.V(2).Infof("AssumeRole: identity policy explicitly denies %s assuming %s", identity.Name, roleArn)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn))
|
|
return
|
|
}
|
|
} else {
|
|
// Synthesize the caller ARN when the identity carries none, else the
|
|
// session ends up with an empty role name in its assumed-role ARN.
|
|
roleArn = h.callerPrincipalArn(identity)
|
|
glog.V(2).Infof("AssumeRole: no RoleArn provided, defaulting to caller identity: %s", roleArn)
|
|
if authErr := h.iam.VerifyActionPermission(r, identity, Action(sts.ActionAssumeRole), "", ""); authErr != s3err.ErrNone {
|
|
glog.Warningf("AssumeRole: caller %s attempted to assume role without RoleArn and lacks global sts:AssumeRole permission", identity.Name)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("access denied"))
|
|
return
|
|
}
|
|
}
|
|
|
|
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
|
fmt.Errorf("invalid Policy document: %w", err))
|
|
return
|
|
}
|
|
|
|
// is_admin lets the session bypass base policy evaluation, so it may only
|
|
// travel into a session the caller assumed for itself — a legacy static admin
|
|
// carries no IAM policies for such a session to inherit. Assuming a named role
|
|
// scopes the session to that role's policies, admin caller or not.
|
|
var modifyClaims func(claims *sts.STSSessionClaims)
|
|
if assumesSelf && identity.isAdmin() {
|
|
modifyClaims = func(claims *sts.STSSessionClaims) {
|
|
if claims.RequestContext == nil {
|
|
claims.RequestContext = make(map[string]interface{})
|
|
}
|
|
claims.RequestContext["is_admin"] = true
|
|
}
|
|
}
|
|
|
|
// Generate common STS components
|
|
stsCreds, assumedUser, err := h.prepareSTSCredentials(r.Context(), roleArn, trustedPrincipal, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims)
|
|
if errors.Is(err, integration.ErrTrustPolicyDenied) {
|
|
// The role was replaced after its trust check by one that does not
|
|
// trust the caller.
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
|
return
|
|
}
|
|
|
|
// Build and return response
|
|
xmlResponse := &AssumeRoleResponse{
|
|
Result: AssumeRoleResult{
|
|
Credentials: stsCreds,
|
|
AssumedRoleUser: assumedUser,
|
|
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
|
|
},
|
|
}
|
|
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
|
|
|
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
|
}
|
|
|
|
// handleAssumeRoleWithLDAPIdentity handles the AssumeRoleWithLDAPIdentity API action
|
|
func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r *http.Request) {
|
|
// Extract parameters from form
|
|
roleArn := r.FormValue("RoleArn")
|
|
roleSessionName := r.FormValue("RoleSessionName")
|
|
ldapUsername := r.FormValue(stsLDAPUsername)
|
|
ldapPassword := r.FormValue(stsLDAPPassword)
|
|
|
|
// Validate required parameters
|
|
if roleArn == "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
|
fmt.Errorf("RoleArn is required"))
|
|
return
|
|
}
|
|
|
|
if errCode, err := validateRoleSessionName(roleSessionName); err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
if ldapUsername == "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
|
fmt.Errorf("LDAPUsername is required"))
|
|
return
|
|
}
|
|
|
|
if ldapPassword == "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
|
fmt.Errorf("LDAPPassword is required"))
|
|
return
|
|
}
|
|
|
|
// Parse and validate DurationSeconds using helper
|
|
durationSeconds, errCode, err := h.parseDurationSeconds(r)
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Check if STS service is initialized
|
|
if h.stsService == nil || !h.stsService.IsInitialized() {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("STS service not initialized"))
|
|
return
|
|
}
|
|
|
|
// Optional: specific LDAP provider name
|
|
ldapProviderName := r.FormValue(stsLDAPProviderName)
|
|
|
|
// Find an LDAP provider from the registered providers
|
|
var ldapProvider *ldap.LDAPProvider
|
|
ldapProvidersFound := 0
|
|
for _, provider := range h.stsService.GetProviders() {
|
|
// Check if this is an LDAP provider by type assertion
|
|
if p, ok := provider.(*ldap.LDAPProvider); ok {
|
|
if ldapProviderName != "" && p.Name() == ldapProviderName {
|
|
ldapProvider = p
|
|
break
|
|
} else if ldapProviderName == "" && ldapProvider == nil {
|
|
ldapProvider = p
|
|
}
|
|
ldapProvidersFound++
|
|
}
|
|
}
|
|
|
|
if ldapProvidersFound > 1 && ldapProviderName == "" {
|
|
glog.Warningf("Multiple LDAP providers found (%d). Using the first one found (non-deterministic). Consider specifying LDAPProviderName.", ldapProvidersFound)
|
|
}
|
|
|
|
if ldapProvider == nil {
|
|
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: no LDAP provider configured")
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("no LDAP provider configured - please add an LDAP provider to IAM configuration"))
|
|
return
|
|
}
|
|
|
|
// Authenticate with LDAP provider
|
|
// The provider expects credentials in "username:password" format
|
|
credentials := ldapUsername + ":" + ldapPassword
|
|
identity, err := ldapProvider.Authenticate(r.Context(), credentials)
|
|
if err != nil {
|
|
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: LDAP authentication failed for user %s: %v", ldapUsername, err)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("authentication failed"))
|
|
return
|
|
}
|
|
|
|
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: user %s authenticated successfully, groups=%v",
|
|
ldapUsername, identity.Groups)
|
|
|
|
accountID := h.getAccountID()
|
|
|
|
ldapUserIdentity := &Identity{
|
|
Name: identity.UserID,
|
|
Account: &Account{
|
|
DisplayName: identity.DisplayName,
|
|
EmailAddress: identity.Email,
|
|
Id: identity.UserID,
|
|
},
|
|
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/%s", accountID, identity.UserID),
|
|
}
|
|
|
|
// Verify that the identity is allowed to assume the role by checking the Trust Policy
|
|
// The LDAP user doesn't have identity policies, so we strictly check if the Role trusts this principal.
|
|
trustedPrincipal := ldapUserIdentity.PrincipalArn
|
|
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, trustedPrincipal); err != nil {
|
|
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: trust policy validation failed for %s to assume %s: %v", ldapUsername, roleArn, err)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
|
return
|
|
}
|
|
|
|
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
|
fmt.Errorf("invalid Policy document: %w", err))
|
|
return
|
|
}
|
|
|
|
// Generate common STS components with LDAP-specific claims
|
|
modifyClaims := func(claims *sts.STSSessionClaims) {
|
|
claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider)
|
|
}
|
|
|
|
stsCreds, assumedUser, err := h.prepareSTSCredentials(r.Context(), roleArn, trustedPrincipal, roleSessionName, durationSeconds, sessionPolicyJSON, modifyClaims)
|
|
if errors.Is(err, integration.ErrTrustPolicyDenied) {
|
|
// The role was replaced after its trust check by one that does not
|
|
// trust the caller.
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
|
return
|
|
}
|
|
|
|
// Build and return response
|
|
xmlResponse := &AssumeRoleWithLDAPIdentityResponse{
|
|
Result: LDAPIdentityResult{
|
|
Credentials: stsCreds,
|
|
AssumedRoleUser: assumedUser,
|
|
PackedPolicySize: computePackedPolicySize(sessionPolicyJSON),
|
|
},
|
|
}
|
|
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
|
|
|
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
|
}
|
|
|
|
// handleGetFederationToken handles the GetFederationToken API action.
|
|
// This allows long-term IAM users to obtain temporary credentials scoped down
|
|
// by an optional inline session policy. Temporary credentials cannot call this action.
|
|
func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Request) {
|
|
// Extract parameters
|
|
name := r.FormValue(stsFederationName)
|
|
|
|
// Validate required parameters
|
|
if name == "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
|
fmt.Errorf("Name is required"))
|
|
return
|
|
}
|
|
|
|
// AWS requires Name to be 2-32 characters matching [\w+=,.@-]+
|
|
if len(name) < 2 || len(name) > 32 {
|
|
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
|
fmt.Errorf("Name must be between 2 and 32 characters"))
|
|
return
|
|
}
|
|
if !federationNameRegex.MatchString(name) {
|
|
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
|
fmt.Errorf("Name contains invalid characters, must match [\\w+=,.@-]+"))
|
|
return
|
|
}
|
|
|
|
// Parse and validate DurationSeconds (GetFederationToken allows up to 36 hours)
|
|
durationSeconds, errCode, err := parseDurationSecondsWithBounds(r, minDurationSeconds, maxFederationDurationSeconds)
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, errCode, err)
|
|
return
|
|
}
|
|
|
|
// Reject calls from temporary credentials (session tokens) early,
|
|
// before SigV4 verification — no need to authenticate first.
|
|
// GetFederationToken can only be called by long-term IAM users.
|
|
securityToken := r.Header.Get("X-Amz-Security-Token")
|
|
if securityToken == "" {
|
|
securityToken = r.URL.Query().Get("X-Amz-Security-Token")
|
|
}
|
|
if securityToken != "" {
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("GetFederationToken cannot be called with temporary credentials"))
|
|
return
|
|
}
|
|
|
|
// Check if STS service is initialized
|
|
if h.stsService == nil || !h.stsService.IsInitialized() {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("STS service not initialized"))
|
|
return
|
|
}
|
|
|
|
// Check if IAM is available for SigV4 verification
|
|
if h.iam == nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("IAM not configured for STS"))
|
|
return
|
|
}
|
|
|
|
// Validate AWS SigV4 authentication
|
|
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
|
|
if sigErrCode != s3err.ErrNone {
|
|
glog.V(2).Infof("GetFederationToken SigV4 verification failed: %v", sigErrCode)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
|
|
return
|
|
}
|
|
|
|
if identity == nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("unable to identify caller"))
|
|
return
|
|
}
|
|
|
|
r = r.WithContext(recordIdentityInContext(r, identity))
|
|
|
|
glog.V(2).Infof("GetFederationToken: caller identity=%s, name=%s", identity.Name, name)
|
|
|
|
// Check if the caller is authorized to call GetFederationToken
|
|
if authErr := h.iam.VerifyActionPermission(r, identity, Action(sts.ActionGetFederationToken), "", ""); authErr != s3err.ErrNone {
|
|
glog.V(2).Infof("GetFederationToken: caller %s is not authorized to call GetFederationToken", identity.Name)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("user %s is not authorized to call GetFederationToken", identity.Name))
|
|
return
|
|
}
|
|
|
|
// Validate session policy if provided
|
|
sessionPolicyJSON, err := sts.NormalizeSessionPolicy(r.FormValue("Policy"))
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrMalformedPolicyDocument,
|
|
fmt.Errorf("invalid Policy document: %w", err))
|
|
return
|
|
}
|
|
|
|
// Calculate duration (default 12 hours for GetFederationToken)
|
|
duration := time.Duration(defaultFederationDurationSeconds) * time.Second
|
|
if durationSeconds != nil {
|
|
duration = time.Duration(*durationSeconds) * time.Second
|
|
}
|
|
|
|
// Generate session ID
|
|
sessionId, err := sts.GenerateSessionId()
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrInternalError,
|
|
fmt.Errorf("failed to generate session ID: %w", err))
|
|
return
|
|
}
|
|
|
|
expiration := time.Now().Add(duration)
|
|
accountID := h.getAccountID()
|
|
|
|
// Build federated user ARN: arn:aws:sts::<account>:federated-user/<Name>
|
|
federatedUserArn := fmt.Sprintf("arn:aws:sts::%s:federated-user/%s", accountID, name)
|
|
federatedUserId := fmt.Sprintf("%s:%s", accountID, name)
|
|
|
|
// Create session claims — use the caller's principal ARN as the RoleArn
|
|
// so that policy evaluation resolves the caller's attached policies
|
|
claims := sts.NewSTSSessionClaims(sessionId, h.stsService.Config.Issuer, expiration).
|
|
WithSessionName(name).
|
|
WithRoleInfo(identity.PrincipalArn, federatedUserId, federatedUserArn)
|
|
|
|
// Embed the caller's effective policies into the token.
|
|
// Merge identity.PolicyNames (from SigV4 identity) with policies resolved
|
|
// from the IAM manager (which may include group-attached policies).
|
|
policySet := make(map[string]struct{})
|
|
for _, p := range identity.PolicyNames {
|
|
policySet[p] = struct{}{}
|
|
}
|
|
|
|
var policyManager *integration.IAMManager
|
|
if h.iam.iamIntegration != nil {
|
|
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
|
|
policyManager = provider.GetIAMManager()
|
|
}
|
|
}
|
|
if policyManager != nil {
|
|
userPolicies, err := policyManager.GetPoliciesForUser(r.Context(), identity.Name)
|
|
switch {
|
|
case err == nil:
|
|
for _, p := range userPolicies {
|
|
policySet[p] = struct{}{}
|
|
}
|
|
case errors.Is(err, credential.ErrUserNotFound):
|
|
// Legacy-config IAM users authenticated via SigV4 are not
|
|
// present in the IAM user store. Fall back to
|
|
// identity.PolicyNames — the caller's SigV4 identity is
|
|
// authoritative for them.
|
|
glog.V(2).Infof("GetFederationToken: %s not in IAM user store, using SigV4 identity policies only", identity.Name)
|
|
default:
|
|
// Any other failure (store unreachable, misconfigured, etc.)
|
|
// means we cannot compute the caller's effective policies.
|
|
// Fail closed rather than mint a token with an incomplete set.
|
|
glog.V(2).Infof("GetFederationToken: failed to resolve policies for %s: %v", identity.Name, err)
|
|
h.writeSTSErrorResponse(w, r, STSErrInternalError,
|
|
fmt.Errorf("failed to resolve caller policies"))
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(policySet) > 0 {
|
|
merged := make([]string, 0, len(policySet))
|
|
for p := range policySet {
|
|
merged = append(merged, p)
|
|
}
|
|
claims.WithPolicies(merged)
|
|
}
|
|
|
|
if sessionPolicyJSON != "" {
|
|
claims.WithSessionPolicy(sessionPolicyJSON)
|
|
}
|
|
|
|
// Generate JWT session token
|
|
sessionToken, err := h.stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrInternalError,
|
|
fmt.Errorf("failed to generate session token: %w", err))
|
|
return
|
|
}
|
|
|
|
// Generate temporary credentials
|
|
stsCredGen := h.stsService.GetCredentialGenerator()
|
|
stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration)
|
|
if err != nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrInternalError,
|
|
fmt.Errorf("failed to generate temporary credentials: %w", err))
|
|
return
|
|
}
|
|
|
|
// Build and return response
|
|
xmlResponse := &GetFederationTokenResponse{
|
|
Result: GetFederationTokenResult{
|
|
Credentials: STSCredentials{
|
|
AccessKeyId: stsCredsDet.AccessKeyId,
|
|
SecretAccessKey: stsCredsDet.SecretAccessKey,
|
|
SessionToken: sessionToken,
|
|
Expiration: expiration.Format(time.RFC3339),
|
|
},
|
|
FederatedUser: FederatedUser{
|
|
FederatedUserId: federatedUserId,
|
|
Arn: federatedUserArn,
|
|
},
|
|
},
|
|
}
|
|
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
|
|
|
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
|
}
|
|
|
|
// prepareSTSCredentials extracts common shared logic for credential generation
|
|
//
|
|
// principalArn is the caller whose assumption of a named role was authorized
|
|
// by its trust policy. The role is resolved once, with that trust evaluated
|
|
// again on the definition resolved, and the session's role ID, duration cap
|
|
// and embedded policies all come from that one definition: a role replaced
|
|
// under the same name between the caller's trust check and here yields no
|
|
// session rather than one for a role whose trust was never checked.
|
|
func (h *STSHandlers) prepareSTSCredentials(ctx context.Context, roleArn, principalArn, roleSessionName string,
|
|
durationSeconds *int64, sessionPolicy string, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
|
|
|
|
duration := time.Hour
|
|
if h.stsService != nil && h.stsService.Config != nil {
|
|
duration = h.stsService.CalculateSessionDuration(durationSeconds)
|
|
} else if durationSeconds != nil {
|
|
duration = time.Duration(*durationSeconds) * time.Second
|
|
}
|
|
|
|
// A named role's MaxSessionDuration bounds the resolved duration the same
|
|
// way capDurationByRole does on the SDK paths; self-assumption has no role
|
|
// definition to consult. The role's ID binds the session to this role, so
|
|
// a named role that cannot be resolved here gets no session: one issued
|
|
// without the ID would be bound to no role at all.
|
|
var roleID string
|
|
var resolvedRole *integration.RoleDefinition
|
|
if h.iam != nil && h.iam.iamIntegration != nil {
|
|
if roleName := utils.ExtractRoleNameFromArn(roleArn); roleName != "" {
|
|
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
|
|
if mgr := provider.GetIAMManager(); mgr != nil {
|
|
var roleDef *integration.RoleDefinition
|
|
var roleErr error
|
|
if principalArn != "" {
|
|
roleDef, roleErr = mgr.ResolveRoleForPrincipal(ctx, roleArn, principalArn)
|
|
} else {
|
|
roleDef, roleErr = mgr.GetRole(ctx, roleName)
|
|
}
|
|
if roleErr != nil {
|
|
return STSCredentials{}, nil, fmt.Errorf("resolve role %s: %w", roleName, roleErr)
|
|
}
|
|
if roleDef == nil {
|
|
return STSCredentials{}, nil, fmt.Errorf("role %s not found", roleName)
|
|
}
|
|
resolvedRole = roleDef
|
|
roleID = roleDef.RoleId
|
|
if roleDef.MaxSessionDuration > 0 {
|
|
if roleMax := time.Duration(roleDef.MaxSessionDuration) * time.Second; duration > roleMax {
|
|
duration = roleMax
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate session ID
|
|
sessionId, err := sts.GenerateSessionId()
|
|
if err != nil {
|
|
return STSCredentials{}, nil, fmt.Errorf("failed to generate session ID: %w", err)
|
|
}
|
|
|
|
expiration := time.Now().Add(duration)
|
|
|
|
// Extract role name from ARN for proper response formatting
|
|
roleName := utils.ExtractRoleNameFromPrincipal(roleArn)
|
|
if roleName == "" {
|
|
// Try to extract user name if it's a user ARN (for "User Context" assumption)
|
|
roleName = utils.ExtractUserNameFromPrincipal(roleArn)
|
|
}
|
|
|
|
if roleName == "" {
|
|
roleName = roleArn // Fallback to full ARN if extraction fails
|
|
}
|
|
|
|
accountID := h.getAccountID()
|
|
|
|
// Construct AssumedRoleUser ARN - this will be used as the principal for the vended token
|
|
assumedRoleArn := fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", accountID, roleName, roleSessionName)
|
|
|
|
// Use assumedRoleArn as RoleArn in claims if original RoleArn is empty
|
|
// This ensures STSSessionClaims.IsValid() passes (it requires non-empty RoleArn)
|
|
effectiveRoleArn := roleArn
|
|
if effectiveRoleArn == "" {
|
|
effectiveRoleArn = assumedRoleArn
|
|
}
|
|
|
|
// Create session claims with role information
|
|
// SECURITY: Use the assumedRoleArn as the principal in the token.
|
|
// This ensures that subsequent requests using this token are correctly identified as the assumed role.
|
|
claims := sts.NewSTSSessionClaims(sessionId, h.stsService.Config.Issuer, expiration).
|
|
WithSessionName(roleSessionName).
|
|
WithRoleInfo(effectiveRoleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), assumedRoleArn).
|
|
WithRoleId(roleID)
|
|
|
|
// If IAM integration is available, embed the role's attached policies into the session token.
|
|
// This makes the token self-sufficient for authorization even when role lookup is unavailable.
|
|
var policyManager *integration.IAMManager
|
|
if h.iam != nil && h.iam.iamIntegration != nil {
|
|
if provider, ok := h.iam.iamIntegration.(IAMManagerProvider); ok {
|
|
policyManager = provider.GetIAMManager()
|
|
}
|
|
}
|
|
|
|
if policyManager != nil {
|
|
roleNameForPolicies := utils.ExtractRoleNameFromArn(roleArn)
|
|
if roleNameForPolicies == "" {
|
|
roleNameForPolicies = utils.ExtractRoleNameFromPrincipal(roleArn)
|
|
}
|
|
|
|
if roleNameForPolicies != "" && len(claims.Policies) == 0 {
|
|
roleDef, err := resolvedRole, error(nil)
|
|
if roleDef == nil || roleDef.RoleName != roleNameForPolicies {
|
|
roleDef, err = policyManager.GetRole(ctx, roleNameForPolicies)
|
|
}
|
|
if err != nil {
|
|
glog.V(2).Infof("Failed to load role %q for policy embedding: %v", roleNameForPolicies, err)
|
|
} else if roleDef == nil {
|
|
glog.V(2).Infof("Role definition %q was missing for policy embedding", roleNameForPolicies)
|
|
} else if len(roleDef.AttachedPolicies) > 0 {
|
|
claims.WithPolicies(roleDef.AttachedPolicies)
|
|
}
|
|
}
|
|
}
|
|
|
|
if sessionPolicy != "" {
|
|
claims.WithSessionPolicy(sessionPolicy)
|
|
}
|
|
|
|
// Apply custom claims if provided (e.g., LDAP identity)
|
|
if modifyClaims != nil {
|
|
modifyClaims(claims)
|
|
}
|
|
|
|
// Generate JWT session token
|
|
sessionToken, err := h.stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
|
if err != nil {
|
|
return STSCredentials{}, nil, fmt.Errorf("failed to generate session token: %w", err)
|
|
}
|
|
|
|
// Generate temporary credentials (deterministic based on sessionId)
|
|
stsCredGen := h.stsService.GetCredentialGenerator()
|
|
stsCredsDet, err := stsCredGen.GenerateTemporaryCredentials(sessionId, expiration)
|
|
if err != nil {
|
|
return STSCredentials{}, nil, fmt.Errorf("failed to generate temporary credentials: %w", err)
|
|
}
|
|
accessKeyId := stsCredsDet.AccessKeyId
|
|
secretAccessKey := stsCredsDet.SecretAccessKey
|
|
|
|
stsCreds := STSCredentials{
|
|
AccessKeyId: accessKeyId,
|
|
SecretAccessKey: secretAccessKey,
|
|
SessionToken: sessionToken,
|
|
Expiration: expiration.Format(time.RFC3339),
|
|
}
|
|
|
|
assumedUser := &AssumedRoleUser{
|
|
AssumedRoleId: fmt.Sprintf("%s:%s", roleName, roleSessionName),
|
|
Arn: assumedRoleArn,
|
|
}
|
|
|
|
return stsCreds, assumedUser, nil
|
|
}
|
|
|
|
// handleGetCallerIdentity handles the GetCallerIdentity API action.
|
|
// It returns the identity (ARN, account, user ID) of the caller based on SigV4 authentication.
|
|
func (h *STSHandlers) handleGetCallerIdentity(w http.ResponseWriter, r *http.Request) {
|
|
if h.iam == nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
|
fmt.Errorf("IAM not configured for STS"))
|
|
return
|
|
}
|
|
|
|
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
|
|
if sigErrCode != s3err.ErrNone {
|
|
glog.V(2).Infof("GetCallerIdentity SigV4 verification failed: %v", sigErrCode)
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
|
|
return
|
|
}
|
|
|
|
if identity == nil {
|
|
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
|
fmt.Errorf("unable to identify caller"))
|
|
return
|
|
}
|
|
|
|
accountID := h.getAccountID()
|
|
arn := h.callerPrincipalArn(identity)
|
|
userId := identity.Name
|
|
|
|
r = r.WithContext(recordIdentityInContext(r, identity))
|
|
|
|
glog.V(2).Infof("GetCallerIdentity: identity=%s, arn=%s, account=%s", identity.Name, arn, accountID)
|
|
|
|
xmlResponse := &GetCallerIdentityResponse{
|
|
Result: GetCallerIdentityResult{
|
|
Arn: arn,
|
|
UserId: userId,
|
|
Account: accountID,
|
|
},
|
|
}
|
|
xmlResponse.ResponseMetadata.RequestId = request_id.GetFromRequest(r)
|
|
|
|
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
|
}
|
|
|
|
// STS Response types for XML marshaling
|
|
|
|
// AssumeRoleWithWebIdentityResponse is the response for AssumeRoleWithWebIdentity
|
|
type AssumeRoleWithWebIdentityResponse struct {
|
|
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithWebIdentityResponse"`
|
|
Result WebIdentityResult `xml:"AssumeRoleWithWebIdentityResult"`
|
|
ResponseMetadata struct {
|
|
RequestId string `xml:"RequestId,omitempty"`
|
|
} `xml:"ResponseMetadata,omitempty"`
|
|
}
|
|
|
|
// WebIdentityResult contains the result of AssumeRoleWithWebIdentity
|
|
type WebIdentityResult struct {
|
|
Credentials STSCredentials `xml:"Credentials"`
|
|
SubjectFromWebIdentityToken string `xml:"SubjectFromWebIdentityToken,omitempty"`
|
|
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
|
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
|
|
}
|
|
|
|
// STSCredentials represents temporary security credentials
|
|
type STSCredentials struct {
|
|
AccessKeyId string `xml:"AccessKeyId"`
|
|
SecretAccessKey string `xml:"SecretAccessKey"`
|
|
SessionToken string `xml:"SessionToken"`
|
|
Expiration string `xml:"Expiration"`
|
|
}
|
|
|
|
// AssumedRoleUser contains information about the assumed role
|
|
type AssumedRoleUser struct {
|
|
AssumedRoleId string `xml:"AssumedRoleId"`
|
|
Arn string `xml:"Arn"`
|
|
}
|
|
|
|
// AssumeRoleResponse is the response for AssumeRole
|
|
type AssumeRoleResponse struct {
|
|
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleResponse"`
|
|
Result AssumeRoleResult `xml:"AssumeRoleResult"`
|
|
ResponseMetadata struct {
|
|
RequestId string `xml:"RequestId,omitempty"`
|
|
} `xml:"ResponseMetadata,omitempty"`
|
|
}
|
|
|
|
// AssumeRoleResult contains the result of AssumeRole
|
|
type AssumeRoleResult struct {
|
|
Credentials STSCredentials `xml:"Credentials"`
|
|
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
|
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
|
|
}
|
|
|
|
// AssumeRoleWithLDAPIdentityResponse is the response for AssumeRoleWithLDAPIdentity
|
|
type AssumeRoleWithLDAPIdentityResponse struct {
|
|
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithLDAPIdentityResponse"`
|
|
Result LDAPIdentityResult `xml:"AssumeRoleWithLDAPIdentityResult"`
|
|
ResponseMetadata struct {
|
|
RequestId string `xml:"RequestId,omitempty"`
|
|
} `xml:"ResponseMetadata,omitempty"`
|
|
}
|
|
|
|
// LDAPIdentityResult contains the result of AssumeRoleWithLDAPIdentity
|
|
type LDAPIdentityResult struct {
|
|
Credentials STSCredentials `xml:"Credentials"`
|
|
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
|
PackedPolicySize *int64 `xml:"PackedPolicySize,omitempty"`
|
|
}
|
|
|
|
// GetCallerIdentityResponse is the response for GetCallerIdentity
|
|
type GetCallerIdentityResponse struct {
|
|
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ GetCallerIdentityResponse"`
|
|
Result GetCallerIdentityResult `xml:"GetCallerIdentityResult"`
|
|
ResponseMetadata struct {
|
|
RequestId string `xml:"RequestId,omitempty"`
|
|
} `xml:"ResponseMetadata,omitempty"`
|
|
}
|
|
|
|
// GetCallerIdentityResult contains the result of GetCallerIdentity
|
|
type GetCallerIdentityResult struct {
|
|
Arn string `xml:"Arn"`
|
|
UserId string `xml:"UserId"`
|
|
Account string `xml:"Account"`
|
|
}
|
|
|
|
// GetFederationTokenResponse is the response for GetFederationToken
|
|
type GetFederationTokenResponse struct {
|
|
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ GetFederationTokenResponse"`
|
|
Result GetFederationTokenResult `xml:"GetFederationTokenResult"`
|
|
ResponseMetadata struct {
|
|
RequestId string `xml:"RequestId,omitempty"`
|
|
} `xml:"ResponseMetadata,omitempty"`
|
|
}
|
|
|
|
// GetFederationTokenResult contains the result of GetFederationToken
|
|
type GetFederationTokenResult struct {
|
|
Credentials STSCredentials `xml:"Credentials"`
|
|
FederatedUser FederatedUser `xml:"FederatedUser"`
|
|
}
|
|
|
|
// FederatedUser contains information about the federated user
|
|
type FederatedUser struct {
|
|
FederatedUserId string `xml:"FederatedUserId"`
|
|
Arn string `xml:"Arn"`
|
|
}
|
|
|
|
// STS Error types
|
|
|
|
// STSErrorCode represents STS error codes
|
|
type STSErrorCode string
|
|
|
|
const (
|
|
STSErrAccessDenied STSErrorCode = "AccessDenied"
|
|
STSErrExpiredToken STSErrorCode = "ExpiredTokenException"
|
|
STSErrInvalidAction STSErrorCode = "InvalidAction"
|
|
STSErrInvalidParameterValue STSErrorCode = "InvalidParameterValue"
|
|
STSErrMalformedPolicyDocument STSErrorCode = "MalformedPolicyDocument"
|
|
STSErrMissingParameter STSErrorCode = "MissingParameter"
|
|
STSErrSTSNotReady STSErrorCode = "ServiceUnavailable"
|
|
STSErrInternalError STSErrorCode = "InternalError"
|
|
)
|
|
|
|
// stsErrorResponses maps error codes to HTTP status and messages
|
|
var stsErrorResponses = map[STSErrorCode]struct {
|
|
HTTPStatusCode int
|
|
Message string
|
|
}{
|
|
STSErrAccessDenied: {http.StatusForbidden, "Access Denied"},
|
|
STSErrExpiredToken: {http.StatusBadRequest, "Token has expired"},
|
|
STSErrInvalidAction: {http.StatusBadRequest, "Invalid action"},
|
|
STSErrInvalidParameterValue: {http.StatusBadRequest, "Invalid parameter value"},
|
|
STSErrMalformedPolicyDocument: {http.StatusBadRequest, "Malformed policy document"},
|
|
STSErrMissingParameter: {http.StatusBadRequest, "Missing required parameter"},
|
|
STSErrSTSNotReady: {http.StatusServiceUnavailable, "STS service not ready"},
|
|
STSErrInternalError: {http.StatusInternalServerError, "Internal error"},
|
|
}
|
|
|
|
// STSErrorResponse is the XML error response format
|
|
type STSErrorResponse struct {
|
|
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ ErrorResponse"`
|
|
Error struct {
|
|
Type string `xml:"Type"`
|
|
Code string `xml:"Code"`
|
|
Message string `xml:"Message"`
|
|
} `xml:"Error"`
|
|
RequestId string `xml:"RequestId"`
|
|
}
|
|
|
|
// writeSTSErrorResponse writes an STS error response
|
|
func (h *STSHandlers) writeSTSErrorResponse(w http.ResponseWriter, r *http.Request, code STSErrorCode, err error) {
|
|
errInfo, ok := stsErrorResponses[code]
|
|
if !ok {
|
|
errInfo = stsErrorResponses[STSErrInternalError]
|
|
}
|
|
|
|
message := errInfo.Message
|
|
if err != nil {
|
|
message = err.Error()
|
|
}
|
|
|
|
response := STSErrorResponse{
|
|
RequestId: request_id.GetFromRequest(r),
|
|
}
|
|
|
|
// Server-side errors use "Receiver" type per AWS spec
|
|
if code == STSErrInternalError || code == STSErrSTSNotReady {
|
|
response.Error.Type = "Receiver"
|
|
} else {
|
|
response.Error.Type = "Sender"
|
|
}
|
|
|
|
response.Error.Code = string(code)
|
|
response.Error.Message = message
|
|
|
|
glog.V(1).Infof("STS error response: code=%s, type=%s, message=%s", code, response.Error.Type, message)
|
|
s3err.WriteXMLResponse(w, r, errInfo.HTTPStatusCode, response)
|
|
}
|