Files
seaweedfs/weed/s3api/s3api_server_routing_test.go
Chris Lu e77f8ae204 fix(s3api): route STS GetFederationToken to STS handler (#9157) (#9167)
* 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).
2026-04-20 19:33:22 -07:00

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)
}
})
}
}