feat(s3): add STS GetFederationToken support (#8891)
* feat(s3): add STS GetFederationToken support Implement the AWS STS GetFederationToken API, which allows long-term IAM users to obtain temporary credentials scoped down by an optional inline session policy. This is useful for server-side applications that mint per-user temporary credentials. Key behaviors: - Requires SigV4 authentication from a long-term IAM user - Rejects calls from temporary credentials (session tokens) - Name parameter (2-64 chars) identifies the federated user - DurationSeconds supports 900-129600 (15 min to 36 hours, default 12h) - Optional inline session policy for permission scoping - Caller's attached policies are embedded in the JWT token - Returns federated user ARN: arn:aws:sts::<account>:federated-user/<Name> No performance impact on the S3 hot path — credential vending is a separate control-plane operation, and all policy data is embedded in the stateless JWT token. * fix(s3): address GetFederationToken PR review feedback - Fix Name validation: max 32 chars (not 64) per AWS spec, add regex validation for [\w+=,.@-]+ character whitelist - Refactor parseDurationSeconds into parseDurationSecondsWithBounds to eliminate duplicated duration parsing logic - Add sts:GetFederationToken permission check via VerifyActionPermission mirroring the AssumeRole authorization pattern - Change GetPoliciesForUser to return ([]string, error) so callers fail closed on policy-resolution failures instead of silently returning nil - Move temporary-credentials rejection before SigV4 verification for early rejection and proper test coverage - Update tests: verify specific error message for temp cred rejection, add regex validation test cases (spaces, slashes rejected) * refactor(s3): use sts.Action* constants instead of hard-coded strings Replace hard-coded "sts:AssumeRole" and "sts:GetFederationToken" strings in VerifyActionPermission calls with sts.ActionAssumeRole and sts.ActionGetFederationToken package constants. * fix(s3): pass through sts: prefix in action resolver and merge policies Two fixes: 1. mapBaseActionToS3Format now passes through "sts:" prefix alongside "s3:" and "iam:", preventing sts:GetFederationToken from being rewritten to s3:sts:GetFederationToken in VerifyActionPermission. This also fixes the existing sts:AssumeRole permission checks. 2. GetFederationToken policy embedding now merges identity.PolicyNames (from SigV4 identity) with policies from the IAM manager (which may include group-attached policies), deduplicated via a map. Previously the IAM manager lookup was skipped when identity.PolicyNames was non-empty, causing group policies to be omitted from the token. * test(s3): add integration tests for sts: action passthrough and policy merge Action resolver tests: - TestMapBaseActionToS3Format_ServicePrefixPassthrough: verifies s3:, iam:, and sts: prefixed actions pass through unchanged while coarse actions (Read, Write) are mapped to S3 format - TestResolveS3Action_STSActionsPassthrough: verifies sts:AssumeRole, sts:GetFederationToken, sts:GetCallerIdentity pass through ResolveS3Action unchanged with both nil and real HTTP requests Policy merge tests: - TestGetFederationToken_GetPoliciesForUser: tests IAMManager.GetPoliciesForUser with no user store (error), missing user, user with policies, user without - TestGetFederationToken_PolicyMergeAndDedup: tests that identity.PolicyNames and IAM-manager-resolved policies are merged and deduplicated (SharedPolicy appears in both sources, result has 3 unique policies) - TestGetFederationToken_PolicyMergeNoManager: tests that when IAM manager is unavailable, identity.PolicyNames alone are embedded * test(s3): add end-to-end integration tests for GetFederationToken Add integration tests that call GetFederationToken using real AWS SigV4 signed HTTP requests against a running SeaweedFS instance, following the existing pattern in test/s3/iam/s3_sts_assume_role_test.go. Tests: - TestSTSGetFederationTokenValidation: missing name, name too short/long, invalid characters, duration too short/long, malformed policy, anonymous rejection (7 subtests) - TestSTSGetFederationTokenRejectTemporaryCredentials: obtains temp creds via AssumeRole then verifies GetFederationToken rejects them - TestSTSGetFederationTokenSuccess: basic success, custom 1h duration, 36h max duration with expiration time verification - TestSTSGetFederationTokenWithSessionPolicy: creates a bucket, obtains federated creds with GetObject-only session policy, verifies GetObject succeeds and PutObject is denied using the AWS SDK S3 client
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +38,10 @@ const (
|
||||
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
|
||||
actionAssumeRoleWithLDAPIdentity = "AssumeRoleWithLDAPIdentity"
|
||||
actionGetCallerIdentity = "GetCallerIdentity"
|
||||
actionGetFederationToken = "GetFederationToken"
|
||||
|
||||
// GetFederationToken-specific parameters
|
||||
stsFederationName = "Name"
|
||||
|
||||
// LDAP parameter names
|
||||
stsLDAPUsername = "LDAPUsername"
|
||||
@@ -44,15 +49,20 @@ const (
|
||||
stsLDAPProviderName = "LDAPProviderName"
|
||||
)
|
||||
|
||||
// federationNameRegex validates the Name parameter for GetFederationToken per AWS spec
|
||||
var federationNameRegex = regexp.MustCompile(`^[\w+=,.@-]+$`)
|
||||
|
||||
// STS duration constants (AWS specification)
|
||||
const (
|
||||
minDurationSeconds = int64(900) // 15 minutes
|
||||
maxDurationSeconds = int64(43200) // 12 hours
|
||||
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)
|
||||
)
|
||||
|
||||
// parseDurationSeconds parses and validates the DurationSeconds parameter
|
||||
// Returns nil if the parameter is not provided, or a pointer to the parsed value
|
||||
func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
|
||||
// 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
|
||||
@@ -63,14 +73,19 @@ func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
|
||||
return nil, STSErrInvalidParameterValue, fmt.Errorf("invalid DurationSeconds: %w", err)
|
||||
}
|
||||
|
||||
if ds < minDurationSeconds || ds > maxDurationSeconds {
|
||||
if ds < minSec || ds > maxSec {
|
||||
return nil, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)
|
||||
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minSec, maxSec)
|
||||
}
|
||||
|
||||
return &ds, "", nil
|
||||
}
|
||||
|
||||
// parseDurationSeconds parses DurationSeconds for AssumeRole (15 min to 12 hours)
|
||||
func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
|
||||
return parseDurationSecondsWithBounds(r, minDurationSeconds, maxDurationSeconds)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -124,6 +139,8 @@ func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
@@ -296,7 +313,7 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// Check authorizations
|
||||
if roleArn != "" {
|
||||
// Check if the caller is authorized to assume the role (sts:AssumeRole permission)
|
||||
if authErr := h.iam.VerifyActionPermission(r, identity, Action("sts:AssumeRole"), "", roleArn); authErr != s3err.ErrNone {
|
||||
if authErr := h.iam.VerifyActionPermission(r, identity, Action(sts.ActionAssumeRole), "", roleArn); authErr != s3err.ErrNone {
|
||||
glog.V(2).Infof("AssumeRole: caller %s is not authorized to assume role %s", identity.Name, roleArn)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn))
|
||||
@@ -320,7 +337,7 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// For safety/consistency with previous logic, we keep the check but strictly it might not be required by AWS for GetSessionToken.
|
||||
// But since this IS AssumeRole, let's keep it.
|
||||
// Admin/Global check when no specific role is requested
|
||||
if authErr := h.iam.VerifyActionPermission(r, identity, Action("sts:AssumeRole"), "", ""); authErr != s3err.ErrNone {
|
||||
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
|
||||
@@ -505,6 +522,202 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, 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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
for _, p := range userPolicies {
|
||||
policySet[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
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 := sts.NewCredentialGenerator()
|
||||
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
|
||||
func (h *STSHandlers) prepareSTSCredentials(ctx context.Context, roleArn, roleSessionName string,
|
||||
durationSeconds *int64, sessionPolicy string, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
|
||||
@@ -743,6 +956,27 @@ type GetCallerIdentityResult struct {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user