diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go index 8a16d55273..e7f4ce89c5 100644 --- a/weed/iam/sts/session_claims.go +++ b/weed/iam/sts/session_claims.go @@ -100,6 +100,11 @@ type STSSessionClaims struct { // guaranteed stable per OpenID Connect Core 1.0. Empty for non-federated // session types. ParentUser string `json:"puid,omitempty"` + + // SourceAccessKeyId is the access key the caller authenticated with when + // a SeaweedFS service minted the session for it (AssumeRoleForPrincipal); + // empty for every other session type. + SourceAccessKeyId string `json:"src_ak,omitempty"` } // NewSTSSessionClaims creates new STS session claims with all required information @@ -156,6 +161,8 @@ func (c *STSSessionClaims) ToSessionInfo(credGen *CredentialGenerator) *SessionI ProviderIssuer: c.ProviderIssuer, RequestContext: c.RequestContext, ParentUser: c.ParentUser, + // The access key the session was minted for, if recorded + SourceAccessKeyId: c.SourceAccessKeyId, // Provide the Subject (sub) from registered claims Subject: c.Subject, Credentials: credentials, @@ -250,6 +257,12 @@ func (c *STSSessionClaims) WithSessionName(sessionName string) *STSSessionClaims return c } +// WithSourceAccessKeyId records the access key the session was minted for. +func (c *STSSessionClaims) WithSourceAccessKeyId(accessKeyId string) *STSSessionClaims { + c.SourceAccessKeyId = accessKeyId + return c +} + // WithParentUser sets the stable per-identity hash for the session. See // ComputeParentUser for the derivation rule. func (c *STSSessionClaims) WithParentUser(parentUser string) *STSSessionClaims { diff --git a/weed/iam/sts/source_access_key_test.go b/weed/iam/sts/source_access_key_test.go new file mode 100644 index 0000000000..137c045376 --- /dev/null +++ b/weed/iam/sts/source_access_key_test.go @@ -0,0 +1,42 @@ +package sts + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newSourceKeyTestSTS(t *testing.T) *STSService { + t.Helper() + s := NewSTSService() + require.NoError(t, s.Initialize(&STSConfig{ + TokenDuration: FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: FlexibleDuration{Duration: 12 * time.Hour}, + Issuer: "test-issuer", + SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"), + })) + return s +} + +// A session minted for a caller's access key carries that key back out of its +// token; a session minted without one carries none. +func TestSessionCarriesItsSourceAccessKey(t *testing.T) { + s := newSourceKeyTestSTS(t) + resp, err := s.issueSession("arn:aws:iam::role/IcebergTableAccess", "", "iceberg-alice-ns-t-kabc", "", nil, + "iceberg-catalog", "alice", func(c *STSSessionClaims) { c.WithSourceAccessKeyId("AKIASOURCE") }) + require.NoError(t, err) + info, err := s.ValidateSessionToken(context.Background(), resp.Credentials.SessionToken) + require.NoError(t, err) + assert.Equal(t, "AKIASOURCE", info.SourceAccessKeyId) + assert.Equal(t, "alice", info.ExternalUserId) + assert.Equal(t, "iceberg-catalog", info.IdentityProvider) + + resp, err = s.issueSession("arn:aws:iam::role/IcebergTableAccess", "", "iceberg-alice-ns-t", "", nil, "iceberg-catalog", "alice") + require.NoError(t, err) + info, err = s.ValidateSessionToken(context.Background(), resp.Credentials.SessionToken) + require.NoError(t, err) + assert.Equal(t, "", info.SourceAccessKeyId) +} diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index 89d09919ad..e2fdcd6f17 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -316,6 +316,10 @@ type SessionInfo struct { // RoleId is the unique ID of the role the session was issued for; empty // for sessions issued before role IDs were recorded. RoleId string `json:"roleId,omitempty"` + + // SourceAccessKeyId is the access key the session was minted for, when the + // minting service recorded it (AssumeRoleForPrincipal). + SourceAccessKeyId string `json:"sourceAccessKeyId,omitempty"` } // NewSTSService creates a new STS service @@ -780,7 +784,7 @@ func (s *STSService) AssumeRoleWithCredentials(ctx context.Context, request *Ass // issueSession mints temporary credentials and the self-contained JWT that // carries the whole session, shared by every assume-role entry point. func (s *STSService) issueSession(roleArn, roleId, roleSessionName, sessionPolicy string, - durationSeconds *int64, providerName, subject string) (*AssumeRoleResponse, error) { + durationSeconds *int64, providerName, subject string, options ...func(*STSSessionClaims)) (*AssumeRoleResponse, error) { sessionDuration := s.CalculateSessionDuration(durationSeconds) expiresAt := time.Now().Add(sessionDuration) @@ -810,6 +814,9 @@ func (s *STSService) issueSession(roleArn, roleId, roleSessionName, sessionPolic if sessionPolicy != "" { sessionClaims.WithSessionPolicy(sessionPolicy) } + for _, option := range options { + option(sessionClaims) + } jwtToken, err := s.tokenGenerator.GenerateJWTWithClaims(sessionClaims) if err != nil { @@ -863,6 +870,10 @@ type AssumeRoleForPrincipalRequest struct { // DurationSeconds requests a session lifetime. DurationSeconds *int64 + + // SourceAccessKeyId optionally records the access key the caller + // authenticated with, so the session can be tied to that key. + SourceAccessKeyId string } // AssumeRoleForPrincipal issues session credentials for a caller that a @@ -908,7 +919,8 @@ func (s *STSService) AssumeRoleForPrincipal(ctx context.Context, request *Assume // Iceberg credential vending resolves no role definition, so its sessions // carry no role ID and stay bound by name. return s.issueSession(request.RoleArn, "", request.RoleSessionName, sessionPolicy, - request.DurationSeconds, request.ProviderName, request.Principal) + request.DurationSeconds, request.ProviderName, request.Principal, + func(c *STSSessionClaims) { c.WithSourceAccessKeyId(request.SourceAccessKeyId) }) } // ValidateSessionToken validates a session token and returns session information diff --git a/weed/s3api/auth_access_key_attribution_test.go b/weed/s3api/auth_access_key_attribution_test.go new file mode 100644 index 0000000000..729e50e98a --- /dev/null +++ b/weed/s3api/auth_access_key_attribution_test.go @@ -0,0 +1,75 @@ +package s3api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +const ( + sharedKeyA = "AKIASHAREDKEYAAAAAAA" + sharedSecretA = "secret-a-wJalrXUtnFEMI/K7MDENG/bPxRfiCY" + sharedKeyB = "AKIASHAREDKEYBBBBBBB" + sharedSecretB = "secret-b-wJalrXUtnFEMI/K7MDENG/bPxRfiCY" +) + +// newSharedIdentityIAM returns one identity holding two key pairs, as when +// several people or services are given their own key on one identity. +func newSharedIdentityIAM() *IdentityAccessManagement { + iam := &IdentityAccessManagement{} + shared := &Identity{ + Name: "shared-user", + Credentials: []*Credential{ + {AccessKey: sharedKeyA, SecretKey: sharedSecretA}, + {AccessKey: sharedKeyB, SecretKey: sharedSecretB}, + }, + Actions: []Action{s3_constants.ACTION_READ}, + } + iam.identities = []*Identity{shared} + iam.accessKeyIdent = map[string]*Identity{sharedKeyA: shared, sharedKeyB: shared} + iam.nameToIdentity = map[string]*Identity{shared.Name: shared} + return iam +} + +// Two keys of one identity: the audit entry names the same requester for both +// and tells them apart by the key each request was signed with. +func TestAuditLogCarriesTheSigningAccessKey(t *testing.T) { + iam := newSharedIdentityIAM() + for _, key := range []struct{ id, secret string }{{sharedKeyA, sharedSecretA}, {sharedKeyB, sharedSecretB}} { + req, err := newTestRequest(http.MethodGet, "https://example.com/bucket/ns/t/data/f.parquet", 0, nil) + require.NoError(t, err) + require.NoError(t, signRequestV4(req, key.id, key.secret)) + // track() installs the holder before authentication runs. + outer := s3_constants.EnsureIdentityHolder(req) + + identity, errCode := iam.reqSignatureV4Verify(outer) + require.Equal(t, s3err.ErrNone, errCode) + iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {}) + + log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone) + assert.Equal(t, "shared-user", log.Requester) + assert.Equal(t, key.id, log.AccessKeyId, "audit entry must name the key the request was signed with") + } +} + +// A request refused after authentication still names its key, like its requester. +func TestAuditLogCarriesTheAccessKeyOnTheDenyPath(t *testing.T) { + iam := newSharedIdentityIAM() + req, err := newTestRequest(http.MethodGet, "https://example.com/bucket/f.parquet", 0, nil) + require.NoError(t, err) + require.NoError(t, signRequestV4(req, sharedKeyB, sharedSecretB)) + outer := s3_constants.EnsureIdentityHolder(req) + identity, errCode := iam.reqSignatureV4Verify(outer) + require.Equal(t, s3err.ErrNone, errCode) + + iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrAccessDenied, func(http.ResponseWriter, *http.Request) { + t.Fatal("handler must not run on the deny path") + }) + assert.Equal(t, sharedKeyB, s3err.GetAccessLog(outer, http.StatusForbidden, s3err.ErrAccessDenied).AccessKeyId) +} diff --git a/weed/s3api/auth_access_key_post_policy_test.go b/weed/s3api/auth_access_key_post_policy_test.go new file mode 100644 index 0000000000..50e36bb023 --- /dev/null +++ b/weed/s3api/auth_access_key_post_policy_test.go @@ -0,0 +1,86 @@ +package s3api + +import ( + "bytes" + "encoding/base64" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// signedPostFormV2 builds a multipart POST Object request whose policy is +// signed with SigV2 under postAuthzAccessKey and secret. +func signedPostFormV2(t *testing.T, key, secret string) *http.Request { + t.Helper() + policyJSON := fmt.Sprintf(`{"expiration":"%s","conditions":[["eq","$bucket","%s"],["eq","$key","%s"]]}`, + time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05.000Z"), postAuthzBucket, key) + encodedPolicy := base64.StdEncoding.EncodeToString([]byte(policyJSON)) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + for _, field := range [][2]string{{"bucket", postAuthzBucket}, {"key", key}, {"AWSAccessKeyId", postAuthzAccessKey}, + {"policy", encodedPolicy}, {"Signature", calculateSignatureV2(encodedPolicy, secret)}} { + require.NoError(t, writer.WriteField(field[0], field[1])) + } + filePart, err := writer.CreateFormFile("file", "payload.txt") + require.NoError(t, err) + _, err = filePart.Write([]byte("payload")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/"+postAuthzBucket, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return mux.SetURLVars(req, map[string]string{"bucket": postAuthzBucket}) +} + +// A browser POST upload is verified in the handler from its form fields; the +// key of the verified form reaches the audit log like a signed PUT's. The +// bucket policy denies denied/ so the request ends before the filer. +func TestPostPolicyUploadRecordsTheSigningKey(t *testing.T) { + s3a := newPostAuthzServer(t) + for name, req := range map[string]*http.Request{ + "SigV4": newSignedPostRequest(t, "denied/secret.txt"), + "SigV2": signedPostFormV2(t, "denied/secret.txt", postAuthzSecretKey), + } { + outer := s3_constants.EnsureIdentityHolder(req) + rec := httptest.NewRecorder() + s3a.PostPolicyBucketHandler(rec, outer) + require.Equal(t, http.StatusForbidden, rec.Code, "%s: %s", name, rec.Body.String()) + assert.Equal(t, postAuthzAccessKey, s3err.GetAccessLog(outer, http.StatusForbidden, s3err.ErrAccessDenied).AccessKeyId, name) + } + + // A form whose signature does not verify records nothing. + outer := s3_constants.EnsureIdentityHolder(signedPostFormV2(t, "denied/secret.txt", "not-the-secret")) + rec := httptest.NewRecorder() + s3a.PostPolicyBucketHandler(rec, outer) + require.Contains(t, rec.Body.String(), "SignatureDoesNotMatch") + assert.Equal(t, "", s3_constants.GetAccessKeyIdFromContext(outer)) +} + +// A signature that verifies for one virtual-hosted bucket but is routed to +// another is refused after verification; the refusal still names the key. +func TestSignedVirtualHostRetargetRecordsTheSigningKey(t *testing.T) { + iam := newVirtualHostTestIAM("s3.test") + r, err := newTestRequest("GET", "http://alpha-bkt.s3.test/shared.txt", 0, nil) + require.NoError(t, err) + r.Header.Set("Host", "alpha-bkt.s3.test") + signV4WithPath(r, "access_key_1", "secret_key_1", r.URL.Path) + r.Host = "beta-bkt.s3.test" + r.Header.Set("X-Forwarded-Host", "alpha-bkt.s3.test") + r = mux.SetURLVars(r, map[string]string{"bucket": "beta-bkt", "object": "shared.txt"}) + r = s3_constants.EnsureIdentityHolder(r) + + _, _, errCode := iam.doesSignatureMatch(r) + require.Equal(t, s3err.ErrAccessDenied, errCode) + assert.Equal(t, "access_key_1", s3_constants.GetAccessKeyIdFromContext(r)) +} diff --git a/weed/s3api/auth_access_key_shapes_test.go b/weed/s3api/auth_access_key_shapes_test.go new file mode 100644 index 0000000000..5ea2b2ac4c --- /dev/null +++ b/weed/s3api/auth_access_key_shapes_test.go @@ -0,0 +1,141 @@ +package s3api + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// The key recorded for a request is the key of the credential that verified, +// whatever else the request carries. A request names its key in several +// places (SigV4 header, X-Amz-Credential, SigV2 header, AWSAccessKeyId); +// getRequestAuthType picks one and that verifier records it, so a second, +// unverified key in another place never reaches the audit log or a vended +// session. + +func authAndRecordedKey(t *testing.T, iam *IdentityAccessManagement, r *http.Request) (s3err.ErrorCode, string) { + t.Helper() + r = s3_constants.EnsureIdentityHolder(r) + _, errCode, _ := iam.authenticateRequestInternal(r) + return errCode, s3_constants.GetAccessKeyIdFromContext(r) +} + +func credOf(key, secret string) *Credential { return &Credential{AccessKey: key, SecretKey: secret} } + +func TestRecordedKeyIsTheVerifiedKeyForSigV4Header(t *testing.T) { + iam := newSharedIdentityIAM() + r, err := newTestRequest(http.MethodGet, "https://example.com/bucket/f.parquet", 0, nil) + require.NoError(t, err) + require.NoError(t, signRequestV4(r, sharedKeyA, sharedSecretA)) + errCode, got := authAndRecordedKey(t, iam, r) + require.Equal(t, s3err.ErrNone, errCode) + assert.Equal(t, sharedKeyA, got) +} + +// SigV2 header signed with A, plus X-Amz-Credential naming B or garbage: the +// request authenticates as A (SigV2 is checked first) and A is recorded. +func TestRecordedKeyIgnoresAJunkCredentialQueryOnASigV2Request(t *testing.T) { + iam := newSharedIdentityIAM() + for _, junk := range []string{sharedKeyB + "/20261016/us-east-1/s3/aws4_request", "GARBAGE"} { + r, err := http.NewRequest(http.MethodGet, "http://example.com/bucket/f.parquet?X-Amz-Credential="+url.QueryEscape(junk), nil) + require.NoError(t, err) + r.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + r.Header.Set("Authorization", signatureV2(credOf(sharedKeyA, sharedSecretA), r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header)) + errCode, got := authAndRecordedKey(t, iam, r) + require.Equal(t, s3err.ErrNone, errCode, "the SigV2 signature with A verifies (junk %q)", junk) + assert.Equal(t, sharedKeyA, got, "the key that verified, not the junk %q", junk) + } +} + +// SigV2 presigned with A, plus a junk SigV4 Authorization naming B: the +// presigned A verifies and A is recorded. +func TestRecordedKeyIgnoresAJunkSigV4HeaderOnASigV2PresignedRequest(t *testing.T) { + iam := newSharedIdentityIAM() + expires := strconv.FormatInt(time.Now().Add(10*time.Minute).Unix(), 10) + q := url.Values{"AWSAccessKeyId": {sharedKeyA}, "Expires": {expires}} + r, err := http.NewRequest(http.MethodGet, "http://example.com/bucket/f.parquet?"+q.Encode(), nil) + require.NoError(t, err) + sig := preSignatureV2(credOf(sharedKeyA, sharedSecretA), r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header, expires) + q.Set("Signature", sig) + r.URL.RawQuery = q.Encode() + r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+sharedKeyB+"/20261016/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00") + + errCode, got := authAndRecordedKey(t, iam, r) + require.Equal(t, s3err.ErrNone, errCode, "the SigV2 presigned A verifies (SigV2 is dispatched first)") + assert.Equal(t, sharedKeyA, got, "the presigned key A is recorded, never the unverified B") +} + +// A refused signature records nothing, even when the key exists. +func TestRecordedKeyIsEmptyWhenTheSignatureFails(t *testing.T) { + iam := newSharedIdentityIAM() + r, err := newTestRequest(http.MethodGet, "https://example.com/bucket/f.parquet", 0, nil) + require.NoError(t, err) + require.NoError(t, signRequestV4(r, sharedKeyA, "not-the-secret")) + errCode, got := authAndRecordedKey(t, iam, r) + require.NotEqual(t, s3err.ErrNone, errCode) + assert.Equal(t, "", got) + + r2 := httptest.NewRequest(http.MethodGet, "http://example.com/bucket/f.parquet", nil) + r2.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + r2.Header.Set("Authorization", "AWS "+sharedKeyB+":bm90LWEtc2lnbmF0dXJl") + errCode, got = authAndRecordedKey(t, iam, r2) + require.NotEqual(t, s3err.ErrNone, errCode) + assert.Equal(t, "", got) +} + +// A streaming (aws-chunked) upload records the key of its seed signature. +func TestRecordedKeyForAStreamingUpload(t *testing.T) { + iam := newSharedIdentityIAM() + now := time.Now().UTC() + amzDate, dateStamp := now.Format(iso8601Format), now.Format(yyyymmdd) + scope := dateStamp + "/us-east-1/s3/aws4_request" + hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" + canonicalHeaders := "content-encoding:aws-chunked\n" + "host:s3.amazonaws.com\n" + + "x-amz-content-sha256:" + hashedPayload + "\n" + "x-amz-date:" + amzDate + "\n" + "x-amz-decoded-content-length:4\n" + signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length" + canonicalRequest := "PUT\n/bucket/ns/t/data/f.parquet\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload + stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + getSHA256Hash([]byte(canonicalRequest)) + seed := getSignature(getSigningKey(sharedSecretB, dateStamp, "us-east-1", "s3"), stringToSign) + + r, err := http.NewRequest(http.MethodPut, "http://s3.amazonaws.com/bucket/ns/t/data/f.parquet", bytes.NewReader([]byte("ignored"))) + require.NoError(t, err) + r.Header.Set("Host", "s3.amazonaws.com") + r.Header.Set("x-amz-date", amzDate) + r.Header.Set("x-amz-content-sha256", hashedPayload) + r.Header.Set("Content-Encoding", "aws-chunked") + r.Header.Set("x-amz-decoded-content-length", "4") + r.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", sharedKeyB, scope, signedHeaders, seed)) + + errCode, got := authAndRecordedKey(t, iam, r) + require.Equal(t, s3err.ErrNone, errCode) + assert.Equal(t, sharedKeyB, got) +} + +// An STS session's request records the session's temporary key. +func TestRecordedKeyForAnSTSSession(t *testing.T) { + stsService, config := setupTestSTSService(t) + iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory") + iam.SetIAMIntegration(&S3IAMIntegration{stsService: stsService, enabled: true}) + sessionInfo, sessionToken := issueTestSession(t, stsService, config) + + r, err := newTestRequest(http.MethodGet, "https://example.com/reports/data.csv", 0, nil) + require.NoError(t, err) + r.Header.Set("X-Amz-Security-Token", sessionToken) + require.NoError(t, signRequestV4(r, sessionInfo.Credentials.AccessKeyId, sessionInfo.Credentials.SecretAccessKey)) + outer := s3_constants.EnsureIdentityHolder(r) + identity, errCode := iam.reqSignatureV4Verify(outer) + require.Equal(t, s3err.ErrNone, errCode) + iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {}) + assert.Equal(t, sessionInfo.Credentials.AccessKeyId, s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone).AccessKeyId) +} diff --git a/weed/s3api/auth_signature_v2.go b/weed/s3api/auth_signature_v2.go index 72fdf9b421..3bda791e64 100644 --- a/weed/s3api/auth_signature_v2.go +++ b/weed/s3api/auth_signature_v2.go @@ -18,6 +18,7 @@ package s3api import ( + "context" "crypto/hmac" "crypto/sha1" "crypto/subtle" @@ -29,6 +30,7 @@ import ( "time" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) @@ -67,7 +69,7 @@ func (iam *IdentityAccessManagement) isReqAuthenticatedV2(r *http.Request) (*Ide return iam.doesPresignV2SignatureMatch(r) } -func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http.Header) (*Identity, s3err.ErrorCode) { +func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(ctx context.Context, formValues http.Header) (*Identity, s3err.ErrorCode) { accessKey := formValues.Get("AWSAccessKeyId") if accessKey == "" { @@ -107,6 +109,7 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http. if !compareSignatureV2(signature, calculateSignatureV2(policy, cred.SecretKey)) { return nil, s3err.ErrSignatureDoesNotMatch } + s3_constants.RecordAccessKeyId(ctx, cred.AccessKey) return identity, s3err.ErrNone } @@ -164,6 +167,7 @@ func (iam *IdentityAccessManagement) doesSignV2Match(r *http.Request) (*Identity if !compareSignatureV2(v2Signature, expectedV2Signature) { return nil, s3err.ErrSignatureDoesNotMatch } + s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey) return identity, s3err.ErrNone } @@ -222,6 +226,7 @@ func (iam *IdentityAccessManagement) doesPresignV2SignatureMatch(r *http.Request if !compareSignatureV2(signature, expectedSignature) { return nil, s3err.ErrSignatureDoesNotMatch } + s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey) return identity, s3err.ErrNone } diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 2289d7f77d..b0ec7fd292 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -19,6 +19,7 @@ package s3api import ( "bytes" + "context" "crypto/hmac" "crypto/sha256" "crypto/subtle" @@ -407,6 +408,10 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh } if matchedHost != "" { + // The key of the credential that verified: the audit log and the + // catalog's vended sessions name it (never a key re-read from the request). + // Recorded before the retarget check so that refusal names it too. + s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey) if signedBucket, ok := bucketFromVirtualHost(matchedHost, iam.domain); ok { if routedBucket, _ := s3_constants.GetBucketAndObject(r); routedBucket != "" && routedBucket != signedBucket { glog.V(2).Infof("reject %s %s: signed host %q implies bucket %q but routed to %q", @@ -830,7 +835,7 @@ func parseSignedHeaderList(signedHeadersValue string) ([]string, s3err.ErrorCode return signedHeaders, s3err.ErrNone } -func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http.Header) (*Identity, s3err.ErrorCode) { +func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(ctx context.Context, formValues http.Header) (*Identity, s3err.ErrorCode) { // Parse credential tag. credHeader, err := parseCredentialHeader("Credential=" + formValues.Get("X-Amz-Credential")) @@ -868,6 +873,7 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http. if !compareSignatureV4(newSignature, formValues.Get("X-Amz-Signature")) { return nil, s3err.ErrSignatureDoesNotMatch } + s3_constants.RecordAccessKeyId(ctx, cred.AccessKey) return identity, s3err.ErrNone } diff --git a/weed/s3api/iceberg/handlers_oauth.go b/weed/s3api/iceberg/handlers_oauth.go index 910ffc279a..8e6933c461 100644 --- a/weed/s3api/iceberg/handlers_oauth.go +++ b/weed/s3api/iceberg/handlers_oauth.go @@ -324,20 +324,28 @@ func mintIcebergToken(identityName, accessKey, secret string, ttlSeconds int) (s // authenticateBearer validates a Bearer token from the Authorization header. // Returns the identity name, identity object, and whether auth succeeded. func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) { + identityName, identity, _, ok := s.authenticateBearerKey(r) + return identityName, identity, ok +} + +// authenticateBearerKey is authenticateBearer that also returns the access key +// the token was minted from (its verified access_key claim), so the catalog +// can tell apart the holders of several keys on one identity. +func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, bool) { auth := r.Header.Get("Authorization") if auth == "" { - return "", nil, false + return "", nil, "", false } if !strings.HasPrefix(strings.ToLower(auth), "bearer ") { - return "", nil, false + return "", nil, "", false } tokenString := strings.TrimSpace(auth[7:]) if tokenString == "" { - return "", nil, false + return "", nil, "", false } if s.credentialValidator == nil { - return "", nil, false + return "", nil, "", false } // Parse the token without verification first to get the access key, @@ -347,18 +355,18 @@ func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) _, _, err := parser.ParseUnverified(tokenString, unverified) if err != nil { glog.V(2).Infof("Iceberg OAuth: failed to parse token: %v", err) - return "", nil, false + return "", nil, "", false } if unverified.AccessKey == "" { - return "", nil, false + return "", nil, "", false } // Look up the credential by access key to get the signing key for verification identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey) if err != nil { glog.V(2).Infof("Iceberg OAuth: failed to get credential for access key: %v", err) - return "", nil, false + return "", nil, "", false } signingKey := deriveSigningKey(unverified.AccessKey, secretKey) @@ -371,10 +379,12 @@ func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) }) if err != nil || !verified.Valid { glog.V(2).Infof("Iceberg OAuth: token verification failed: %v", err) - return "", nil, false + return "", nil, "", false } - return identityName, identity, true + // The token's signing key is derived from this access key, so a verified + // token was minted from it. + return identityName, identity, unverified.AccessKey, true } // deriveSigningKey derives a signing key from the access key and secret using HMAC-SHA256. diff --git a/weed/s3api/iceberg/server.go b/weed/s3api/iceberg/server.go index 5403654753..5ec8de2160 100644 --- a/weed/s3api/iceberg/server.go +++ b/weed/s3api/iceberg/server.go @@ -269,9 +269,10 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { // The auth scheme is case-insensitive (RFC 7235), matching // authenticateBearer below. if strings.HasPrefix(strings.ToLower(r.Header.Get("Authorization")), "bearer ") { - if identityName, identity, ok := s.authenticateBearer(r); ok { + if identityName, identity, accessKey, ok := s.authenticateBearerKey(r); ok { ctx := r.Context() ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) + ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) if identity != nil { ctx = s3_constants.SetIdentityInContext(ctx, identity) } @@ -290,6 +291,9 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { return } + // The verifier records the key of the credential that matched into + // this holder; the vended credentials name it. + r = s3_constants.EnsureIdentityHolder(r) identityName, identity, errCode := s.authenticator.AuthenticateRequest(r) if errCode != s3err.ErrNone { apiErr := s3err.GetAPIError(errCode) diff --git a/weed/s3api/iceberg/server_access_key_test.go b/weed/s3api/iceberg/server_access_key_test.go new file mode 100644 index 0000000000..80ef9923b5 --- /dev/null +++ b/weed/s3api/iceberg/server_access_key_test.go @@ -0,0 +1,97 @@ +package iceberg + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// keyedS3Authenticator accepts every SigV4 request as one identity and records +// the key that verified into the request's holder, as S3ApiServer's verifiers do. +type keyedS3Authenticator struct { + mockS3Authenticator + accessKey string +} + +func (k *keyedS3Authenticator) AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode) { + s3_constants.RecordAccessKeyId(r.Context(), k.accessKey) + return "shared-identity", "identity-object", s3err.ErrNone +} + +// A catalog token is minted from one access key; the request it authenticates +// carries that key, so the credentials it vends can name it. +func TestAuthBearerRecordsTheTokensAccessKey(t *testing.T) { + s, _ := newAuthTestServer() + now := time.Now() + token := mintTestToken(t, "AKID123", "secret456", now, now.Add(time.Hour)) + + var gotKey, gotName string + handler := s.Auth(func(w http.ResponseWriter, r *http.Request) { + gotKey = s3_constants.GetAccessKeyIdFromContext(r) + gotName = s3_constants.GetIdentityNameFromContext(r) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("valid Bearer: status = %d, want 200", rec.Code) + } + if gotName != "testuser" { + t.Errorf("identity = %q, want testuser", gotName) + } + if gotKey != "AKID123" { + t.Errorf("access key in context = %q, want AKID123", gotKey) + } +} + +// A SigV4-signed catalog request carries the key its authenticator verified. +func TestAuthSigV4RecordsTheAuthenticatorsAccessKey(t *testing.T) { + s := newTestServerWithOAuth() + s.authenticator = &keyedS3Authenticator{accessKey: "AKIDSIGV4"} + + var gotKey string + handler := s.Auth(func(w http.ResponseWriter, r *http.Request) { + gotKey = s3_constants.GetAccessKeyIdFromContext(r) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIDSIGV4/20261016/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if gotKey != "AKIDSIGV4" { + t.Errorf("access key in context = %q, want AKIDSIGV4", gotKey) + } +} + +// An authenticator that records no key leaves the context without one. +func TestAuthSigV4WithoutKeyAuthenticatorRecordsNoKey(t *testing.T) { + s := newTestServerWithOAuth() + s.authenticator = &mockS3Authenticator{} + + gotKey := "unset" + handler := s.Auth(func(w http.ResponseWriter, r *http.Request) { + gotKey = s3_constants.GetAccessKeyIdFromContext(r) + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if gotKey != "" { + t.Errorf("access key in context = %q, want none", gotKey) + } +} diff --git a/weed/s3api/lance/handlers_oauth.go b/weed/s3api/lance/handlers_oauth.go index 5e7003a743..20e791221e 100644 --- a/weed/s3api/lance/handlers_oauth.go +++ b/weed/s3api/lance/handlers_oauth.go @@ -147,20 +147,27 @@ func mintToken(identityName, accessKey, secret string, ttlSeconds int) (string, // authenticateBearer validates a Bearer token from the Authorization header. // Returns the identity name, identity object, and whether auth succeeded. func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) { + identityName, identity, _, ok := s.authenticateBearerKey(r) + return identityName, identity, ok +} + +// authenticateBearerKey is authenticateBearer that also returns the access key +// the token was minted from (its verified access_key claim). +func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, bool) { auth := r.Header.Get("Authorization") if auth == "" { - return "", nil, false + return "", nil, "", false } if !strings.HasPrefix(strings.ToLower(auth), "bearer ") { - return "", nil, false + return "", nil, "", false } tokenString := strings.TrimSpace(auth[7:]) if tokenString == "" { - return "", nil, false + return "", nil, "", false } if s.credentialValidator == nil { - return "", nil, false + return "", nil, "", false } // Parse the token without verification first to get the access key, @@ -170,17 +177,17 @@ func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) _, _, err := parser.ParseUnverified(tokenString, unverified) if err != nil { glog.V(2).Infof("Lance OAuth: failed to parse token: %v", err) - return "", nil, false + return "", nil, "", false } if unverified.AccessKey == "" { - return "", nil, false + return "", nil, "", false } identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey) if err != nil { glog.V(2).Infof("Lance OAuth: failed to get credential for access key: %v", err) - return "", nil, false + return "", nil, "", false } signingKey := deriveSigningKey(unverified.AccessKey, secretKey) @@ -193,10 +200,12 @@ func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) }) if err != nil || !verified.Valid { glog.V(2).Infof("Lance OAuth: token verification failed: %v", err) - return "", nil, false + return "", nil, "", false } - return identityName, identity, true + // The token's signing key is derived from this access key, so a verified + // token was minted from it. + return identityName, identity, unverified.AccessKey, true } // authenticateApiKey validates an x-api-key header carrying an S3 credential diff --git a/weed/s3api/lance/server.go b/weed/s3api/lance/server.go index 936bd391e7..d0cb813daa 100644 --- a/weed/s3api/lance/server.go +++ b/weed/s3api/lance/server.go @@ -177,9 +177,10 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { // different error that clients cannot act on. // The auth scheme is case-insensitive (RFC 7235). if strings.HasPrefix(strings.ToLower(r.Header.Get("Authorization")), "bearer ") { - if identityName, identity, ok := s.authenticateBearer(r); ok { + if identityName, identity, accessKey, ok := s.authenticateBearerKey(r); ok { ctx := r.Context() ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) + ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) if identity != nil { ctx = s3_constants.SetIdentityInContext(ctx, identity) } @@ -194,8 +195,10 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { if apiKey := r.Header.Get("x-api-key"); apiKey != "" { if identityName, identity, ok := s.authenticateApiKey(apiKey); ok { + accessKey, _, _ := strings.Cut(apiKey, ":") ctx := r.Context() ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) + ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) if identity != nil { ctx = s3_constants.SetIdentityInContext(ctx, identity) } @@ -212,6 +215,9 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { return } + // The verifier records the key of the credential that matched into + // this holder; the vended credentials name it. + r = s3_constants.EnsureIdentityHolder(r) identityName, identity, errCode := s.authenticator.AuthenticateRequest(r) if errCode != s3err.ErrNone { apiErr := s3err.GetAPIError(errCode) diff --git a/weed/s3api/lance/server_access_key_test.go b/weed/s3api/lance/server_access_key_test.go new file mode 100644 index 0000000000..3013256453 --- /dev/null +++ b/weed/s3api/lance/server_access_key_test.go @@ -0,0 +1,69 @@ +package lance + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// keyedS3Authenticator accepts every request as one identity and records the +// key that verified into the request's holder, as S3ApiServer's verifiers do. +type keyedS3Authenticator struct { + mockS3Authenticator + accessKey string +} + +func (k *keyedS3Authenticator) AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode) { + s3_constants.RecordAccessKeyId(r.Context(), k.accessKey) + return "s3user", nil, s3err.ErrNone +} + +func accessKeySeenByHandler(t *testing.T, s *Server, req *http.Request) string { + t.Helper() + gotKey := "unset" + handler := s.Auth(func(w http.ResponseWriter, r *http.Request) { + gotKey = s3_constants.GetAccessKeyIdFromContext(r) + w.WriteHeader(http.StatusOK) + }) + rec := httptest.NewRecorder() + handler(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + return gotKey +} + +// Each way a Lance client authenticates leaves the access key it used in the +// request context, so the credentials it vends can name that key. +func TestAuthRecordsTheCallersAccessKey(t *testing.T) { + now := time.Now() + + s := newTestServerWithOAuth() + req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil) + req.Header.Set("Authorization", "Bearer "+mintTestToken(t, "AKID123", "secret456", now, now.Add(time.Hour))) + if got := accessKeySeenByHandler(t, s, req); got != "AKID123" { + t.Errorf("Bearer: access key in context = %q, want AKID123", got) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil) + req.Header.Set("x-api-key", "AKID123:secret456") + if got := accessKeySeenByHandler(t, s, req); got != "AKID123" { + t.Errorf("x-api-key: access key in context = %q, want AKID123", got) + } + + s.authenticator = &keyedS3Authenticator{accessKey: "AKIDSIGV4"} + req = httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil) + if got := accessKeySeenByHandler(t, s, req); got != "AKIDSIGV4" { + t.Errorf("SigV4: access key in context = %q, want AKIDSIGV4", got) + } + + s.authenticator = &mockS3Authenticator{} + req = httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil) + if got := accessKeySeenByHandler(t, s, req); got != "" { + t.Errorf("authenticator without a key: access key in context = %q, want none", got) + } +} diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index 6fcedfdedc..cf26ac8f84 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -354,6 +354,7 @@ const ( contextKeyIdentityClaim contextKey = "s3-identity-claim" contextKeyIgnoredSessionToken contextKey = "s3-ignored-session-token" contextKeyOriginalRequestPath contextKey = "s3-original-request-path" + contextKeyAccessKeyId contextKey = "s3-access-key-id" ) // identityHolder is a mutable container for the authenticated identity name, @@ -367,6 +368,7 @@ type identityHolder struct { name atomic.Pointer[string] principalArn atomic.Pointer[string] identityClaim atomic.Pointer[string] + accessKeyId atomic.Pointer[string] } // EnsureIdentityHolder attaches a mutable identity holder to the request context @@ -496,6 +498,62 @@ func GetOriginalRequestPathFromContext(r *http.Request) string { return "" } +// SetAccessKeyIdInContext stores the access key id the request authenticated +// with: a static key's id, or an STS session's temporary key id. Several keys +// can belong to one identity, so the identity name alone cannot tell their +// holders apart in the audit log. An access key id is not a secret (AWS logs +// it in CloudTrail); the secret key never reaches the context. +func SetAccessKeyIdInContext(ctx context.Context, accessKeyId string) context.Context { + if accessKeyId == "" { + return ctx + } + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + h.accessKeyId.Store(&accessKeyId) + } + return context.WithValue(ctx, contextKeyAccessKeyId, accessKeyId) +} + +// RecordAccessKeyId stores the access key id of a credential whose signature +// was just verified into the request's identity holder, so the outer +// middleware (the audit fallback) and the handler both see it. A no-op without +// a holder (EnsureIdentityHolder) or without a key. Only a verifier calls it: +// the key comes from the credential that matched, never from re-reading the +// request, whose shape the verifier already resolved. +func RecordAccessKeyId(ctx context.Context, accessKeyId string) { + if ctx == nil || accessKeyId == "" { + return + } + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + h.accessKeyId.Store(&accessKeyId) + } +} + +// GetAccessKeyIdFromContext retrieves the access key id the request +// authenticated with, or "" when it carried none (anonymous, OIDC bearer). +func GetAccessKeyIdFromContext(r *http.Request) string { + if r == nil { + return "" + } + return AccessKeyIdFromContext(r.Context()) +} + +// AccessKeyIdFromContext is GetAccessKeyIdFromContext for code that holds +// only the request's context, such as the Iceberg credential vendor. +func AccessKeyIdFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if id, ok := ctx.Value(contextKeyAccessKeyId).(string); ok && id != "" { + return id + } + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + if id := h.accessKeyId.Load(); id != nil { + return *id + } + } + return "" +} + // IgnoreSessionTokenInContext marks the request's session token as tolerated // rather than authenticated: a statically configured access key signed the // request, so the token (which credential vendors like Unity Catalog emit on diff --git a/weed/s3api/s3api_iceberg_credentials.go b/weed/s3api/s3api_iceberg_credentials.go index 830a2ca98f..50af7cdb99 100644 --- a/weed/s3api/s3api_iceberg_credentials.go +++ b/weed/s3api/s3api_iceberg_credentials.go @@ -2,12 +2,15 @@ package s3api import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "strings" "time" "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" ) // icebergVendedCredentialsProvider names the origin recorded on sessions the @@ -34,6 +37,11 @@ type VendedCredentials struct { // has already authenticated and authorized. Returns nil when no role is // configured, which leaves the catalog telling the client to keep using its // own credentials. +// +// The access key the caller authenticated with (from ctx) is recorded in the +// session: a short hash of it ends the session name, so the audit log's +// requester_arn tells apart the holders of several keys on one identity, and +// the key id itself is carried in the session token. func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*VendedCredentials, error) { if s3a.icebergCredentialRole == "" { return nil, nil @@ -55,12 +63,14 @@ func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, buc return nil, err } + accessKey := s3_constants.AccessKeyIdFromContext(ctx) request := &sts.AssumeRoleForPrincipalRequest{ - RoleArn: s3a.icebergCredentialRole, - Principal: principal, - RoleSessionName: icebergSessionName(principal, bucket, prefix), - ProviderName: icebergVendedCredentialsProvider, - Policy: &policy, + RoleArn: s3a.icebergCredentialRole, + Principal: principal, + RoleSessionName: icebergSessionName(principal, bucket, prefix, accessKey), + ProviderName: icebergVendedCredentialsProvider, + Policy: &policy, + SourceAccessKeyId: accessKey, } if s3a.icebergCredentialDuration > 0 { duration := s3a.icebergCredentialDuration @@ -162,9 +172,25 @@ func tablePrefixSessionPolicy(bucket, prefix string, readOnly bool) (string, err return string(encoded), nil } -// icebergSessionName builds a session name that identifies the caller and the -// table in audit logs, within the 64-character AWS limit. -func icebergSessionName(principal, bucket, prefix string) string { +// icebergSessionKeyTagLength is the hex length of the access key hash that ends +// a vended session's name: 48 bits, so two keys of one identity collide with +// odds of about 2^-48. +const icebergSessionKeyTagLength = 12 + +// icebergSessionKeyTag is "-k" and the first icebergSessionKeyTagLength hex +// digits of the SHA-256 of the access key id, or "" without a key. +func icebergSessionKeyTag(accessKey string) string { + if accessKey == "" { + return "" + } + sum := sha256.Sum256([]byte(accessKey)) + return "-k" + hex.EncodeToString(sum[:])[:icebergSessionKeyTagLength] +} + +// icebergSessionName builds a session name that identifies the caller, the +// table and the caller's access key in audit logs, within the 64-character AWS +// limit. The key tag is never cut: the table part gives way to it. +func icebergSessionName(principal, bucket, prefix, accessKey string) string { name := fmt.Sprintf("iceberg-%s-%s", principal, strings.ReplaceAll(strings.Trim(prefix, "/"), "/", "-")) if strings.Trim(prefix, "/") == "" { name = fmt.Sprintf("iceberg-%s-%s", principal, bucket) @@ -177,8 +203,9 @@ func icebergSessionName(principal, bucket, prefix string) string { return '-' } }, name) - if len(name) > 64 { - name = name[:64] + tag := icebergSessionKeyTag(accessKey) + if limit := 64 - len(tag); len(name) > limit { + name = name[:limit] } - return name + return name + tag } diff --git a/weed/s3api/s3api_iceberg_credentials_test.go b/weed/s3api/s3api_iceberg_credentials_test.go index 77df42e8b6..bdca1bf5da 100644 --- a/weed/s3api/s3api_iceberg_credentials_test.go +++ b/weed/s3api/s3api_iceberg_credentials_test.go @@ -114,7 +114,7 @@ func TestTablePrefixSessionPolicyLeavesBucketLocationUnconditioned(t *testing.T) } func TestIcebergSessionNameIsBounded(t *testing.T) { - name := icebergSessionName(strings.Repeat("principal", 10), "warehouse", "ns/table") + name := icebergSessionName(strings.Repeat("principal", 10), "warehouse", "ns/table", "") if len(name) > 64 { t.Errorf("session name is %d chars, want at most 64", len(name)) } diff --git a/weed/s3api/s3api_iceberg_session_name_test.go b/weed/s3api/s3api_iceberg_session_name_test.go new file mode 100644 index 0000000000..9a615bef8d --- /dev/null +++ b/weed/s3api/s3api_iceberg_session_name_test.go @@ -0,0 +1,98 @@ +package s3api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + "github.com/seaweedfs/seaweedfs/weed/iam/policy" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +func TestIcebergSessionNameWithoutKeyIsUnchanged(t *testing.T) { + assert.Equal(t, "iceberg-analytics-ns-t", icebergSessionName("analytics", "b", "ns/t", "")) + assert.Equal(t, "iceberg-anonymous-ns-t", icebergSessionName("anonymous", "b", "ns/t", "")) +} + +func TestIcebergSessionNameEndsWithTheKeyTag(t *testing.T) { + a := icebergSessionName("analytics", "b", "ns/t", sharedKeyA) + b := icebergSessionName("analytics", "b", "ns/t", sharedKeyB) + assert.NotEqual(t, a, b, "two keys of one identity must give distinguishable session names") + for _, name := range []string{a, b} { + assert.True(t, strings.HasPrefix(name, "iceberg-analytics-ns-t-k"), name) + assert.Len(t, name, len("iceberg-analytics-ns-t-k")+icebergSessionKeyTagLength) + } + assert.Equal(t, a, icebergSessionName("analytics", "b", "ns/t", sharedKeyA), "the tag is stable") + assert.NotContains(t, a, sharedKeyA, "the name carries a hash, not the key id") +} + +// A long identity and table give way to the tag; the tag is never cut. +func TestIcebergSessionNameKeepsTheWholeTagWithinTheLimit(t *testing.T) { + principal := "svc-0b6f6d1c-6f1e-4c1a-9f57-3a8f4c0f1d2e" + name := icebergSessionName(principal, "b", "a_long_namespace/a_long_table_name", sharedKeyA) + tag := icebergSessionKeyTag(sharedKeyA) + assert.LessOrEqual(t, len(name), 64) + assert.True(t, strings.HasSuffix(name, tag), name) + assert.True(t, strings.HasPrefix(name, "iceberg-"+principal), name) + for _, r := range name { + ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' + assert.True(t, ok, "session name %q contains %q", name, r) + } + long := icebergSessionName(strings.Repeat("principal", 10), "b", "ns/t", sharedKeyB) + assert.Len(t, long, 64) + assert.True(t, strings.HasSuffix(long, icebergSessionKeyTag(sharedKeyB))) +} + +// The vendor reads the caller's key from the request context. +func TestVendTableCredentialsReadsTheKeyFromTheContext(t *testing.T) { + ctx := s3_constants.SetAccessKeyIdInContext(context.Background(), sharedKeyA) + assert.Equal(t, sharedKeyA, s3_constants.AccessKeyIdFromContext(ctx)) + assert.Equal(t, "", s3_constants.AccessKeyIdFromContext(context.Background())) +} + +// VendTableCredentials mints a real session from the caller's key: the session +// name ends with the key's tag and the token carries the key id. +func TestVendTableCredentialsRecordsTheCallersKey(t *testing.T) { + mgr := newTestIAMManager(t) + require.NoError(t, mgr.CreateRole(context.Background(), "", "IcebergTableAccess", &integration.RoleDefinition{ + RoleName: "IcebergTableAccess", + TrustPolicy: &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{{ + Effect: "Allow", + Principal: map[string]interface{}{"Federated": icebergVendedCredentialsProvider}, + Action: []string{"sts:AssumeRoleWithCredentials"}, + }}, + }, + })) + iam := newSharedIdentityIAM() + iam.SetIAMIntegration(NewS3IAMIntegration(mgr, "")) + s3a := &S3ApiServer{iam: iam} + s3a.SetIcebergCredentialRole("arn:aws:iam::role/IcebergTableAccess", 3600) + + for _, key := range []string{sharedKeyA, sharedKeyB, ""} { + ctx := context.Background() + if key != "" { + // As the catalog's auth does: the holder is installed, the verifier records into it. + r := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "/", nil)) + s3_constants.RecordAccessKeyId(r.Context(), key) + ctx = r.Context() + } + creds, err := s3a.VendTableCredentials(ctx, "shared-user", "warehouse", "ns/t", false) + require.NoError(t, err) + require.NotNil(t, creds) + info, err := s3a.stsService().ValidateSessionToken(context.Background(), creds.SessionToken) + require.NoError(t, err) + assert.Equal(t, key, info.SourceAccessKeyId, "key %q", key) + assert.Equal(t, icebergSessionName("shared-user", "warehouse", "ns/t", key), info.SessionName, "key %q", key) + if key != "" { + assert.Contains(t, info.SessionName, icebergSessionKeyTag(key)) + } + } +} diff --git a/weed/s3api/s3api_object_handlers_postpolicy.go b/weed/s3api/s3api_object_handlers_postpolicy.go index 3d5cffbc8e..4fb604abbf 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy.go +++ b/weed/s3api/s3api_object_handlers_postpolicy.go @@ -2,6 +2,7 @@ package s3api import ( "bytes" + "context" "encoding/base64" "errors" "fmt" @@ -80,7 +81,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R } // Verify policy signature. - identity, errCode := s3a.iam.doesPolicySignatureMatch(formValues) + identity, errCode := s3a.iam.doesPolicySignatureMatch(r.Context(), formValues) if errCode != s3err.ErrNone { s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode)) return @@ -362,18 +363,19 @@ func getRedirectPostRawQuery(bucket, key, etag string) string { } // Check to see if Policy is signed correctly, returning the signing identity. -// The identity's Write permission on the bucket is checked only once the -// signature verifies, as on the header-signed path, so a form that fails -// verification learns nothing about the key's permissions. A verified identity -// that may not write is returned with ErrAccessDenied. -func (iam *IdentityAccessManagement) doesPolicySignatureMatch(formValues http.Header) (*Identity, s3err.ErrorCode) { +// The verified form's access key is recorded in ctx's identity holder. The +// identity's Write permission on the bucket is checked only once the signature +// verifies, as on the header-signed path, so a form that fails verification +// learns nothing about the key's permissions. A verified identity that may not +// write is returned with ErrAccessDenied. +func (iam *IdentityAccessManagement) doesPolicySignatureMatch(ctx context.Context, formValues http.Header) (*Identity, s3err.ErrorCode) { var identity *Identity var errCode s3err.ErrorCode // For SignV2 - Signature field will be valid if _, ok := formValues["Signature"]; ok { - identity, errCode = iam.doesPolicySignatureV2Match(formValues) + identity, errCode = iam.doesPolicySignatureV2Match(ctx, formValues) } else { - identity, errCode = iam.doesPolicySignatureV4Match(formValues) + identity, errCode = iam.doesPolicySignatureV4Match(ctx, formValues) } if errCode != s3err.ErrNone { return nil, errCode diff --git a/weed/s3api/s3err/audit_fluent.go b/weed/s3api/s3err/audit_fluent.go index 3985e85fa3..a8d28035be 100644 --- a/weed/s3api/s3err/audit_fluent.go +++ b/weed/s3api/s3err/audit_fluent.go @@ -29,6 +29,7 @@ type AccessLog struct { Requester string `msg:"requester" json:"requester,omitempty"` // IAM user id RequesterArn string `msg:"requester_arn" json:"requester_arn,omitempty"` // arn:aws:sts::0:assumed-role/Role/session RequesterIdentity string `msg:"requester_identity" json:"requester_identity,omitempty"` // authoritative OIDC identity claim (preferred_username/email/sub) + AccessKeyId string `msg:"access_key_id" json:"access_key_id,omitempty"` // the key the request was signed with; an STS session's temporary key id RequestID string `msg:"request_id" json:"request_id,omitempty"` // 3E57427F33A59F07 Operation string `msg:"operation" json:"operation,omitempty"` // REST.HTTP_method.resource_type REST.PUT.OBJECT Key string `msg:"key" json:"key,omitempty"` // /photos/2019/08/puppy.jpg @@ -174,6 +175,7 @@ func GetAccessLog(r *http.Request, HTTPStatusCode int, s3errCode ErrorCode) *Acc Requester: s3_constants.GetIdentityNameFromContext(r), // Get from context, not header (secure) RequesterArn: s3_constants.GetPrincipalArnFromContext(r), RequesterIdentity: s3_constants.GetIdentityClaimFromContext(r), + AccessKeyId: s3_constants.GetAccessKeyIdFromContext(r), SignatureVersion: r.Header.Get(s3_constants.AmzAuthType), UserAgent: r.Header.Get("user-agent"), HostId: hostname,