Files
seaweedFS/weed/s3api/s3_action_resolver_test.go
Chris Lu 059bee683f 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
2026-04-02 17:37:05 -07:00

116 lines
3.4 KiB
Go

package s3api
import (
"net/http"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
// TestMapBaseActionToS3Format_ServicePrefixPassthrough verifies that actions
// with known service prefixes (s3:, iam:, sts:) are returned unchanged.
func TestMapBaseActionToS3Format_ServicePrefixPassthrough(t *testing.T) {
tests := []struct {
name string
input string
expect string
}{
{"s3 prefix", "s3:GetObject", "s3:GetObject"},
{"iam prefix", "iam:CreateUser", "iam:CreateUser"},
{"sts:AssumeRole", "sts:AssumeRole", "sts:AssumeRole"},
{"sts:GetFederationToken", "sts:GetFederationToken", "sts:GetFederationToken"},
{"sts:GetCallerIdentity", "sts:GetCallerIdentity", "sts:GetCallerIdentity"},
{"coarse Read maps to s3:GetObject", "Read", s3_constants.S3_ACTION_GET_OBJECT},
{"coarse Write maps to s3:PutObject", "Write", s3_constants.S3_ACTION_PUT_OBJECT},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mapBaseActionToS3Format(tt.input)
if got != tt.expect {
t.Errorf("mapBaseActionToS3Format(%q) = %q, want %q", tt.input, got, tt.expect)
}
})
}
}
// TestResolveS3Action_STSActionsPassthrough verifies that STS actions flow
// through ResolveS3Action unchanged, both with and without an HTTP request.
func TestResolveS3Action_STSActionsPassthrough(t *testing.T) {
stsActions := []string{
"sts:AssumeRole",
"sts:GetFederationToken",
"sts:GetCallerIdentity",
}
for _, action := range stsActions {
t.Run("nil_request_"+action, func(t *testing.T) {
got := ResolveS3Action(nil, action, "", "")
if got != action {
t.Errorf("ResolveS3Action(nil, %q) = %q, want %q", action, got, action)
}
})
t.Run("with_request_"+action, func(t *testing.T) {
r, _ := http.NewRequest(http.MethodPost, "http://localhost/", nil)
got := ResolveS3Action(r, action, "", "")
if got != action {
t.Errorf("ResolveS3Action(r, %q) = %q, want %q", action, got, action)
}
})
}
}
func TestResolveS3Action_AttributesBeforeVersionId(t *testing.T) {
tests := []struct {
name string
query string
method string
baseAction string
object string
want string
}{
{
name: "attributes only",
query: "attributes",
method: http.MethodGet,
baseAction: s3_constants.ACTION_READ,
object: "key",
want: s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES,
},
{
name: "attributes with versionId",
query: "attributes&versionId=abc123",
method: http.MethodGet,
baseAction: s3_constants.ACTION_READ,
object: "key",
want: s3_constants.S3_ACTION_GET_OBJECT_ATTRIBUTES,
},
{
name: "versionId only GET",
query: "versionId=abc123",
method: http.MethodGet,
baseAction: s3_constants.ACTION_READ,
object: "key",
want: s3_constants.S3_ACTION_GET_OBJECT_VERSION,
},
{
name: "versionId only DELETE",
query: "versionId=abc123",
method: http.MethodDelete,
baseAction: s3_constants.ACTION_WRITE,
object: "key",
want: s3_constants.S3_ACTION_DELETE_OBJECT_VERSION,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest(tt.method, "http://localhost/bucket/"+tt.object+"?"+tt.query, nil)
got := ResolveS3Action(r, tt.baseAction, "bucket", tt.object)
if got != tt.want {
t.Errorf("ResolveS3Action() = %q, want %q", got, tt.want)
}
})
}
}