mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-05-14 05:41:29 +00: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).
299 lines
11 KiB
Go
299 lines
11 KiB
Go
package s3api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/credential"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// routingTestAccessKey/routingTestSecretKey are the credentials seeded into
|
|
// the IAM for tests that need to exercise code paths behind SigV4
|
|
// verification (e.g., UnifiedPostHandler's STS dispatch).
|
|
const (
|
|
routingTestAccessKey = "routing-test-ak"
|
|
routingTestSecretKey = "routing-test-sk"
|
|
routingTestUser = "routing-test-user"
|
|
)
|
|
|
|
// setupRoutingTestServer creates a minimal S3ApiServer for routing tests
|
|
func setupRoutingTestServer(t *testing.T) *S3ApiServer {
|
|
opt := &S3ApiServerOption{EnableIam: true}
|
|
iam := NewIdentityAccessManagementWithStore(opt, nil, "memory")
|
|
iam.isAuthEnabled = true
|
|
|
|
if iam.credentialManager == nil {
|
|
cm, err := credential.NewCredentialManager("memory", util.GetViper(), "")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create credential manager: %v", err)
|
|
}
|
|
iam.credentialManager = cm
|
|
}
|
|
|
|
// Seed a test identity with known credentials so SigV4-signed requests
|
|
// can pass AuthSignatureOnly and reach downstream handlers.
|
|
testIdent := &Identity{
|
|
Name: routingTestUser,
|
|
Actions: []Action{s3_constants.ACTION_ADMIN},
|
|
IsStatic: true,
|
|
Credentials: []*Credential{{
|
|
AccessKey: routingTestAccessKey,
|
|
SecretKey: routingTestSecretKey,
|
|
}},
|
|
}
|
|
iam.m.Lock()
|
|
if iam.accessKeyIdent == nil {
|
|
iam.accessKeyIdent = make(map[string]*Identity)
|
|
}
|
|
if iam.nameToIdentity == nil {
|
|
iam.nameToIdentity = make(map[string]*Identity)
|
|
}
|
|
iam.identities = append(iam.identities, testIdent)
|
|
iam.accessKeyIdent[routingTestAccessKey] = testIdent
|
|
iam.nameToIdentity[routingTestUser] = testIdent
|
|
iam.m.Unlock()
|
|
|
|
server := &S3ApiServer{
|
|
option: opt,
|
|
iam: iam,
|
|
credentialManager: iam.credentialManager,
|
|
embeddedIam: NewEmbeddedIamApi(iam.credentialManager, iam, false),
|
|
stsHandlers: &STSHandlers{},
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
// signRoutingTestRequest signs req with the seeded routing-test credentials
|
|
// for the given AWS service. Fails the test on signing errors.
|
|
func signRoutingTestRequest(t *testing.T, req *http.Request, body, service string) {
|
|
t.Helper()
|
|
creds := credentials.NewStaticCredentials(routingTestAccessKey, routingTestSecretKey, "")
|
|
signer := v4.NewSigner(creds)
|
|
if _, err := signer.Sign(req, strings.NewReader(body), service, "us-east-1", time.Now()); err != nil {
|
|
t.Fatalf("sign request: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRouting_STSWithQueryParams verifies that AssumeRoleWithWebIdentity with query params routes to STS
|
|
func TestRouting_STSWithQueryParams(t *testing.T) {
|
|
router := mux.NewRouter()
|
|
s3a := setupRoutingTestServer(t)
|
|
s3a.registerRouter(router)
|
|
|
|
// Create request with Action in query params (no auth header)
|
|
req, _ := http.NewRequest("POST", "/?Action=AssumeRoleWithWebIdentity&WebIdentityToken=test-token&RoleArn=arn:aws:iam::123:role/test&RoleSessionName=test-session", nil)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// Should route to STS handler -> 503 (service not initialized) or 400 (validation error)
|
|
assert.Contains(t, []int{http.StatusBadRequest, http.StatusServiceUnavailable}, rr.Code, "Should route to STS handler")
|
|
}
|
|
|
|
// TestRouting_STSWithBodyParams verifies that AssumeRoleWithWebIdentity with body params routes to STS fallback
|
|
func TestRouting_STSWithBodyParams(t *testing.T) {
|
|
router := mux.NewRouter()
|
|
s3a := setupRoutingTestServer(t)
|
|
s3a.registerRouter(router)
|
|
|
|
// Create request with Action in POST body (no auth header)
|
|
data := url.Values{}
|
|
data.Set("Action", "AssumeRoleWithWebIdentity")
|
|
data.Set("WebIdentityToken", "test-token")
|
|
data.Set("RoleArn", "arn:aws:iam::123:role/test")
|
|
data.Set("RoleSessionName", "test-session")
|
|
|
|
req, _ := http.NewRequest("POST", "/", strings.NewReader(data.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// Should route to STS fallback handler -> 503 (service not initialized in test)
|
|
assert.Equal(t, http.StatusServiceUnavailable, rr.Code, "Should route to STS fallback handler (503 because STS not initialized)")
|
|
}
|
|
|
|
// TestRouting_GetFederationTokenWithQueryParams verifies that GetFederationToken with
|
|
// Action in the query string routes to the STS handler (not IAM / not S3).
|
|
// Regression test for https://github.com/seaweedfs/seaweedfs/issues/9157
|
|
func TestRouting_GetFederationTokenWithQueryParams(t *testing.T) {
|
|
router := mux.NewRouter()
|
|
s3a := setupRoutingTestServer(t)
|
|
s3a.registerRouter(router)
|
|
|
|
req, _ := http.NewRequest("POST", "/?Action=GetFederationToken&Name=admin", nil)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// Must not be 501 NotImplemented (previous buggy behavior).
|
|
// Expected: routes to STS -> 503 (service not initialized in test) or 400 (validation).
|
|
assert.NotEqual(t, http.StatusNotImplemented, rr.Code, "Should route to STS, not fall through to S3 NotImplemented")
|
|
assert.Contains(t, []int{http.StatusBadRequest, http.StatusServiceUnavailable, http.StatusForbidden}, rr.Code, "Should route to STS handler")
|
|
}
|
|
|
|
// TestRouting_GetFederationTokenAuthenticatedBody verifies that an authenticated
|
|
// POST with Action=GetFederationToken in the form body is dispatched by
|
|
// UnifiedPostHandler to the STS handler instead of being treated as an IAM action.
|
|
// Regression test for https://github.com/seaweedfs/seaweedfs/issues/9157
|
|
//
|
|
// The request is signed with seeded test credentials so it passes
|
|
// AuthSignatureOnly in UnifiedPostHandler and actually reaches STSHandlers.
|
|
// STSHandlers is a zero value in the test server (no stsService set), so a
|
|
// correctly routed request must return 503 ServiceUnavailable from
|
|
// writeSTSErrorResponse(STSErrSTSNotReady). Any other status means we didn't
|
|
// reach STSHandlers.HandleSTSRequest.
|
|
func TestRouting_GetFederationTokenAuthenticatedBody(t *testing.T) {
|
|
router := mux.NewRouter()
|
|
s3a := setupRoutingTestServer(t)
|
|
s3a.registerRouter(router)
|
|
|
|
data := url.Values{}
|
|
data.Set("Action", "GetFederationToken")
|
|
data.Set("Name", "admin")
|
|
data.Set("Version", "2011-06-15")
|
|
body := data.Encode()
|
|
|
|
req, _ := http.NewRequest("POST", "http://localhost/", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
signRoutingTestRequest(t, req, body, "sts")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// Reaching STSHandlers with an uninitialized stsService yields 503.
|
|
// 501 would mean we fell through to the S3 NotImplemented handler.
|
|
// 403 would mean AuthSignatureOnly rejected us (test seed broken).
|
|
assert.Equal(t, http.StatusServiceUnavailable, rr.Code,
|
|
"should reach STS handler; got %d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// TestRouting_AuthenticatedIAM verifies that authenticated IAM requests route to IAM handler
|
|
func TestRouting_AuthenticatedIAM(t *testing.T) {
|
|
router := mux.NewRouter()
|
|
s3a := setupRoutingTestServer(t)
|
|
s3a.registerRouter(router)
|
|
|
|
// Create IAM request with Authorization header
|
|
data := url.Values{}
|
|
data.Set("Action", "CreateUser")
|
|
data.Set("UserName", "testuser")
|
|
|
|
req, _ := http.NewRequest("POST", "/", strings.NewReader(data.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIA.../...")
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
// Should route to IAM handler -> 400/403 (invalid signature)
|
|
// NOT 503 (which would indicate STS handler)
|
|
assert.NotEqual(t, http.StatusServiceUnavailable, rr.Code, "Should NOT route to STS handler")
|
|
assert.Contains(t, []int{http.StatusBadRequest, http.StatusForbidden}, rr.Code, "Should route to IAM handler (400/403 due to invalid signature)")
|
|
}
|
|
|
|
// TestRouting_IAMMatcherLogic verifies the iamMatcher correctly distinguishes auth types
|
|
func TestRouting_IAMMatcherLogic(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
authHeader string
|
|
queryParams string
|
|
expectsIAM bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "No auth - anonymous",
|
|
authHeader: "",
|
|
queryParams: "",
|
|
expectsIAM: false,
|
|
description: "Request with no auth should NOT match IAM",
|
|
},
|
|
{
|
|
name: "AWS4 signature",
|
|
authHeader: "AWS4-HMAC-SHA256 Credential=AKIA.../...",
|
|
queryParams: "",
|
|
expectsIAM: true,
|
|
description: "Request with AWS4 signature should match IAM",
|
|
},
|
|
{
|
|
name: "AWS2 signature",
|
|
authHeader: "AWS AKIA...:signature",
|
|
queryParams: "",
|
|
expectsIAM: true,
|
|
description: "Request with AWS2 signature should match IAM",
|
|
},
|
|
{
|
|
name: "Presigned V4",
|
|
authHeader: "",
|
|
queryParams: "?X-Amz-Credential=AKIA...",
|
|
expectsIAM: true,
|
|
description: "Request with presigned V4 params should match IAM",
|
|
},
|
|
{
|
|
name: "Presigned V2",
|
|
authHeader: "",
|
|
queryParams: "?AWSAccessKeyId=AKIA...",
|
|
expectsIAM: true,
|
|
description: "Request with presigned V2 params should match IAM",
|
|
},
|
|
{
|
|
name: "AWS4 signature with STS action in body",
|
|
authHeader: "AWS4-HMAC-SHA256 Credential=AKIA.../...",
|
|
queryParams: "",
|
|
expectsIAM: false,
|
|
description: "Authenticated STS action should route to STS handler (STS handlers handle their own auth)",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
router := mux.NewRouter()
|
|
s3a := setupRoutingTestServer(t)
|
|
s3a.registerRouter(router)
|
|
|
|
data := url.Values{}
|
|
// For the authenticated STS action test, set the STS action
|
|
// For other tests, don't set Action to avoid STS validation errors
|
|
if tt.name == "AWS4 signature with STS action in body" {
|
|
data.Set("Action", "AssumeRoleWithWebIdentity")
|
|
data.Set("WebIdentityToken", "test-token")
|
|
data.Set("RoleArn", "arn:aws:iam::123:role/test")
|
|
data.Set("RoleSessionName", "test-session")
|
|
}
|
|
|
|
req, _ := http.NewRequest("POST", "/"+tt.queryParams, strings.NewReader(data.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if tt.authHeader != "" {
|
|
req.Header.Set("Authorization", tt.authHeader)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if tt.expectsIAM {
|
|
// Should route to IAM (400/403 for invalid sig)
|
|
// NOT 400 from STS (which would be missing Action parameter)
|
|
// We distinguish by checking it's NOT a generic 400 with empty body
|
|
assert.NotEqual(t, http.StatusServiceUnavailable, rr.Code, tt.description)
|
|
} else {
|
|
// Should route to STS fallback
|
|
// Can be 503 (service not initialized) or 400 (missing/invalid Action parameter)
|
|
assert.Contains(t, []int{http.StatusBadRequest, http.StatusServiceUnavailable}, rr.Code, tt.description)
|
|
}
|
|
})
|
|
}
|
|
}
|