Files
seaweedfs/weed/s3api/auth_audit_requester_test.go
T
Chris Lu a3638e479e fix(s3api/audit): surface OIDC identity claim in audit log for STS sessions (#11269)
* Add ResolveIdentityClaim helper for OIDC audit identity

ComputeParentUser derives a stable per-identity hash from (sub, iss) for
internal keying, but it is opaque and not human-readable. Audit logs for
STS-assumed OIDC sessions currently surface that opaque value (or the
random session id) as the requester, leaving no authoritative trace of the
federated user.

Add ResolveIdentityClaim next to ComputeParentUser to recover a
human-readable, server-asserted identity attribute from the STS request
context populated at federation time. It walks a priority list
(preferred_username, email, name, sub) so a federated session always
audits against a stable OIDC claim rather than a client-supplied role
session name.

For #11264

* Surface authoritative OIDC identity claim in S3 audit log

For STS-assumed sessions minted from an OIDC web identity, the audit log
requester field is the opaque session subject, which cannot be traced back
to the federated user who performed the operation. The OIDC identity claims
(preferred_username, email, sub) are already carried in the session request
context and reach the auth layer as identity.Claims, but they were never
surfaced to the audit log.

Add a requester_identity field to the S3 access audit log, populated from
the authoritative OIDC identity claim resolved via ResolveIdentityClaim.
The claim is propagated through the shared identity holder (the same
mechanism the requester name and principal ARN already use) so it survives
the request-context copy that hides auth-set values from the outer audit
middleware.

The existing requester field is left unchanged for backward compatibility;
requester_identity is empty for non-federated sessions, where requester
already carries the real username.

For #11264

* Gate OIDC audit identity on federation marker and harden resolver

Address review feedback (Devin Review, Greptile) on the initial
implementation:

- Non-federated STS sessions no longer gain a false requester_identity.
  ValidateJWTWithClaims merges the JWT registered sub claim (the opaque
  session id) into RequestContext for sessions without an explicit request
  context, so the previous ResolveIdentityClaim fallback to sub surfaced
  that session id as an authoritative identity. Resolution is now gated on
  SessionInfo.ParentUser, which is set only for OIDC-federated sessions in
  AssumeRoleWithWebIdentity. The claim is resolved from the original
  sessionInfo.RequestContext (not the local claims map, whose sub the bearer
  path overwrites with the session subject) so SigV4 and bearer sessions
  surface the same identity.

- ResolveIdentityClaim now trims whitespace and treats whitespace-only
  claims as absent, so a blank preferred_username no longer masks a usable
  email or sub.

The resolved claim is carried on Identity.IdentityClaim (and IAMIdentity for
the bearer path) rather than re-derived in recordIdentityInContext, making
the federation gate explicit at the auth boundary.

For #11264

* Resolve OIDC identity claim for external bearer tokens

The external OIDC bearer path (a raw OIDC JWT presented directly, not via
STS) populates Claims with preferred_username/email/name/sub from the
validated token but did not set IdentityClaim, so requester_identity stayed
blank for that authentication path. Resolve the claim there too — sub is the
real OIDC subject on this path (not an STS session id), so no federation
gate is needed.

Also drop an ineffectual ctx assignment flagged by ineffassign in the audit
test.

For #11264
2026-09-11 11:29:46 -07:00

218 lines
9.3 KiB
Go

package s3api
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"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/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// An STS session authenticates as an opaque session subject, so the audit entry
// must also carry the principal ARN — the role name and the role session name
// are only recoverable from there.
func TestAuditRequesterArnForSTSSession(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: "ClientRole/dev-session",
Principal: "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session",
Subject: "47ad4828c45b3f337bc3146081ba8f0f",
SessionName: "dev-session",
Credentials: &sts.Credentials{
AccessKeyId: "ASIA0189777d42cba8e2",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"ClientPolicy"},
}, nil
},
},
}
// track() installs the holder before authentication runs.
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
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, "47ad4828c45b3f337bc3146081ba8f0f", log.Requester)
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session", log.RequesterArn,
"audit entry must name the assumed role and session")
}
// An OIDC-federated STS session authenticates as an opaque session subject, so
// the requester field alone is useless for compliance auditing. The audit entry
// must surface the authoritative OIDC identity claim (preferred_username, email
// or sub) carried in the session request context, independent of the
// client-supplied role session name. See issue #11264.
func TestAuditRequesterIdentityForOIDCFederatedSession(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: "S3ReadOnlyRole/boto3-session",
Principal: "arn:aws:sts::assumed-role/S3ReadOnlyRole/boto3-session",
Subject: "2a19e647c5d43a62a91ecf664d07dbe1",
SessionName: "boto3-session",
Credentials: &sts.Credentials{
AccessKeyId: "ASIA0189777d42cba8e2",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"S3ReadOnly"},
ParentUser: sts.ComputeParentUser("oidc-sub-123", "https://idp.example/"),
RequestContext: map[string]interface{}{
"preferred_username": "grant.west",
"email": "grant.west@digital.mod.uk",
"sub": "oidc-sub-123",
},
}, nil
},
},
}
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
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, "2a19e647c5d43a62a91ecf664d07dbe1", log.Requester,
"requester stays the opaque session subject for backward compatibility")
assert.Equal(t, "grant.west", log.RequesterIdentity,
"audit entry must surface the authoritative OIDC identity claim, not the client-supplied role session name")
}
// A non-federated STS session has no OIDC identity. ValidateJWTWithClaims merges
// the JWT registered sub claim (the opaque session id) into RequestContext, so
// the context carries a sub even though it is not an OIDC subject. The audit
// entry must not surface that session id as a requester_identity — ParentUser
// gates the resolution so only federated sessions report an identity claim.
func TestAuditRequesterIdentityEmptyForNonFederatedSession(t *testing.T) {
iam := &IdentityAccessManagement{
iamIntegration: &MockIAMIntegration{
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
return &sts.SessionInfo{
AssumedRoleUser: "ClientRole/dev-session",
Principal: "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session",
Subject: "47ad4828c45b3f337bc3146081ba8f0f",
SessionName: "dev-session",
Credentials: &sts.Credentials{
AccessKeyId: "ASIA0189777d42cba8e2",
SecretAccessKey: "secret",
},
ExpiresAt: time.Now().Add(time.Hour),
Policies: []string{"ClientPolicy"},
// Simulate ValidateJWTWithClaims merging the registered sub
// (the session id) into RequestContext for a session that has
// no OIDC identity and therefore no ParentUser.
RequestContext: map[string]interface{}{
"sub": "47ad4828c45b3f337bc3146081ba8f0f",
},
}, nil
},
},
}
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
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.Empty(t, log.RequesterIdentity, "non-federated session must not surface the session id as a requester_identity")
}
// A JWT-authenticated identity carries no PrincipalArn of its own — the auth
// layer hands the principal over in a request header — so the audit entry has to
// resolve the ARN the same way policy evaluation does.
func TestAuditRequesterArnForJWTIdentity(t *testing.T) {
iam := &IdentityAccessManagement{}
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
outer.Header.Set(s3_constants.SeaweedFSPrincipalHeader, "arn:aws:sts::000000000000:assumed-role/ClientRole/oidc-session")
identity := &Identity{Name: "alice", Account: &Account{Id: "alice"}}
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
assert.Equal(t, "alice", log.Requester)
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/oidc-session", log.RequesterArn)
}
// The AssumeRole call itself is authenticated inside the STS handler, which the
// generic auth middleware never wraps; without recording the caller there the
// audit entry for minting a session has no requester at all.
func TestAuditRequesterForAssumeRole(t *testing.T) {
ctx := context.Background()
manager := newTestSTSIntegrationManager(t)
require.NoError(t, manager.CreatePolicy(ctx, "", "ClientPolicy", &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{
Effect: "Allow",
Action: []string{"s3:*"},
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
}},
}))
require.NoError(t, manager.CreateRole(ctx, "", "ClientRole", &integration.RoleDefinition{
RoleName: "ClientRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}},
},
AttachedPolicies: []string{"ClientPolicy"},
}))
const accessKey, secretKey = "adminkey", "adminsecret"
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "admin",
Credentials: []*iam_pb.Credential{{AccessKey: accessKey, SecretKey: secretKey}},
Actions: []string{"Admin"},
}},
}))
body := url.Values{
"Action": {"AssumeRole"},
"Version": {"2011-06-15"},
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/ClientRole"},
"RoleSessionName": {"dev-session"},
}.Encode()
req, err := newTestRequest(http.MethodPost, "http://sts.seaweedfs.test/", int64(len(body)), strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
require.NoError(t, signRequestV4(req, accessKey, secretKey))
req = s3_constants.EnsureIdentityHolder(req)
rec := httptest.NewRecorder()
NewSTSHandlers(manager.GetSTSService(), iam).handleAssumeRole(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
log := s3err.GetAccessLog(req, rec.Code, s3err.ErrNone)
assert.Equal(t, "admin", log.Requester, "AssumeRole must audit who asked for the session")
assert.Equal(t, "arn:aws:iam::"+defaultAccountID+":user/admin", log.RequesterArn)
}