Browse Source
feat(s3): add STS GetFederationToken support (#8891)
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 clientpull/8877/merge
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1573 additions and 11 deletions
-
511test/s3/iam/s3_sts_get_federation_token_test.go
-
17weed/iam/integration/iam_manager.go
-
1weed/iam/sts/constants.go
-
4weed/s3api/s3_action_resolver.go
-
53weed/s3api/s3_action_resolver_test.go
-
252weed/s3api/s3api_sts.go
-
746weed/s3api/s3api_sts_get_federation_token_test.go
@ -0,0 +1,511 @@ |
|||
package iam |
|||
|
|||
import ( |
|||
"encoding/xml" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"net/url" |
|||
"os" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/aws/aws-sdk-go/aws" |
|||
"github.com/aws/aws-sdk-go/aws/credentials" |
|||
"github.com/aws/aws-sdk-go/aws/session" |
|||
"github.com/aws/aws-sdk-go/service/s3" |
|||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/require" |
|||
) |
|||
|
|||
// GetFederationTokenTestResponse represents the STS GetFederationToken response
|
|||
type GetFederationTokenTestResponse struct { |
|||
XMLName xml.Name `xml:"GetFederationTokenResponse"` |
|||
Result struct { |
|||
Credentials struct { |
|||
AccessKeyId string `xml:"AccessKeyId"` |
|||
SecretAccessKey string `xml:"SecretAccessKey"` |
|||
SessionToken string `xml:"SessionToken"` |
|||
Expiration string `xml:"Expiration"` |
|||
} `xml:"Credentials"` |
|||
FederatedUser struct { |
|||
FederatedUserId string `xml:"FederatedUserId"` |
|||
Arn string `xml:"Arn"` |
|||
} `xml:"FederatedUser"` |
|||
} `xml:"GetFederationTokenResult"` |
|||
} |
|||
|
|||
func getTestCredentials() (string, string) { |
|||
accessKey := os.Getenv("STS_TEST_ACCESS_KEY") |
|||
if accessKey == "" { |
|||
accessKey = "admin" |
|||
} |
|||
secretKey := os.Getenv("STS_TEST_SECRET_KEY") |
|||
if secretKey == "" { |
|||
secretKey = "admin" |
|||
} |
|||
return accessKey, secretKey |
|||
} |
|||
|
|||
// isGetFederationTokenImplemented checks if the running server supports GetFederationToken
|
|||
func isGetFederationTokenImplemented(t *testing.T) bool { |
|||
accessKey, secretKey := getTestCredentials() |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"probe"}, |
|||
}, accessKey, secretKey) |
|||
if err != nil { |
|||
return false |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
if xml.Unmarshal(body, &errResp) == nil { |
|||
if errResp.Error.Code == "InvalidAction" || errResp.Error.Code == "NotImplemented" { |
|||
return false |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// TestSTSGetFederationTokenValidation tests input validation for the GetFederationToken endpoint
|
|||
func TestSTSGetFederationTokenValidation(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("Skipping integration test in short mode") |
|||
} |
|||
|
|||
if !isSTSEndpointRunning(t) { |
|||
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first") |
|||
} |
|||
|
|||
if !isGetFederationTokenImplemented(t) { |
|||
t.Fatal("GetFederationToken action is not implemented in the running server") |
|||
} |
|||
|
|||
accessKey, secretKey := getTestCredentials() |
|||
|
|||
t.Run("missing_name", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
// Name is missing
|
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "MissingParameter", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("name_too_short", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"A"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("name_too_long", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {strings.Repeat("A", 33)}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("name_invalid_characters", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"bad name"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("duration_too_short", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"TestApp"}, |
|||
"DurationSeconds": {"100"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("duration_too_long", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"TestApp"}, |
|||
"DurationSeconds": {"200000"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("malformed_policy", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"TestApp"}, |
|||
"Policy": {"not-valid-json"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
var errResp STSErrorTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &errResp), "Failed to parse: %s", string(body)) |
|||
assert.Equal(t, "MalformedPolicyDocument", errResp.Error.Code) |
|||
}) |
|||
|
|||
t.Run("anonymous_rejected", func(t *testing.T) { |
|||
// GetFederationToken requires SigV4, anonymous should fail
|
|||
resp, err := callSTSAPI(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"TestApp"}, |
|||
}) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
assert.NotEqual(t, http.StatusOK, resp.StatusCode) |
|||
}) |
|||
} |
|||
|
|||
// TestSTSGetFederationTokenRejectTemporaryCredentials tests that temporary
|
|||
// credentials (session tokens) are rejected by GetFederationToken
|
|||
func TestSTSGetFederationTokenRejectTemporaryCredentials(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("Skipping integration test in short mode") |
|||
} |
|||
|
|||
if !isSTSEndpointRunning(t) { |
|||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
|||
} |
|||
|
|||
if !isGetFederationTokenImplemented(t) { |
|||
t.Skip("GetFederationToken not implemented") |
|||
} |
|||
|
|||
accessKey, secretKey := getTestCredentials() |
|||
|
|||
// First, obtain temporary credentials via AssumeRole
|
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"AssumeRole"}, |
|||
"Version": {"2011-06-15"}, |
|||
"RoleArn": {"arn:aws:iam::role/admin"}, |
|||
"RoleSessionName": {"temp-session"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, err := io.ReadAll(resp.Body) |
|||
require.NoError(t, err) |
|||
|
|||
if resp.StatusCode != http.StatusOK { |
|||
t.Skipf("AssumeRole failed (may not be configured): status=%d body=%s", resp.StatusCode, string(body)) |
|||
} |
|||
|
|||
var assumeResp AssumeRoleTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &assumeResp), "Parse AssumeRole response: %s", string(body)) |
|||
|
|||
tempAccessKey := assumeResp.Result.Credentials.AccessKeyId |
|||
tempSecretKey := assumeResp.Result.Credentials.SecretAccessKey |
|||
tempSessionToken := assumeResp.Result.Credentials.SessionToken |
|||
require.NotEmpty(t, tempAccessKey) |
|||
require.NotEmpty(t, tempSessionToken) |
|||
|
|||
// Now try GetFederationToken with the temporary credentials
|
|||
// Include X-Amz-Security-Token header which marks this as a temp credential call
|
|||
params := url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"ShouldFail"}, |
|||
} |
|||
|
|||
reqBody := params.Encode() |
|||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/", strings.NewReader(reqBody)) |
|||
require.NoError(t, err) |
|||
|
|||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|||
req.Header.Set("X-Amz-Security-Token", tempSessionToken) |
|||
|
|||
creds := credentials.NewStaticCredentials(tempAccessKey, tempSecretKey, tempSessionToken) |
|||
signer := v4.NewSigner(creds) |
|||
_, err = signer.Sign(req, strings.NewReader(reqBody), "sts", "us-east-1", time.Now()) |
|||
require.NoError(t, err) |
|||
|
|||
client := &http.Client{Timeout: 30 * time.Second} |
|||
resp2, err := client.Do(req) |
|||
require.NoError(t, err) |
|||
defer resp2.Body.Close() |
|||
|
|||
body2, _ := io.ReadAll(resp2.Body) |
|||
assert.Equal(t, http.StatusForbidden, resp2.StatusCode, |
|||
"GetFederationToken should reject temporary credentials: %s", string(body2)) |
|||
assert.Contains(t, string(body2), "temporary credentials", |
|||
"Error should mention temporary credentials") |
|||
} |
|||
|
|||
// TestSTSGetFederationTokenSuccess tests a successful GetFederationToken call
|
|||
// and verifies the returned credentials can be used to access S3
|
|||
func TestSTSGetFederationTokenSuccess(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("Skipping integration test in short mode") |
|||
} |
|||
|
|||
if !isSTSEndpointRunning(t) { |
|||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
|||
} |
|||
|
|||
if !isGetFederationTokenImplemented(t) { |
|||
t.Skip("GetFederationToken not implemented") |
|||
} |
|||
|
|||
accessKey, secretKey := getTestCredentials() |
|||
|
|||
t.Run("basic_success", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"AppClient"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, err := io.ReadAll(resp.Body) |
|||
require.NoError(t, err) |
|||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) |
|||
|
|||
if resp.StatusCode != http.StatusOK { |
|||
var errResp STSErrorTestResponse |
|||
_ = xml.Unmarshal(body, &errResp) |
|||
t.Fatalf("GetFederationToken failed: code=%s message=%s", errResp.Error.Code, errResp.Error.Message) |
|||
} |
|||
|
|||
var stsResp GetFederationTokenTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &stsResp), "Parse response: %s", string(body)) |
|||
|
|||
creds := stsResp.Result.Credentials |
|||
assert.NotEmpty(t, creds.AccessKeyId) |
|||
assert.NotEmpty(t, creds.SecretAccessKey) |
|||
assert.NotEmpty(t, creds.SessionToken) |
|||
assert.NotEmpty(t, creds.Expiration) |
|||
|
|||
fedUser := stsResp.Result.FederatedUser |
|||
assert.Contains(t, fedUser.Arn, "federated-user/AppClient") |
|||
assert.Contains(t, fedUser.FederatedUserId, "AppClient") |
|||
}) |
|||
|
|||
t.Run("with_custom_duration", func(t *testing.T) { |
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"DurationTest"}, |
|||
"DurationSeconds": {"3600"}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body)) |
|||
|
|||
if resp.StatusCode == http.StatusOK { |
|||
var stsResp GetFederationTokenTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &stsResp)) |
|||
assert.NotEmpty(t, stsResp.Result.Credentials.AccessKeyId) |
|||
|
|||
// Verify expiration is roughly 1 hour from now
|
|||
expTime, err := time.Parse(time.RFC3339, stsResp.Result.Credentials.Expiration) |
|||
require.NoError(t, err) |
|||
diff := time.Until(expTime) |
|||
assert.InDelta(t, 3600, diff.Seconds(), 60, |
|||
"Expiration should be ~1 hour from now") |
|||
} |
|||
}) |
|||
|
|||
t.Run("with_36_hour_duration", func(t *testing.T) { |
|||
// GetFederationToken allows up to 36 hours (unlike AssumeRole's 12h max)
|
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"LongDuration"}, |
|||
"DurationSeconds": {"129600"}, // 36 hours
|
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
if resp.StatusCode == http.StatusOK { |
|||
var stsResp GetFederationTokenTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &stsResp)) |
|||
|
|||
expTime, err := time.Parse(time.RFC3339, stsResp.Result.Credentials.Expiration) |
|||
require.NoError(t, err) |
|||
diff := time.Until(expTime) |
|||
assert.InDelta(t, 129600, diff.Seconds(), 60, |
|||
"Expiration should be ~36 hours from now") |
|||
} else { |
|||
// Duration should not cause a rejection
|
|||
var errResp STSErrorTestResponse |
|||
_ = xml.Unmarshal(body, &errResp) |
|||
assert.NotContains(t, errResp.Error.Message, "DurationSeconds", |
|||
"36-hour duration should be accepted by GetFederationToken") |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// TestSTSGetFederationTokenWithSessionPolicy tests that vended credentials
|
|||
// are scoped down by an inline session policy
|
|||
func TestSTSGetFederationTokenWithSessionPolicy(t *testing.T) { |
|||
if testing.Short() { |
|||
t.Skip("Skipping integration test in short mode") |
|||
} |
|||
|
|||
if !isSTSEndpointRunning(t) { |
|||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint) |
|||
} |
|||
|
|||
if !isGetFederationTokenImplemented(t) { |
|||
t.Skip("GetFederationToken not implemented") |
|||
} |
|||
|
|||
accessKey, secretKey := getTestCredentials() |
|||
|
|||
// Create a test bucket using admin credentials
|
|||
adminSess, err := session.NewSession(&aws.Config{ |
|||
Region: aws.String("us-east-1"), |
|||
Endpoint: aws.String(TestSTSEndpoint), |
|||
DisableSSL: aws.Bool(true), |
|||
S3ForcePathStyle: aws.Bool(true), |
|||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
adminS3 := s3.New(adminSess) |
|||
bucket := fmt.Sprintf("fed-token-test-%d", time.Now().UnixNano()) |
|||
|
|||
_, err = adminS3.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket)}) |
|||
require.NoError(t, err) |
|||
defer adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucket)}) |
|||
|
|||
_, err = adminS3.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucket), |
|||
Key: aws.String("test.txt"), |
|||
Body: strings.NewReader("hello"), |
|||
}) |
|||
require.NoError(t, err) |
|||
defer adminS3.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String("test.txt")}) |
|||
|
|||
// Get federated credentials with a session policy that only allows GetObject
|
|||
sessionPolicy := fmt.Sprintf(`{ |
|||
"Version": "2012-10-17", |
|||
"Statement": [{ |
|||
"Effect": "Allow", |
|||
"Action": ["s3:GetObject"], |
|||
"Resource": ["arn:aws:s3:::%s/*"] |
|||
}] |
|||
}`, bucket) |
|||
|
|||
resp, err := callSTSAPIWithSigV4(t, url.Values{ |
|||
"Action": {"GetFederationToken"}, |
|||
"Version": {"2011-06-15"}, |
|||
"Name": {"ScopedClient"}, |
|||
"Policy": {sessionPolicy}, |
|||
}, accessKey, secretKey) |
|||
require.NoError(t, err) |
|||
defer resp.Body.Close() |
|||
|
|||
body, _ := io.ReadAll(resp.Body) |
|||
t.Logf("GetFederationToken response: status=%d body=%s", resp.StatusCode, string(body)) |
|||
|
|||
if resp.StatusCode != http.StatusOK { |
|||
t.Skipf("GetFederationToken failed (may need IAM policy config): %s", string(body)) |
|||
} |
|||
|
|||
var stsResp GetFederationTokenTestResponse |
|||
require.NoError(t, xml.Unmarshal(body, &stsResp)) |
|||
|
|||
fedCreds := stsResp.Result.Credentials |
|||
require.NotEmpty(t, fedCreds.AccessKeyId) |
|||
require.NotEmpty(t, fedCreds.SessionToken) |
|||
|
|||
// Create S3 client with the federated credentials
|
|||
fedSess, err := session.NewSession(&aws.Config{ |
|||
Region: aws.String("us-east-1"), |
|||
Endpoint: aws.String(TestSTSEndpoint), |
|||
DisableSSL: aws.Bool(true), |
|||
S3ForcePathStyle: aws.Bool(true), |
|||
Credentials: credentials.NewStaticCredentials( |
|||
fedCreds.AccessKeyId, fedCreds.SecretAccessKey, fedCreds.SessionToken), |
|||
}) |
|||
require.NoError(t, err) |
|||
|
|||
fedS3 := s3.New(fedSess) |
|||
|
|||
// GetObject should succeed (allowed by session policy)
|
|||
getResp, err := fedS3.GetObject(&s3.GetObjectInput{ |
|||
Bucket: aws.String(bucket), |
|||
Key: aws.String("test.txt"), |
|||
}) |
|||
if err == nil { |
|||
defer getResp.Body.Close() |
|||
t.Log("GetObject with federated credentials succeeded (as expected)") |
|||
} else { |
|||
t.Logf("GetObject with federated credentials: %v (session policy enforcement may vary)", err) |
|||
} |
|||
|
|||
// PutObject should be denied (not allowed by session policy)
|
|||
_, err = fedS3.PutObject(&s3.PutObjectInput{ |
|||
Bucket: aws.String(bucket), |
|||
Key: aws.String("denied.txt"), |
|||
Body: strings.NewReader("should fail"), |
|||
}) |
|||
if err != nil { |
|||
t.Log("PutObject correctly denied with federated credentials") |
|||
assert.Contains(t, err.Error(), "AccessDenied", |
|||
"PutObject should be denied by session policy") |
|||
} else { |
|||
// Clean up if unexpectedly succeeded
|
|||
adminS3.DeleteObject(&s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: aws.String("denied.txt")}) |
|||
t.Log("PutObject unexpectedly succeeded — session policy enforcement may not be active") |
|||
} |
|||
} |
|||
@ -0,0 +1,746 @@ |
|||
package s3api |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/xml" |
|||
"fmt" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"net/url" |
|||
"sort" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/iam/integration" |
|||
"github.com/seaweedfs/seaweedfs/weed/iam/policy" |
|||
"github.com/seaweedfs/seaweedfs/weed/iam/sts" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/require" |
|||
) |
|||
|
|||
// mockUserStore implements integration.UserStore for testing GetPoliciesForUser
|
|||
type mockUserStore struct { |
|||
users map[string]*iam_pb.Identity |
|||
} |
|||
|
|||
func (m *mockUserStore) GetUser(_ context.Context, username string) (*iam_pb.Identity, error) { |
|||
u, ok := m.users[username] |
|||
if !ok { |
|||
return nil, nil |
|||
} |
|||
return u, nil |
|||
} |
|||
|
|||
// TestGetFederationToken_BasicFlow tests basic credential generation for GetFederationToken
|
|||
func TestGetFederationToken_BasicFlow(t *testing.T) { |
|||
stsService, _ := setupTestSTSService(t) |
|||
|
|||
iam := &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
} |
|||
stsHandlers := NewSTSHandlers(stsService, iam) |
|||
|
|||
// Simulate the core logic of handleGetFederationToken
|
|||
name := "BobApp" |
|||
callerIdentity := &Identity{ |
|||
Name: "alice", |
|||
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/alice", defaultAccountID), |
|||
PolicyNames: []string{"S3ReadPolicy"}, |
|||
} |
|||
|
|||
accountID := stsHandlers.getAccountID() |
|||
|
|||
// Generate session ID and credentials
|
|||
sessionId, err := sts.GenerateSessionId() |
|||
require.NoError(t, err) |
|||
|
|||
expiration := time.Now().Add(12 * time.Hour) |
|||
federatedUserArn := fmt.Sprintf("arn:aws:sts::%s:federated-user/%s", accountID, name) |
|||
federatedUserId := fmt.Sprintf("%s:%s", accountID, name) |
|||
|
|||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiration). |
|||
WithSessionName(name). |
|||
WithRoleInfo(callerIdentity.PrincipalArn, federatedUserId, federatedUserArn). |
|||
WithPolicies(callerIdentity.PolicyNames) |
|||
|
|||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims) |
|||
require.NoError(t, err) |
|||
|
|||
// Validate the session token
|
|||
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken) |
|||
require.NoError(t, err) |
|||
require.NotNil(t, sessionInfo) |
|||
|
|||
// Verify the session info contains caller's policies
|
|||
assert.Equal(t, []string{"S3ReadPolicy"}, sessionInfo.Policies) |
|||
|
|||
// Verify principal is the federated user ARN
|
|||
assert.Equal(t, federatedUserArn, sessionInfo.Principal) |
|||
|
|||
// Verify the RoleArn points to the caller's identity (for policy resolution)
|
|||
assert.Equal(t, callerIdentity.PrincipalArn, sessionInfo.RoleArn) |
|||
|
|||
// Verify session name
|
|||
assert.Equal(t, name, sessionInfo.SessionName) |
|||
} |
|||
|
|||
// TestGetFederationToken_WithSessionPolicy tests session policy scoping
|
|||
func TestGetFederationToken_WithSessionPolicy(t *testing.T) { |
|||
stsService, _ := setupTestSTSService(t) |
|||
|
|||
stsHandlers := NewSTSHandlers(stsService, &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
}) |
|||
|
|||
accountID := stsHandlers.getAccountID() |
|||
name := "ScopedApp" |
|||
|
|||
sessionPolicyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::my-bucket/*"]}]}` |
|||
normalizedPolicy, err := sts.NormalizeSessionPolicy(sessionPolicyJSON) |
|||
require.NoError(t, err) |
|||
|
|||
sessionId, err := sts.GenerateSessionId() |
|||
require.NoError(t, err) |
|||
|
|||
expiration := time.Now().Add(12 * time.Hour) |
|||
federatedUserArn := fmt.Sprintf("arn:aws:sts::%s:federated-user/%s", accountID, name) |
|||
federatedUserId := fmt.Sprintf("%s:%s", accountID, name) |
|||
|
|||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiration). |
|||
WithSessionName(name). |
|||
WithRoleInfo("arn:aws:iam::000000000000:user/caller", federatedUserId, federatedUserArn). |
|||
WithPolicies([]string{"S3FullAccess"}). |
|||
WithSessionPolicy(normalizedPolicy) |
|||
|
|||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims) |
|||
require.NoError(t, err) |
|||
|
|||
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken) |
|||
require.NoError(t, err) |
|||
require.NotNil(t, sessionInfo) |
|||
|
|||
// Verify session policy is embedded
|
|||
assert.NotEmpty(t, sessionInfo.SessionPolicy) |
|||
assert.Contains(t, sessionInfo.SessionPolicy, "s3:GetObject") |
|||
|
|||
// Verify caller's policies are still present
|
|||
assert.Equal(t, []string{"S3FullAccess"}, sessionInfo.Policies) |
|||
} |
|||
|
|||
// TestGetFederationToken_RejectTemporaryCredentials tests that requests with
|
|||
// session tokens are rejected.
|
|||
func TestGetFederationToken_RejectTemporaryCredentials(t *testing.T) { |
|||
stsService, _ := setupTestSTSService(t) |
|||
stsHandlers := NewSTSHandlers(stsService, &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
}) |
|||
|
|||
tests := []struct { |
|||
name string |
|||
setToken func(r *http.Request) |
|||
description string |
|||
}{ |
|||
{ |
|||
name: "SessionTokenInHeader", |
|||
setToken: func(r *http.Request) { |
|||
r.Header.Set("X-Amz-Security-Token", "some-session-token") |
|||
}, |
|||
description: "Session token in X-Amz-Security-Token header should be rejected", |
|||
}, |
|||
{ |
|||
name: "SessionTokenInQuery", |
|||
setToken: func(r *http.Request) { |
|||
q := r.URL.Query() |
|||
q.Set("X-Amz-Security-Token", "some-session-token") |
|||
r.URL.RawQuery = q.Encode() |
|||
}, |
|||
description: "Session token in query string should be rejected", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
form := url.Values{} |
|||
form.Set("Action", "GetFederationToken") |
|||
form.Set("Name", "TestUser") |
|||
form.Set("Version", "2011-06-15") |
|||
|
|||
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode())) |
|||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|||
tt.setToken(req) |
|||
|
|||
// Parse form so the handler can read it
|
|||
require.NoError(t, req.ParseForm()) |
|||
// Re-set values after parse
|
|||
req.Form.Set("Action", "GetFederationToken") |
|||
req.Form.Set("Name", "TestUser") |
|||
req.Form.Set("Version", "2011-06-15") |
|||
|
|||
rr := httptest.NewRecorder() |
|||
stsHandlers.HandleSTSRequest(rr, req) |
|||
|
|||
// The handler rejects temporary credentials before SigV4 verification
|
|||
assert.Equal(t, http.StatusForbidden, rr.Code, tt.description) |
|||
assert.Contains(t, rr.Body.String(), "AccessDenied") |
|||
assert.Contains(t, rr.Body.String(), "cannot be called with temporary credentials") |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGetFederationToken_MissingName tests that a missing Name parameter returns an error
|
|||
func TestGetFederationToken_MissingName(t *testing.T) { |
|||
stsService, _ := setupTestSTSService(t) |
|||
stsHandlers := NewSTSHandlers(stsService, &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
}) |
|||
|
|||
req := httptest.NewRequest("POST", "/", nil) |
|||
req.Form = url.Values{} |
|||
req.Form.Set("Action", "GetFederationToken") |
|||
req.Form.Set("Version", "2011-06-15") |
|||
// Name is intentionally omitted
|
|||
|
|||
rr := httptest.NewRecorder() |
|||
stsHandlers.HandleSTSRequest(rr, req) |
|||
|
|||
assert.Equal(t, http.StatusBadRequest, rr.Code) |
|||
assert.Contains(t, rr.Body.String(), "Name is required") |
|||
} |
|||
|
|||
// TestGetFederationToken_NameValidation tests Name parameter validation
|
|||
func TestGetFederationToken_NameValidation(t *testing.T) { |
|||
stsService, _ := setupTestSTSService(t) |
|||
stsHandlers := NewSTSHandlers(stsService, &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
}) |
|||
|
|||
tests := []struct { |
|||
name string |
|||
federName string |
|||
expectError bool |
|||
errContains string |
|||
}{ |
|||
{ |
|||
name: "TooShort", |
|||
federName: "A", |
|||
expectError: true, |
|||
errContains: "between 2 and 32", |
|||
}, |
|||
{ |
|||
name: "TooLong", |
|||
federName: strings.Repeat("A", 33), |
|||
expectError: true, |
|||
errContains: "between 2 and 32", |
|||
}, |
|||
{ |
|||
name: "MinLength", |
|||
federName: "AB", |
|||
expectError: false, |
|||
}, |
|||
{ |
|||
name: "MaxLength", |
|||
federName: strings.Repeat("A", 32), |
|||
expectError: false, |
|||
}, |
|||
{ |
|||
name: "ValidSpecialChars", |
|||
federName: "user+=,.@-test", |
|||
expectError: false, |
|||
}, |
|||
{ |
|||
name: "InvalidChars_Space", |
|||
federName: "bad name", |
|||
expectError: true, |
|||
errContains: "invalid characters", |
|||
}, |
|||
{ |
|||
name: "InvalidChars_Slash", |
|||
federName: "bad/name", |
|||
expectError: true, |
|||
errContains: "invalid characters", |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
req := httptest.NewRequest("POST", "/", nil) |
|||
req.Form = url.Values{} |
|||
req.Form.Set("Action", "GetFederationToken") |
|||
req.Form.Set("Name", tt.federName) |
|||
req.Form.Set("Version", "2011-06-15") |
|||
|
|||
rr := httptest.NewRecorder() |
|||
stsHandlers.HandleSTSRequest(rr, req) |
|||
|
|||
if tt.expectError { |
|||
assert.Equal(t, http.StatusBadRequest, rr.Code) |
|||
assert.Contains(t, rr.Body.String(), tt.errContains) |
|||
} else { |
|||
// Valid name should proceed past validation — will fail at SigV4
|
|||
// (returns 403 because we have no real signature)
|
|||
assert.NotEqual(t, http.StatusBadRequest, rr.Code, |
|||
"Valid name should not produce a 400 for name validation") |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGetFederationToken_DurationValidation tests DurationSeconds validation
|
|||
func TestGetFederationToken_DurationValidation(t *testing.T) { |
|||
stsService, _ := setupTestSTSService(t) |
|||
stsHandlers := NewSTSHandlers(stsService, &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
}) |
|||
|
|||
tests := []struct { |
|||
name string |
|||
duration string |
|||
expectError bool |
|||
errContains string |
|||
}{ |
|||
{ |
|||
name: "BelowMinimum", |
|||
duration: "899", |
|||
expectError: true, |
|||
errContains: "between", |
|||
}, |
|||
{ |
|||
name: "AboveMaximum", |
|||
duration: "129601", |
|||
expectError: true, |
|||
errContains: "between", |
|||
}, |
|||
{ |
|||
name: "InvalidFormat", |
|||
duration: "not-a-number", |
|||
expectError: true, |
|||
errContains: "invalid DurationSeconds", |
|||
}, |
|||
{ |
|||
name: "MinimumValid", |
|||
duration: "900", |
|||
expectError: false, |
|||
}, |
|||
{ |
|||
name: "MaximumValid_36Hours", |
|||
duration: "129600", |
|||
expectError: false, |
|||
}, |
|||
{ |
|||
name: "Default12Hours", |
|||
duration: "43200", |
|||
expectError: false, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
req := httptest.NewRequest("POST", "/", nil) |
|||
req.Form = url.Values{} |
|||
req.Form.Set("Action", "GetFederationToken") |
|||
req.Form.Set("Name", "TestUser") |
|||
req.Form.Set("DurationSeconds", tt.duration) |
|||
req.Form.Set("Version", "2011-06-15") |
|||
|
|||
rr := httptest.NewRecorder() |
|||
stsHandlers.HandleSTSRequest(rr, req) |
|||
|
|||
if tt.expectError { |
|||
assert.Equal(t, http.StatusBadRequest, rr.Code) |
|||
assert.Contains(t, rr.Body.String(), tt.errContains) |
|||
} else { |
|||
// Valid duration should proceed past validation — will fail at SigV4
|
|||
assert.NotEqual(t, http.StatusBadRequest, rr.Code, |
|||
"Valid duration should not produce a 400 for duration validation") |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGetFederationToken_ResponseFormat tests the XML response structure
|
|||
func TestGetFederationToken_ResponseFormat(t *testing.T) { |
|||
// Verify the response XML structure matches AWS format
|
|||
response := GetFederationTokenResponse{ |
|||
Result: GetFederationTokenResult{ |
|||
Credentials: STSCredentials{ |
|||
AccessKeyId: "ASIA1234567890", |
|||
SecretAccessKey: "secret123", |
|||
SessionToken: "token123", |
|||
Expiration: "2026-04-02T12:00:00Z", |
|||
}, |
|||
FederatedUser: FederatedUser{ |
|||
FederatedUserId: "000000000000:BobApp", |
|||
Arn: "arn:aws:sts::000000000000:federated-user/BobApp", |
|||
}, |
|||
}, |
|||
} |
|||
response.ResponseMetadata.RequestId = "test-request-id" |
|||
|
|||
data, err := xml.MarshalIndent(response, "", " ") |
|||
require.NoError(t, err) |
|||
|
|||
xmlStr := string(data) |
|||
assert.Contains(t, xmlStr, "GetFederationTokenResponse") |
|||
assert.Contains(t, xmlStr, "GetFederationTokenResult") |
|||
assert.Contains(t, xmlStr, "FederatedUser") |
|||
assert.Contains(t, xmlStr, "FederatedUserId") |
|||
assert.Contains(t, xmlStr, "federated-user/BobApp") |
|||
assert.Contains(t, xmlStr, "ASIA1234567890") |
|||
assert.Contains(t, xmlStr, "test-request-id") |
|||
|
|||
// Verify it can be unmarshaled back
|
|||
var parsed GetFederationTokenResponse |
|||
err = xml.Unmarshal(data, &parsed) |
|||
require.NoError(t, err) |
|||
assert.Equal(t, "ASIA1234567890", parsed.Result.Credentials.AccessKeyId) |
|||
assert.Equal(t, "arn:aws:sts::000000000000:federated-user/BobApp", parsed.Result.FederatedUser.Arn) |
|||
assert.Equal(t, "000000000000:BobApp", parsed.Result.FederatedUser.FederatedUserId) |
|||
} |
|||
|
|||
// TestGetFederationToken_PolicyEmbedding tests that the caller's policies are embedded
|
|||
// into the session token using the IAM integration manager
|
|||
func TestGetFederationToken_PolicyEmbedding(t *testing.T) { |
|||
ctx := context.Background() |
|||
manager := newTestSTSIntegrationManager(t) |
|||
|
|||
// Create a policy that the user has attached
|
|||
userPolicy := &policy.PolicyDocument{ |
|||
Version: "2012-10-17", |
|||
Statement: []policy.Statement{ |
|||
{ |
|||
Effect: "Allow", |
|||
Action: []string{"s3:GetObject", "s3:PutObject"}, |
|||
Resource: []string{"arn:aws:s3:::user-bucket/*"}, |
|||
}, |
|||
}, |
|||
} |
|||
require.NoError(t, manager.CreatePolicy(ctx, "", "UserS3Policy", userPolicy)) |
|||
|
|||
stsService := manager.GetSTSService() |
|||
|
|||
// Simulate what handleGetFederationToken does for policy embedding
|
|||
name := "AppClient" |
|||
callerPolicies := []string{"UserS3Policy"} |
|||
|
|||
sessionId, err := sts.GenerateSessionId() |
|||
require.NoError(t, err) |
|||
|
|||
expiration := time.Now().Add(12 * time.Hour) |
|||
accountID := defaultAccountID |
|||
federatedUserArn := fmt.Sprintf("arn:aws:sts::%s:federated-user/%s", accountID, name) |
|||
federatedUserId := fmt.Sprintf("%s:%s", accountID, name) |
|||
|
|||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiration). |
|||
WithSessionName(name). |
|||
WithRoleInfo("arn:aws:iam::000000000000:user/caller", federatedUserId, federatedUserArn). |
|||
WithPolicies(callerPolicies) |
|||
|
|||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims) |
|||
require.NoError(t, err) |
|||
|
|||
sessionInfo, err := stsService.ValidateSessionToken(ctx, sessionToken) |
|||
require.NoError(t, err) |
|||
require.NotNil(t, sessionInfo) |
|||
|
|||
// Verify the caller's policy names are embedded
|
|||
assert.Equal(t, []string{"UserS3Policy"}, sessionInfo.Policies) |
|||
} |
|||
|
|||
// TestGetFederationToken_PolicyIntersection tests that both the caller's base policies
|
|||
// and the restrictive session policy are embedded in the token, enabling the
|
|||
// authorization layer to compute their intersection at request time.
|
|||
func TestGetFederationToken_PolicyIntersection(t *testing.T) { |
|||
ctx := context.Background() |
|||
manager := newTestSTSIntegrationManager(t) |
|||
|
|||
// Create a broad policy for the caller
|
|||
broadPolicy := &policy.PolicyDocument{ |
|||
Version: "2012-10-17", |
|||
Statement: []policy.Statement{ |
|||
{ |
|||
Effect: "Allow", |
|||
Action: []string{"s3:*"}, |
|||
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"}, |
|||
}, |
|||
}, |
|||
} |
|||
require.NoError(t, manager.CreatePolicy(ctx, "", "S3FullAccess", broadPolicy)) |
|||
|
|||
stsService := manager.GetSTSService() |
|||
|
|||
// Session policy restricts to one bucket and one action
|
|||
sessionPolicyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::restricted-bucket/*"]}]}` |
|||
normalizedPolicy, err := sts.NormalizeSessionPolicy(sessionPolicyJSON) |
|||
require.NoError(t, err) |
|||
|
|||
sessionId, err := sts.GenerateSessionId() |
|||
require.NoError(t, err) |
|||
|
|||
expiration := time.Now().Add(12 * time.Hour) |
|||
name := "RestrictedApp" |
|||
accountID := defaultAccountID |
|||
federatedUserArn := fmt.Sprintf("arn:aws:sts::%s:federated-user/%s", accountID, name) |
|||
federatedUserId := fmt.Sprintf("%s:%s", accountID, name) |
|||
|
|||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiration). |
|||
WithSessionName(name). |
|||
WithRoleInfo("arn:aws:iam::000000000000:user/caller", federatedUserId, federatedUserArn). |
|||
WithPolicies([]string{"S3FullAccess"}). |
|||
WithSessionPolicy(normalizedPolicy) |
|||
|
|||
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims) |
|||
require.NoError(t, err) |
|||
|
|||
sessionInfo, err := stsService.ValidateSessionToken(ctx, sessionToken) |
|||
require.NoError(t, err) |
|||
require.NotNil(t, sessionInfo) |
|||
|
|||
// Verify both the broad base policies and the restrictive session policy are embedded
|
|||
// The authorization layer computes intersection at request time
|
|||
assert.Equal(t, []string{"S3FullAccess"}, sessionInfo.Policies, |
|||
"Caller's base policies should be embedded in token") |
|||
assert.Contains(t, sessionInfo.SessionPolicy, "restricted-bucket", |
|||
"Session policy should restrict to specific bucket") |
|||
assert.Contains(t, sessionInfo.SessionPolicy, "s3:GetObject", |
|||
"Session policy should restrict to specific action") |
|||
} |
|||
|
|||
// TestGetFederationToken_MalformedPolicy tests that invalid policy JSON is rejected
|
|||
// by the session policy normalization used in the handler
|
|||
func TestGetFederationToken_MalformedPolicy(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
policyStr string |
|||
expectErr bool |
|||
}{ |
|||
{ |
|||
name: "InvalidJSON", |
|||
policyStr: "not-valid-json", |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "EmptyObject", |
|||
policyStr: "{}", |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "TooLarge", |
|||
policyStr: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["` + strings.Repeat("a", 2048) + `"]}]}`, |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "ValidPolicy", |
|||
policyStr: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`, |
|||
expectErr: false, |
|||
}, |
|||
{ |
|||
name: "EmptyString", |
|||
policyStr: "", |
|||
expectErr: false, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
_, err := sts.NormalizeSessionPolicy(tt.policyStr) |
|||
if tt.expectErr { |
|||
assert.Error(t, err) |
|||
} else { |
|||
assert.NoError(t, err) |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// TestGetFederationToken_STSNotReady tests that the handler returns 503 when STS is not initialized
|
|||
func TestGetFederationToken_STSNotReady(t *testing.T) { |
|||
// Create handlers with nil STS service
|
|||
stsHandlers := NewSTSHandlers(nil, &IdentityAccessManagement{ |
|||
iamIntegration: &MockIAMIntegration{}, |
|||
}) |
|||
|
|||
req := httptest.NewRequest("POST", "/", nil) |
|||
req.Form = url.Values{} |
|||
req.Form.Set("Action", "GetFederationToken") |
|||
req.Form.Set("Name", "TestUser") |
|||
req.Form.Set("Version", "2011-06-15") |
|||
|
|||
rr := httptest.NewRecorder() |
|||
stsHandlers.HandleSTSRequest(rr, req) |
|||
|
|||
assert.Equal(t, http.StatusServiceUnavailable, rr.Code) |
|||
assert.Contains(t, rr.Body.String(), "ServiceUnavailable") |
|||
} |
|||
|
|||
// TestGetFederationToken_DefaultDuration tests that the default duration is 12 hours
|
|||
func TestGetFederationToken_DefaultDuration(t *testing.T) { |
|||
assert.Equal(t, int64(43200), defaultFederationDurationSeconds, "Default duration should be 12 hours (43200 seconds)") |
|||
assert.Equal(t, int64(129600), maxFederationDurationSeconds, "Max duration should be 36 hours (129600 seconds)") |
|||
} |
|||
|
|||
// TestGetFederationToken_GetPoliciesForUser tests that GetPoliciesForUser
|
|||
// correctly resolves user policies from the UserStore and returns errors
|
|||
// when the store is unavailable.
|
|||
func TestGetFederationToken_GetPoliciesForUser(t *testing.T) { |
|||
ctx := context.Background() |
|||
manager := newTestSTSIntegrationManager(t) |
|||
|
|||
t.Run("NoUserStore", func(t *testing.T) { |
|||
// UserStore not set — should return error
|
|||
policies, err := manager.GetPoliciesForUser(ctx, "alice") |
|||
assert.Error(t, err) |
|||
assert.Nil(t, policies) |
|||
assert.Contains(t, err.Error(), "user store not configured") |
|||
}) |
|||
|
|||
t.Run("UserNotFound", func(t *testing.T) { |
|||
manager.SetUserStore(&mockUserStore{users: map[string]*iam_pb.Identity{}}) |
|||
policies, err := manager.GetPoliciesForUser(ctx, "nonexistent") |
|||
assert.NoError(t, err) |
|||
assert.Nil(t, policies) |
|||
}) |
|||
|
|||
t.Run("UserWithPolicies", func(t *testing.T) { |
|||
manager.SetUserStore(&mockUserStore{ |
|||
users: map[string]*iam_pb.Identity{ |
|||
"alice": { |
|||
Name: "alice", |
|||
PolicyNames: []string{"GroupReadPolicy", "GroupWritePolicy"}, |
|||
}, |
|||
}, |
|||
}) |
|||
policies, err := manager.GetPoliciesForUser(ctx, "alice") |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, []string{"GroupReadPolicy", "GroupWritePolicy"}, policies) |
|||
}) |
|||
|
|||
t.Run("UserWithNoPolicies", func(t *testing.T) { |
|||
manager.SetUserStore(&mockUserStore{ |
|||
users: map[string]*iam_pb.Identity{ |
|||
"bob": {Name: "bob"}, |
|||
}, |
|||
}) |
|||
policies, err := manager.GetPoliciesForUser(ctx, "bob") |
|||
assert.NoError(t, err) |
|||
assert.Empty(t, policies) |
|||
}) |
|||
} |
|||
|
|||
// TestGetFederationToken_PolicyMergeAndDedup tests that the handler's policy
|
|||
// merge logic correctly combines identity.PolicyNames with IAM-manager-resolved
|
|||
// policies and deduplicates the result.
|
|||
func TestGetFederationToken_PolicyMergeAndDedup(t *testing.T) { |
|||
ctx := context.Background() |
|||
manager := newTestSTSIntegrationManager(t) |
|||
|
|||
// Create policies so they exist in the engine
|
|||
for _, name := range []string{"DirectPolicy", "GroupPolicy", "SharedPolicy"} { |
|||
require.NoError(t, manager.CreatePolicy(ctx, "", name, &policy.PolicyDocument{ |
|||
Version: "2012-10-17", |
|||
Statement: []policy.Statement{ |
|||
{Effect: "Allow", Action: []string{"s3:GetObject"}, Resource: []string{"arn:aws:s3:::*/*"}}, |
|||
}, |
|||
})) |
|||
} |
|||
|
|||
// Set up a user store that returns group-attached policies
|
|||
manager.SetUserStore(&mockUserStore{ |
|||
users: map[string]*iam_pb.Identity{ |
|||
"alice": { |
|||
Name: "alice", |
|||
PolicyNames: []string{"GroupPolicy", "SharedPolicy"}, |
|||
}, |
|||
}, |
|||
}) |
|||
|
|||
stsService := manager.GetSTSService() |
|||
|
|||
// Simulate what the handler does: merge identity.PolicyNames with GetPoliciesForUser
|
|||
identityPolicies := []string{"DirectPolicy", "SharedPolicy"} // SharedPolicy overlaps
|
|||
|
|||
policySet := make(map[string]struct{}) |
|||
for _, p := range identityPolicies { |
|||
policySet[p] = struct{}{} |
|||
} |
|||
|
|||
userPolicies, err := manager.GetPoliciesForUser(ctx, "alice") |
|||
require.NoError(t, err) |
|||
for _, p := range userPolicies { |
|||
policySet[p] = struct{}{} |
|||
} |
|||
|
|||
merged := make([]string, 0, len(policySet)) |
|||
for p := range policySet { |
|||
merged = append(merged, p) |
|||
} |
|||
sort.Strings(merged) // deterministic for assertion
|
|||
|
|||
// Should contain all three unique policies, no duplicates
|
|||
assert.Equal(t, []string{"DirectPolicy", "GroupPolicy", "SharedPolicy"}, merged) |
|||
|
|||
// Verify the merged policies can be embedded in a token and recovered
|
|||
sessionId, err := sts.GenerateSessionId() |
|||
require.NoError(t, err) |
|||
|
|||
expiration := time.Now().Add(time.Hour) |
|||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiration). |
|||
WithSessionName("test"). |
|||
WithRoleInfo("arn:aws:iam::000000000000:user/alice", "000000000000:test", "arn:aws:sts::000000000000:federated-user/test"). |
|||
WithPolicies(merged) |
|||
|
|||
token, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims) |
|||
require.NoError(t, err) |
|||
|
|||
sessionInfo, err := stsService.ValidateSessionToken(ctx, token) |
|||
require.NoError(t, err) |
|||
|
|||
sort.Strings(sessionInfo.Policies) |
|||
assert.Equal(t, []string{"DirectPolicy", "GroupPolicy", "SharedPolicy"}, sessionInfo.Policies, |
|||
"Token should contain the deduplicated merge of identity and group policies") |
|||
} |
|||
|
|||
// TestGetFederationToken_PolicyMergeNoManager tests that when the IAM manager
|
|||
// is unavailable, identity.PolicyNames alone are still embedded.
|
|||
func TestGetFederationToken_PolicyMergeNoManager(t *testing.T) { |
|||
ctx := context.Background() |
|||
stsService, _ := setupTestSTSService(t) |
|||
|
|||
// No IAM manager — only identity.PolicyNames should be used
|
|||
identityPolicies := []string{"UserDirectPolicy"} |
|||
|
|||
policySet := make(map[string]struct{}) |
|||
for _, p := range identityPolicies { |
|||
policySet[p] = struct{}{} |
|||
} |
|||
|
|||
// IAM manager is nil — skip GetPoliciesForUser (mirrors handler logic)
|
|||
var policyManager *integration.IAMManager // nil
|
|||
if policyManager != nil { |
|||
t.Fatal("policyManager should be nil in this test") |
|||
} |
|||
|
|||
merged := make([]string, 0, len(policySet)) |
|||
for p := range policySet { |
|||
merged = append(merged, p) |
|||
} |
|||
|
|||
sessionId, err := sts.GenerateSessionId() |
|||
require.NoError(t, err) |
|||
|
|||
expiration := time.Now().Add(time.Hour) |
|||
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiration). |
|||
WithSessionName("test"). |
|||
WithRoleInfo("arn:aws:iam::000000000000:user/alice", "000000000000:test", "arn:aws:sts::000000000000:federated-user/test"). |
|||
WithPolicies(merged) |
|||
|
|||
token, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims) |
|||
require.NoError(t, err) |
|||
|
|||
sessionInfo, err := stsService.ValidateSessionToken(ctx, token) |
|||
require.NoError(t, err) |
|||
|
|||
assert.Equal(t, []string{"UserDirectPolicy"}, sessionInfo.Policies, |
|||
"Without IAM manager, only identity policies should be embedded") |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue