mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 05:24:28 +02:00
* fix(s3api): route STS GetFederationToken requests to STS handler (#9157) The STS GetFederationToken handler was implemented but never reachable. Three routing gaps sent requests to the S3/IAM path instead of STS: - No explicit mux route for Action=GetFederationToken in the URL query - iamMatcher did not exclude GetFederationToken, so authenticated POSTs with Action in the form body were matched and dispatched to IAM - UnifiedPostHandler only dispatched AssumeRole* and GetCallerIdentity to STS, leaving GetFederationToken to fall through to DoActions and return NotImplemented Add the missing route, the matcher exclusion, and the dispatch branch. Also wire TestSTS, TestAssumeRoleWithWebIdentity, and TestServiceAccount into the s3-iam-tests workflow as a new "sts" matrix entry. Before this change, none of test/s3/iam/s3_sts_get_federation_token_test.go's four test functions ran in CI, which is why this regression shipped. * test(iam): make orphaned STS/service-account tests pass under auth-enabled CI Follow-up to wiring STS tests into CI: fixes several pre-existing issues that made the newly-included tests fail locally. Server fixes: - weed/s3api/s3api_sts.go: handleGetFederationToken no longer 500s when the caller is a legacy S3-config identity (not in the IAM user store). Previously any GetPoliciesForUser error short-circuited to InternalError, which hard-failed every SigV4 caller using keys from -s3.config. - weed/s3api/s3api_embedded_iam.go: CreateServiceAccount now generates IDs in the sa:<parent>:<uuid> format required by credential.ValidateServiceAccountId. The old "sa-XXXXXXXX" format failed the persistence-layer regex and caused every CreateServiceAccount call to return 500 once a filer-backed credential store validated the ID. Test helpers: - test/s3/iam/s3_sts_assume_role_test.go: callSTSAPIWithSigV4 no longer sets req.Header["Host"]. aws-sdk-go v1 v4.Signer already signs Host from req.URL.Host, and a manual Host header made the signer emit host;host in SignedHeaders, producing SignatureDoesNotMatch. Updated missing_role_arn subtest to match the existing SeaweedFS behavior (user-context assumption). - test/s3/iam/s3_service_account_test.go: callIAMAPI now SigV4-signs requests when STS_TEST_{ACCESS,SECRET}_KEY env vars are set. Unsigned IAM writes otherwise fall through to the STS fallback and return InvalidAction. CI matrix: - .github/workflows/s3-iam-tests.yml: skip TestServiceAccountLifecycle/use_service_account_credentials only. The rest of the service-account suite passes; that one subtest depends on a separate credential-reload issue where new ABIA keys briefly register into accessKeyIdent but aren't persisted to the filer, so they vanish on the next reload. Out of scope for the #9157 GetFederationToken fix. * fix(credential): accept AWS IAM username chars in service-account IDs Gemini review on #9167 pointed out that ServiceAccountIdPattern's parent-user segment was more restrictive than an AWS IAM username: `[A-Za-z0-9_-]` vs. IAM's `[\w+=,.@-]`. Realistic usernames with `@`, `.`, `+`, `=`, or `,` (e.g. email-style principals) would fail validation at the filer store even though the embedded IAM API happily created them. Broaden the regex to `[A-Za-z0-9_+=,.@-]` (matching the AWS IAM spec at https://docs.aws.amazon.com/IAM/latest/APIReference/API_User.html) and add a table-driven test that locks the expansion in. * address PR review feedback on #9167 All five review items were valid; changes keyed to review bullets: - weed/s3api/s3api_sts.go: handleGetFederationToken no longer swallows arbitrary policy-lookup failures. Only credential.ErrUserNotFound is treated leniently (the legacy-config SigV4 path); any other error now returns InternalError so we don't mint tokens with an incomplete policy set. - weed/credential/grpc/grpc_identity.go: GetUser translates gRPC NotFound back to credential.ErrUserNotFound so errors.Is(...) above matches for gRPC-backed stores, not just memory/filer-direct. - weed/s3api/s3api_embedded_iam.go: CreateServiceAccount now validates the generated saId against credential.ValidateServiceAccountId before returning. Surfaces a client 400 with the offending ID instead of the opaque 500 that used to bubble up from the persistence layer. - weed/s3api/s3api_server_routing_test.go: seed a routing-test identity with a known AK/SK, sign TestRouting_GetFederationTokenAuthenticatedBody with aws-sdk-go v4.Signer so the request actually passes AuthSignatureOnly. Assert 503 ServiceUnavailable (from STSHandlers with no stsService) instead of just NotEqual(501) — 503 proves the dispatch reached STSHandlers.HandleSTSRequest. - test/s3/iam/s3_service_account_test.go: callIAMAPI signs with service="iam" instead of "s3" (SeaweedFS verifies against whichever service the client signed with, but "iam" is semantically correct). - weed/credential/validation_test.go: add positive rows for an uppercase parent (sa:ALICE:...) and a canonical hyphenated UUID suffix (sa:alice:123e4567-e89b-12d3-a456-426614174000).
366 lines
12 KiB
Go
366 lines
12 KiB
Go
package iam
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// AssumeRoleResponse represents the STS AssumeRole response
|
|
type AssumeRoleTestResponse struct {
|
|
XMLName xml.Name `xml:"AssumeRoleResponse"`
|
|
Result struct {
|
|
Credentials struct {
|
|
AccessKeyId string `xml:"AccessKeyId"`
|
|
SecretAccessKey string `xml:"SecretAccessKey"`
|
|
SessionToken string `xml:"SessionToken"`
|
|
Expiration string `xml:"Expiration"`
|
|
} `xml:"Credentials"`
|
|
AssumedRoleUser struct {
|
|
AssumedRoleId string `xml:"AssumedRoleId"`
|
|
Arn string `xml:"Arn"`
|
|
} `xml:"AssumedRoleUser"`
|
|
} `xml:"AssumeRoleResult"`
|
|
}
|
|
|
|
// TestSTSAssumeRoleValidation tests input validation for AssumeRole endpoint
|
|
func TestSTSAssumeRoleValidation(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")
|
|
}
|
|
|
|
// Check if AssumeRole is implemented by making a test call
|
|
if !isAssumeRoleImplemented(t) {
|
|
t.Fatal("AssumeRole action is not implemented in the running server - please rebuild weed binary with new code and restart the server")
|
|
}
|
|
|
|
t.Run("missing_role_arn_defaults_to_caller_identity", func(t *testing.T) {
|
|
// SeaweedFS intentionally allows AssumeRole without RoleArn to support
|
|
// S3-compatible clients that omit it. In that case the session is tied
|
|
// to the caller's own identity (User Context assumption). See
|
|
// handleAssumeRole in weed/s3api/s3api_sts.go.
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleSessionName": {"test-session"},
|
|
// RoleArn is missing on purpose
|
|
}, "test-access-key", "test-secret-key")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
|
"Should succeed without RoleArn (user-context assumption): %s", string(body))
|
|
|
|
var assumeResp AssumeRoleTestResponse
|
|
require.NoError(t, xml.Unmarshal(body, &assumeResp),
|
|
"Failed to parse response: %s", string(body))
|
|
assert.NotEmpty(t, assumeResp.Result.Credentials.AccessKeyId,
|
|
"AccessKeyId should be issued for user-context assumption")
|
|
})
|
|
|
|
t.Run("missing_role_session_name", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
// RoleSessionName is missing
|
|
}, "test-access-key", "test-secret-key")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail without RoleSessionName")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
|
})
|
|
|
|
t.Run("unsupported_action_for_anonymous", func(t *testing.T) {
|
|
// AssumeRole requires SigV4 authentication, anonymous requests should fail
|
|
resp, err := callSTSAPI(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
"RoleSessionName": {"test-session"},
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should fail because AssumeRole requires AWS SigV4 authentication
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"AssumeRole should require authentication")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
t.Logf("Response for anonymous AssumeRole: status=%d, body=%s", resp.StatusCode, string(body))
|
|
})
|
|
|
|
t.Run("invalid_duration_too_short", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
"RoleSessionName": {"test-session"},
|
|
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
|
}, "test-access-key", "test-secret-key")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail with DurationSeconds < 900")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
|
})
|
|
|
|
t.Run("invalid_duration_too_long", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/test-role"},
|
|
"RoleSessionName": {"test-session"},
|
|
"DurationSeconds": {"100000"}, // More than 43200 seconds maximum
|
|
}, "test-access-key", "test-secret-key")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail with DurationSeconds > 43200")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
|
})
|
|
}
|
|
|
|
// isAssumeRoleImplemented checks if the running server supports AssumeRole
|
|
func isAssumeRoleImplemented(t *testing.T) bool {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/test"},
|
|
"RoleSessionName": {"test"},
|
|
}, "test", "test")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// If we get "NotImplemented", the action isn't supported
|
|
var errResp STSErrorTestResponse
|
|
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" {
|
|
return false
|
|
}
|
|
|
|
// If we get InvalidAction, the action isn't routed
|
|
if errResp.Error.Code == "InvalidAction" {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// TestSTSAssumeRoleWithValidCredentials tests AssumeRole with valid IAM credentials
|
|
// This test requires a configured IAM user in SeaweedFS
|
|
func TestSTSAssumeRoleWithValidCredentials(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)
|
|
}
|
|
|
|
// Use test credentials from environment or fall back to defaults
|
|
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
|
|
if accessKey == "" {
|
|
accessKey = "admin"
|
|
}
|
|
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
|
if secretKey == "" {
|
|
secretKey = "admin"
|
|
}
|
|
|
|
t.Run("successful_assume_role", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/admin"},
|
|
"RoleSessionName": {"integration-test-session"},
|
|
}, 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 AssumeRole is not yet implemented, expect an error about unsupported action
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
t.Logf("Error response: code=%s, message=%s", errResp.Error.Code, errResp.Error.Message)
|
|
|
|
// This test will initially fail until AssumeRole is implemented
|
|
// Once implemented, uncomment the assertions below
|
|
// assert.Fail(t, "AssumeRole not yet implemented")
|
|
} else {
|
|
var stsResp AssumeRoleTestResponse
|
|
err = xml.Unmarshal(body, &stsResp)
|
|
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
|
|
|
creds := stsResp.Result.Credentials
|
|
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
|
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
|
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
|
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
|
|
|
t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId)
|
|
}
|
|
})
|
|
|
|
t.Run("with_custom_duration", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/admin"},
|
|
"RoleSessionName": {"duration-test-session"},
|
|
"DurationSeconds": {"3600"}, // 1 hour
|
|
}, 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))
|
|
|
|
// Verify DurationSeconds is accepted
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp STSErrorTestResponse
|
|
err = xml.Unmarshal(body, &errResp)
|
|
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
|
// Should not fail due to DurationSeconds parameter
|
|
assert.NotContains(t, errResp.Error.Message, "DurationSeconds",
|
|
"DurationSeconds parameter should be accepted")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSTSAssumeRoleWithInvalidCredentials tests AssumeRole rejection with bad credentials
|
|
func TestSTSAssumeRoleWithInvalidCredentials(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)
|
|
}
|
|
|
|
t.Run("invalid_access_key", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/admin"},
|
|
"RoleSessionName": {"test-session"},
|
|
}, "invalid-access-key", "some-secret-key")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should fail with access denied or signature mismatch
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail with invalid access key")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
t.Logf("Response for invalid credentials: status=%d, body=%s", resp.StatusCode, string(body))
|
|
})
|
|
|
|
t.Run("invalid_secret_key", func(t *testing.T) {
|
|
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
|
"Action": {"AssumeRole"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::role/admin"},
|
|
"RoleSessionName": {"test-session"},
|
|
}, "admin", "wrong-secret-key")
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should fail with signature mismatch
|
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
|
"Should fail with invalid secret key")
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
t.Logf("Response for wrong secret: status=%d, body=%s", resp.StatusCode, string(body))
|
|
})
|
|
}
|
|
|
|
// callSTSAPIWithSigV4 makes an STS API call with AWS Signature V4 authentication
|
|
func callSTSAPIWithSigV4(t *testing.T, params url.Values, accessKey, secretKey string) (*http.Response, error) {
|
|
// Prepare request body
|
|
body := params.Encode()
|
|
|
|
// Create request
|
|
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
|
strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
// Do NOT set req.Header["Host"] — aws-sdk-go v1 v4.Signer reads the host
|
|
// from req.URL.Host/req.Host, and a manual Host header makes the signer
|
|
// emit `host;host` in SignedHeaders, producing a different signature than
|
|
// the server calculates from canonical headers.
|
|
|
|
// Sign request with AWS Signature V4 using official SDK
|
|
creds := credentials.NewStaticCredentials(accessKey, secretKey, "")
|
|
signer := v4.NewSigner(creds)
|
|
|
|
// Read body for signing
|
|
// Note: We need a ReadSeeker for the signer, or we can pass the body string/bytes to ComputeBodyHash if needed,
|
|
// but standard Sign method takes an io.ReadSeeker for the body.
|
|
bodyReader := strings.NewReader(body)
|
|
_, err = signer.Sign(req, bodyReader, "sts", "us-east-1", time.Now())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to sign request: %w", err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
return client.Do(req)
|
|
}
|