mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 12:16:36 +00:00
* s3: test the GetCallerIdentity handler The handler had no test, only XML marshalling, so nothing pinned that a caller presenting session credentials is reported as the assumed role rather than the user who minted the session. * s3: drive AssumeRoleWithWebIdentity over HTTP with a real OIDC token Coverage reached the OIDC path either at the IAMManager service layer or through the Authorization: Bearer shortcut. Nothing exercised the public STS entry point an AWS SDK actually calls, which is where parameter parsing, the IAMManager dispatch and the XML response shape live. * s3: test that every STS route emits an audit entry STS responses go out through WriteXMLResponse, which never calls PostLog, so track() is the only thing that logs them. A route registered outside it would mint credentials with no audit trail and nothing would notice. STS has three routing layers, so a new action is easy to attach to the wrong one. * s3: make the STS tests assert what they claim to cover The audit routing test ran against an uninitialized STS service, so every case answered 503 and a non-404 status was the only evidence the request had reached STS at all - the POST-body case could have been served by the dispatcher's IAM branch and still passed. Back it with a real STS service and assert the STS response namespace, which IAM and S3 responses do not carry. The session policy case checked that the policy travelled in the token rather than that it restricted anything; assert the narrowed bucket is allowed and another bucket is refused. Give the forged-token case the same claim set a valid token gets, so it cannot pass for want of a claim, and cover both rejection paths: a key we do not publish, and a key id absent from the JWKS.
283 lines
11 KiB
Go
283 lines
11 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// AssumeRoleWithWebIdentity is the public, unauthenticated STS entry point, but
|
|
// every existing test reaches the OIDC path either at the IAMManager service
|
|
// layer or through the Authorization: Bearer shortcut. Nothing drove a real
|
|
// signed OIDC token through HandleSTSRequest, which is what an AWS SDK actually
|
|
// does - and that is the path carrying parameter parsing, the IAMManager
|
|
// dispatch, and the XML response shape.
|
|
func TestAssumeRoleWithWebIdentityOverHTTP(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
jwks := map[string]interface{}{
|
|
"keys": []map[string]interface{}{{
|
|
"kty": "RSA",
|
|
"kid": "web-identity-test-key",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
|
|
"e": "AQAB",
|
|
}},
|
|
}
|
|
idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/jwks" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(jwks)
|
|
}))
|
|
defer idp.Close()
|
|
|
|
provider := oidc.NewOIDCProvider("test-oidc")
|
|
require.NoError(t, provider.Initialize(&oidc.OIDCConfig{
|
|
Issuer: idp.URL,
|
|
ClientID: "test-client",
|
|
JWKSUri: idp.URL + "/jwks",
|
|
}))
|
|
|
|
manager := newTestSTSIntegrationManager(t)
|
|
require.NoError(t, manager.RegisterIdentityProvider(provider))
|
|
|
|
require.NoError(t, manager.CreatePolicy(ctx, "", "WebIdentityPolicy", &policy.PolicyDocument{
|
|
Version: "2012-10-17",
|
|
Statement: []policy.Statement{{
|
|
Effect: "Allow",
|
|
Action: []string{"s3:GetObject"},
|
|
Resource: []string{"arn:aws:s3:::*/*"},
|
|
}},
|
|
}))
|
|
|
|
trustPolicy := func(federatedProvider string) *policy.PolicyDocument {
|
|
return &policy.PolicyDocument{
|
|
Version: "2012-10-17",
|
|
Statement: []policy.Statement{{
|
|
Effect: "Allow",
|
|
Principal: map[string]interface{}{"Federated": federatedProvider},
|
|
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
|
}},
|
|
}
|
|
}
|
|
require.NoError(t, manager.CreateRole(ctx, "", "WebIdentityRole", &integration.RoleDefinition{
|
|
RoleName: "WebIdentityRole",
|
|
TrustPolicy: trustPolicy("test-oidc"),
|
|
AttachedPolicies: []string{"WebIdentityPolicy"},
|
|
}))
|
|
require.NoError(t, manager.CreateRole(ctx, "", "WebIdentityDeniedRole", &integration.RoleDefinition{
|
|
RoleName: "WebIdentityDeniedRole",
|
|
TrustPolicy: trustPolicy("some-other-provider"),
|
|
AttachedPolicies: []string{"WebIdentityPolicy"},
|
|
}))
|
|
|
|
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
|
|
handlers := NewSTSHandlers(manager.GetSTSService(), iam)
|
|
|
|
signOIDCToken := func(t *testing.T, subject string) string {
|
|
t.Helper()
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"iss": idp.URL,
|
|
"sub": subject,
|
|
"aud": "test-client",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
token.Header["kid"] = "web-identity-test-key"
|
|
signed, err := token.SignedString(key)
|
|
require.NoError(t, err)
|
|
return signed
|
|
}
|
|
|
|
const trustedRoleArn = "arn:aws:iam::" + defaultAccountID + ":role/WebIdentityRole"
|
|
|
|
// The SDK may put the parameters in the query string or the form body; both
|
|
// reach HandleSTSRequest and must behave identically.
|
|
for _, encoding := range []string{"query", "body"} {
|
|
t.Run("succeeds with parameters in the "+encoding, func(t *testing.T) {
|
|
form := url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {trustedRoleArn},
|
|
"RoleSessionName": {"web-session"},
|
|
"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
|
|
}
|
|
|
|
var req *http.Request
|
|
if encoding == "query" {
|
|
req = httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/?"+form.Encode(), nil)
|
|
} else {
|
|
req = httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleSTSRequest(rec, req)
|
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
|
|
|
var resp AssumeRoleWithWebIdentityResponse
|
|
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
|
|
assert.Equal(t, "oidc-user-1", resp.Result.SubjectFromWebIdentityToken)
|
|
assert.True(t, strings.HasPrefix(resp.Result.Credentials.AccessKeyId, "ASIA"),
|
|
"temporary credentials must use the ASIA prefix")
|
|
require.NotEmpty(t, resp.Result.Credentials.SessionToken)
|
|
assert.Nil(t, resp.Result.PackedPolicySize, "no inline session policy was sent")
|
|
|
|
// The minted session must resolve to the assumed-role principal.
|
|
// Unlike AssumeRole, this path does not embed the role's attached
|
|
// policies in the token - they are resolved from the role at request
|
|
// time - so assert the effective permission rather than the claim.
|
|
info, err := manager.GetSTSService().ValidateSessionToken(ctx, resp.Result.Credentials.SessionToken)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, info.Principal, "assumed-role/WebIdentityRole/web-session")
|
|
|
|
allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
|
|
Principal: info.Principal,
|
|
Action: "s3:GetObject",
|
|
Resource: "arn:aws:s3:::bucket/key",
|
|
SessionToken: resp.Result.Credentials.SessionToken,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, allowed, "the role grants s3:GetObject")
|
|
|
|
denied, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
|
|
Principal: info.Principal,
|
|
Action: "s3:PutObject",
|
|
Resource: "arn:aws:s3:::bucket/key",
|
|
SessionToken: resp.Result.Credentials.SessionToken,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, denied, "the role does not grant s3:PutObject")
|
|
})
|
|
}
|
|
|
|
t.Run("inline session policy is reported in PackedPolicySize", func(t *testing.T) {
|
|
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
|
|
form := url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {trustedRoleArn},
|
|
"RoleSessionName": {"web-session-scoped"},
|
|
"WebIdentityToken": {signOIDCToken(t, "oidc-user-2")},
|
|
"Policy": {sessionPolicy},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleSTSRequest(rec, req)
|
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
|
|
|
var resp AssumeRoleWithWebIdentityResponse
|
|
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &resp))
|
|
require.NotNil(t, resp.Result.PackedPolicySize)
|
|
assert.Greater(t, *resp.Result.PackedPolicySize, int64(0))
|
|
|
|
info, err := manager.GetSTSService().ValidateSessionToken(ctx, resp.Result.Credentials.SessionToken)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, info.SessionPolicy, "the inline policy must travel in the session token")
|
|
|
|
// Carrying the policy is not the point - restricting the session is. The
|
|
// role allows s3:GetObject on any bucket; the session policy narrows that
|
|
// to one bucket, so the same action on another bucket must be refused.
|
|
allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
|
|
Principal: info.Principal,
|
|
Action: "s3:GetObject",
|
|
Resource: "arn:aws:s3:::bucket/key",
|
|
SessionToken: resp.Result.Credentials.SessionToken,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, allowed, "the session policy allows this bucket")
|
|
|
|
denied, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
|
|
Principal: info.Principal,
|
|
Action: "s3:GetObject",
|
|
Resource: "arn:aws:s3:::other-bucket/key",
|
|
SessionToken: resp.Result.Credentials.SessionToken,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, denied, "the session policy must scope the session down to one bucket")
|
|
})
|
|
|
|
t.Run("role whose trust policy rejects the provider is denied", func(t *testing.T) {
|
|
form := url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/WebIdentityDeniedRole"},
|
|
"RoleSessionName": {"web-session"},
|
|
"WebIdentityToken": {signOIDCToken(t, "oidc-user-1")},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleSTSRequest(rec, req)
|
|
assert.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
|
|
})
|
|
|
|
// Both rejection paths matter and they are not the same code: one fails
|
|
// signature verification against a key we do publish, the other never finds
|
|
// a key to verify against. The claim set is otherwise identical to a token
|
|
// signOIDCToken would mint, so neither can pass or fail for want of a claim.
|
|
forgedTokenCases := []struct {
|
|
name string
|
|
kid string
|
|
}{
|
|
{name: "token signed by a key we do not publish is rejected", kid: "web-identity-test-key"},
|
|
{name: "token naming an unknown key id is rejected", kid: "not-in-the-jwks"},
|
|
}
|
|
for _, tc := range forgedTokenCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"iss": idp.URL,
|
|
"sub": "forged-user",
|
|
"aud": "test-client",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
token.Header["kid"] = tc.kid
|
|
forged, err := token.SignedString(otherKey)
|
|
require.NoError(t, err)
|
|
|
|
form := url.Values{
|
|
"Action": {"AssumeRoleWithWebIdentity"},
|
|
"Version": {"2011-06-15"},
|
|
"RoleArn": {trustedRoleArn},
|
|
"RoleSessionName": {"web-session"},
|
|
"WebIdentityToken": {forged},
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "http://sts.seaweedfs.test/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleSTSRequest(rec, req)
|
|
assert.NotEqual(t, http.StatusOK, rec.Code, "a forged token must not mint a session")
|
|
})
|
|
}
|
|
}
|