mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-13 18:10:49 +02:00
* 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
163 lines
5.9 KiB
Go
163 lines
5.9 KiB
Go
package s3err
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestGetAccessLogUsesAmzRequestID(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
|
|
req = req.WithContext(request_id.Set(req.Context(), "req-123"))
|
|
|
|
log := GetAccessLog(req, http.StatusOK, ErrNone)
|
|
|
|
assert.Equal(t, "req-123", log.RequestID)
|
|
}
|
|
|
|
func TestGetAccessLogRemoteIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
xRealIP string
|
|
xForwardedFor string
|
|
expectedRemote string
|
|
}{
|
|
{
|
|
name: "falls back to RemoteAddr (port stripped) when no headers set",
|
|
remoteAddr: "10.89.0.1:35832",
|
|
expectedRemote: "10.89.0.1",
|
|
},
|
|
{
|
|
name: "preserves IPv6 host from RemoteAddr",
|
|
remoteAddr: "[2001:db8::1]:35832",
|
|
expectedRemote: "2001:db8::1",
|
|
},
|
|
{
|
|
name: "returns RemoteAddr unchanged when no port present",
|
|
remoteAddr: "@",
|
|
expectedRemote: "@",
|
|
},
|
|
{
|
|
name: "uses X-Real-IP when X-Forwarded-For is absent",
|
|
remoteAddr: "10.89.0.1:35832",
|
|
xRealIP: "203.0.113.7",
|
|
expectedRemote: "203.0.113.7",
|
|
},
|
|
{
|
|
name: "prefers X-Forwarded-For over X-Real-IP",
|
|
remoteAddr: "10.89.0.1:35832",
|
|
xRealIP: "203.0.113.7",
|
|
xForwardedFor: "198.51.100.42",
|
|
expectedRemote: "198.51.100.42",
|
|
},
|
|
{
|
|
name: "uses first hop in X-Forwarded-For chain",
|
|
remoteAddr: "10.89.0.1:35832",
|
|
xForwardedFor: "198.51.100.42, 10.0.0.5, 10.89.0.1",
|
|
expectedRemote: "198.51.100.42",
|
|
},
|
|
{
|
|
name: "skips empty leading entries in X-Forwarded-For",
|
|
remoteAddr: "10.89.0.1:35832",
|
|
xForwardedFor: ", 198.51.100.42",
|
|
expectedRemote: "198.51.100.42",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
|
|
req.RemoteAddr = tc.remoteAddr
|
|
if tc.xRealIP != "" {
|
|
req.Header.Set("X-Real-IP", tc.xRealIP)
|
|
}
|
|
if tc.xForwardedFor != "" {
|
|
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
|
|
}
|
|
|
|
log := GetAccessLog(req, http.StatusOK, ErrNone)
|
|
|
|
assert.Equal(t, tc.expectedRemote, log.RemoteIP)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetAccessLogRequesterFromFallback reproduces the fallback audit path for
|
|
// GET/HEAD/IAM operations: authentication records the requester on a request
|
|
// copy the track() middleware never sees, yet the fallback audit entry (built
|
|
// from the original request) must still report the authenticated user.
|
|
func TestGetAccessLogRequesterFromFallback(t *testing.T) {
|
|
// track() installs the holder before authentication runs.
|
|
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "/bucket/object", nil))
|
|
|
|
// auth records the identity on a copy and hands that copy to the handler;
|
|
// the copy itself is discarded once the handler returns.
|
|
_ = outer.WithContext(s3_constants.SetIdentityNameInContext(outer.Context(), "admin"))
|
|
|
|
// The handler returned without logging, so track() builds the fallback entry
|
|
// from the original request.
|
|
log := GetAccessLog(outer, http.StatusOK, ErrNone)
|
|
|
|
assert.Equal(t, "admin", log.Requester, "fallback audit entry must report the authenticated requester")
|
|
}
|
|
|
|
func TestGetAccessLogRequesterAnonymous(t *testing.T) {
|
|
req := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "/bucket/object", nil))
|
|
|
|
log := GetAccessLog(req, http.StatusOK, ErrNone)
|
|
|
|
assert.Empty(t, log.Requester, "anonymous request must not report a requester")
|
|
assert.Empty(t, log.RequesterArn, "anonymous request must not report a principal ARN")
|
|
}
|
|
|
|
// An STS session's identity name is an opaque session subject, so the audit
|
|
// entry must also carry the principal ARN — that is where the assumed role and
|
|
// the role session name are recoverable from.
|
|
func TestGetAccessLogRequesterArnForAssumedRole(t *testing.T) {
|
|
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "/bucket/object", nil))
|
|
|
|
// Auth writes into the holder from a request copy the audit path never sees.
|
|
ctx := s3_constants.SetIdentityNameInContext(outer.Context(), "47ad4828c45b3f337bc3146081ba8f0f")
|
|
s3_constants.SetPrincipalArnInContext(ctx, "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session")
|
|
|
|
log := GetAccessLog(outer, http.StatusOK, ErrNone)
|
|
|
|
assert.Equal(t, "47ad4828c45b3f337bc3146081ba8f0f", log.Requester)
|
|
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session", log.RequesterArn)
|
|
}
|
|
|
|
// An OIDC-federated session's opaque requester is useless for compliance
|
|
// auditing, so the audit entry must also carry the authoritative identity claim
|
|
// written by auth into the shared identity holder. See issue #11264.
|
|
func TestGetAccessLogRequesterIdentityFromFallback(t *testing.T) {
|
|
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "/bucket/object", nil))
|
|
|
|
ctx := s3_constants.SetIdentityNameInContext(outer.Context(), "2a19e647c5d43a62a91ecf664d07dbe1")
|
|
s3_constants.SetIdentityClaimInContext(ctx, "grant.west")
|
|
|
|
log := GetAccessLog(outer, http.StatusOK, ErrNone)
|
|
|
|
assert.Equal(t, "2a19e647c5d43a62a91ecf664d07dbe1", log.Requester)
|
|
assert.Equal(t, "grant.west", log.RequesterIdentity)
|
|
}
|
|
|
|
func TestAuditTrackingFlag(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/bucket/object", nil)
|
|
assert.False(t, AuditAlreadyLogged(req), "untracked request reports not logged")
|
|
|
|
tracked := EnsureAuditTracking(req)
|
|
assert.NotSame(t, req, tracked, "EnsureAuditTracking returns a new request when no flag is present")
|
|
assert.False(t, AuditAlreadyLogged(tracked), "tracked request starts unlogged")
|
|
|
|
again := EnsureAuditTracking(tracked)
|
|
assert.Same(t, tracked, again, "EnsureAuditTracking is idempotent when flag already present")
|
|
|
|
MarkAuditLogged(tracked)
|
|
assert.True(t, AuditAlreadyLogged(tracked), "flag flips after MarkAuditLogged")
|
|
}
|