From 74da88fc299ebd9affb86c31935de55f5adcdd81 Mon Sep 17 00:00:00 2001 From: Javier de la Torre Date: Wed, 21 Oct 2026 23:33:08 -0400 Subject: [PATCH] s3, iceberg: record the access key a request was signed with (#11901) * s3, iceberg: record the access key a request was signed with Several access keys can belong to one identity, but nothing downstream of authentication says which one signed a request: the audit log carries the identity name only, and every table credential the Iceberg catalog vends for that identity gets the same session name. When an identity is shared (one key per person or per service), the log cannot tell the holders apart. The verifier that matched a credential now records that credential's access key id in the request's identity holder (verifyV4Signature for header, presigned, streaming and STS requests; both SigV2 verifiers). Nothing re-reads the request for a key, so a second, unverified key elsewhere in the request (a junk X-Amz-Credential on a SigV2 request, a junk SigV4 header on a SigV2 presigned URL) is never recorded. - s3err.AccessLog gains access_key_id (omitempty): a static key's id, or an STS session's temporary key id. Every other field is unchanged. - The Iceberg and Lance catalogs install the holder before authenticating a SigV4 request; a Bearer token records the key it was minted from (the token is verified with a key derived from it); a Lance x-api-key records its key. - VendTableCredentials ends the session name with "-k" and the first 12 hex digits of SHA-256 of the key id (the table part gives way within the 64-character limit; the tag is never cut), and carries the key id in the session token (src_ak, SessionInfo.SourceAccessKeyId). Tests: auth_access_key_attribution_test.go and auth_access_key_shapes_test.go (two keys on one identity, the deny path, SigV2 and presigned SigV2 with a junk key elsewhere, streaming seed, STS session, failed signatures record nothing), s3api_iceberg_session_name_test.go, iceberg and lance Auth tests, sts source_access_key_test.go. * s3: record the access key of a verified POST-policy form and of a retargeted signature Two verified signatures left access_key_id empty in the audit log: - A browser POST upload is verified in PostPolicyBucketHandler from its form fields by doesPolicySignatureV2Match / doesPolicySignatureV4Match, which did not record the key. Both now take the request context and record the credential's key once its signature verifies; a form that fails verification records nothing. - verifyV4Signature refuses a signature that verified for one virtual-hosted bucket but was routed to another. The key was recorded only after that check, so the refusal carried none. It is now recorded as soon as a host candidate verifies. Test: TestPostPolicyUploadRecordsTheSigningKey (SigV4 and SigV2 forms, and a form with a wrong signature) and TestSignedVirtualHostRetargetRecordsTheSigningKey; both fail before this change. * s3: test VendTableCredentials end to end for the caller's access key TestVendTableCredentialsRecordsTheCallersKey mints sessions through VendTableCredentials with a configured STS service, the key recorded the way the catalog's authentication records it, then validates each session token: SourceAccessKeyId and the session name's key tag match the key, and a caller without a key gets an untagged session. It fails if the vendor stops passing either field. --------- Co-authored-by: Javier de la Torre Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- weed/iam/sts/session_claims.go | 13 ++ weed/iam/sts/source_access_key_test.go | 42 ++++++ weed/iam/sts/sts_service.go | 16 +- .../s3api/auth_access_key_attribution_test.go | 75 ++++++++++ .../s3api/auth_access_key_post_policy_test.go | 86 +++++++++++ weed/s3api/auth_access_key_shapes_test.go | 141 ++++++++++++++++++ weed/s3api/auth_signature_v2.go | 7 +- weed/s3api/auth_signature_v4.go | 8 +- weed/s3api/iceberg/handlers_oauth.go | 28 ++-- weed/s3api/iceberg/server.go | 6 +- weed/s3api/iceberg/server_access_key_test.go | 97 ++++++++++++ weed/s3api/lance/handlers_oauth.go | 27 ++-- weed/s3api/lance/server.go | 8 +- weed/s3api/lance/server_access_key_test.go | 69 +++++++++ weed/s3api/s3_constants/header.go | 58 +++++++ weed/s3api/s3api_iceberg_credentials.go | 49 ++++-- weed/s3api/s3api_iceberg_credentials_test.go | 2 +- weed/s3api/s3api_iceberg_session_name_test.go | 98 ++++++++++++ .../s3api/s3api_object_handlers_postpolicy.go | 18 ++- weed/s3api/s3err/audit_fluent.go | 2 + 20 files changed, 806 insertions(+), 44 deletions(-) create mode 100644 weed/iam/sts/source_access_key_test.go create mode 100644 weed/s3api/auth_access_key_attribution_test.go create mode 100644 weed/s3api/auth_access_key_post_policy_test.go create mode 100644 weed/s3api/auth_access_key_shapes_test.go create mode 100644 weed/s3api/iceberg/server_access_key_test.go create mode 100644 weed/s3api/lance/server_access_key_test.go create mode 100644 weed/s3api/s3api_iceberg_session_name_test.go 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,