diff --git a/weed/s3api/auth_audit_requester_test.go b/weed/s3api/auth_audit_requester_test.go new file mode 100644 index 000000000..bf1df04e4 --- /dev/null +++ b/weed/s3api/auth_audit_requester_test.go @@ -0,0 +1,130 @@ +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") +} + +// 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) +} diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 0d051cfbe..fa066c4dd 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -1467,15 +1467,27 @@ func (iam *IdentityAccessManagement) AuthPostPolicy(f http.HandlerFunc, action A } } +// recordIdentityInContext stores the authenticated identity, its name and its +// principal ARN in the request context. An STS session's name is only an opaque +// subject, so the ARN is what carries the assumed role and session name to the +// audit log. A JWT-authenticated identity carries no PrincipalArn of its own, +// hence the resolution through buildPrincipalARN. +func recordIdentityInContext(r *http.Request, identity *Identity) context.Context { + if identity == nil { + return r.Context() + } + ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name) + ctx = s3_constants.SetPrincipalArnInContext(ctx, buildPrincipalARN(identity, r)) + // Also store the full identity object for handlers that need it (e.g., ListBuckets) + // This is especially important for JWT users whose identity is not in the identities list + return s3_constants.SetIdentityInContext(ctx, identity) +} + func (iam *IdentityAccessManagement) handleAuthResult(w http.ResponseWriter, r *http.Request, identity *Identity, errCode s3err.ErrorCode, f http.HandlerFunc) { if errCode == s3err.ErrNone { // Store the authenticated identity in request context (secure, cannot be spoofed) if identity != nil && identity.Name != "" { - ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name) - // Also store the full identity object for handlers that need it (e.g., ListBuckets) - // This is especially important for JWT users whose identity is not in the identities list - ctx = s3_constants.SetIdentityInContext(ctx, identity) - r = r.WithContext(ctx) + r = r.WithContext(recordIdentityInContext(r, identity)) } f(w, r) return diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index cdfddb9cb..a41c7ab36 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -302,6 +302,7 @@ const ( contextKeyIdentityName contextKey = "s3-identity-name" contextKeyIdentityObject contextKey = "s3-identity-object" contextKeyIdentityHolder contextKey = "s3-identity-holder" + contextKeyPrincipalArn contextKey = "s3-principal-arn" ) // identityHolder is a mutable container for the authenticated identity name, @@ -312,7 +313,8 @@ const ( // holder installed before authentication is shared across all copies, so the // name written by the inner handler is readable by the outer middleware. type identityHolder struct { - name atomic.Pointer[string] + name atomic.Pointer[string] + principalArn atomic.Pointer[string] } // EnsureIdentityHolder attaches a mutable identity holder to the request context @@ -363,6 +365,34 @@ func GetIdentityNameFromContext(r *http.Request) string { return "" } +// SetPrincipalArnInContext stores the authenticated principal ARN in the request +// context. For an STS session the identity name is an opaque session subject, so +// the ARN is the only place the assumed role and session name survive to the +// audit log. +func SetPrincipalArnInContext(ctx context.Context, principalArn string) context.Context { + if principalArn == "" { + return ctx + } + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + h.principalArn.Store(&principalArn) + } + return context.WithValue(ctx, contextKeyPrincipalArn, principalArn) +} + +// GetPrincipalArnFromContext retrieves the authenticated principal ARN from the +// request context, or "" when the request is unauthenticated. +func GetPrincipalArnFromContext(r *http.Request) string { + if arn, ok := r.Context().Value(contextKeyPrincipalArn).(string); ok && arn != "" { + return arn + } + if h, ok := r.Context().Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + if arn := h.principalArn.Load(); arn != nil { + return *arn + } + } + return "" +} + // SetIdentityInContext stores the full authenticated identity object in the request context // This is used to pass the full identity (including for JWT users) to handlers func SetIdentityInContext(ctx context.Context, identity interface{}) context.Context { diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 8dcea749a..6350e821e 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -26,7 +26,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" - . "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/util/request_id" "google.golang.org/protobuf/proto" @@ -2516,9 +2515,7 @@ func (e *EmbeddedIamApi) AuthIam(f http.HandlerFunc, _ Action) http.HandlerFunc // Store identity in context if identity != nil && identity.Name != "" { - ctx := SetIdentityNameInContext(r.Context(), identity.Name) - ctx = SetIdentityInContext(ctx, identity) - r = r.WithContext(ctx) + r = r.WithContext(recordIdentityInContext(r, identity)) } // Check permissions based on action type diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 951ad29db..ba0317a6d 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -705,6 +705,11 @@ func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Reques s3err.WriteErrorResponse(w, r, s3err.ErrServiceUnavailable) return } + // AssumeRoleWithWebIdentity/WithLDAPIdentity carry no SigV4 caller, so + // identity may be nil here; the STS handlers record their own caller. + if identity != nil { + r = r.WithContext(recordIdentityInContext(r, identity)) + } s3a.stsHandlers.HandleSTSRequest(w, r) } else { // IAM @@ -716,12 +721,7 @@ func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Reques // Store identity in context // Always set identity in context when non-nil to ensure downstream handlers have access - ctx := r.Context() - if identity.Name != "" { - ctx = SetIdentityNameInContext(ctx, identity.Name) - } - ctx = SetIdentityInContext(ctx, identity) - r = r.WithContext(ctx) + r = r.WithContext(recordIdentityInContext(r, identity)) targetUserName := r.Form.Get("UserName") diff --git a/weed/s3api/s3api_sts.go b/weed/s3api/s3api_sts.go index c2f68ef8d..e142d5f84 100644 --- a/weed/s3api/s3api_sts.go +++ b/weed/s3api/s3api_sts.go @@ -385,6 +385,10 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { return } + // Record the caller so the audit entry for the AssumeRole call itself names + // who asked for the session, not just the session it minted. + r = r.WithContext(recordIdentityInContext(r, identity)) + glog.V(2).Infof("AssumeRole: caller identity=%s, roleArn=%s, sessionName=%s", identity.Name, roleArn, roleSessionName) @@ -685,6 +689,8 @@ func (h *STSHandlers) handleGetFederationToken(w http.ResponseWriter, r *http.Re return } + r = r.WithContext(recordIdentityInContext(r, identity)) + glog.V(2).Infof("GetFederationToken: caller identity=%s, name=%s", identity.Name, name) // Check if the caller is authorized to call GetFederationToken @@ -958,6 +964,8 @@ func (h *STSHandlers) handleGetCallerIdentity(w http.ResponseWriter, r *http.Req arn := h.callerPrincipalArn(identity) userId := identity.Name + r = r.WithContext(recordIdentityInContext(r, identity)) + glog.V(2).Infof("GetCallerIdentity: identity=%s, arn=%s, account=%s", identity.Name, arn, accountID) xmlResponse := &GetCallerIdentityResponse{ diff --git a/weed/s3api/s3api_tables.go b/weed/s3api/s3api_tables.go index 6ea2732f2..a1eb54d6f 100644 --- a/weed/s3api/s3api_tables.go +++ b/weed/s3api/s3api_tables.go @@ -14,7 +14,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" - "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" ) @@ -704,9 +703,7 @@ func (s3a *S3ApiServer) authenticateS3Tables(f http.HandlerFunc) http.HandlerFun // Store the authenticated identity in request context if identity != nil && identity.Name != "" { glog.V(2).Infof("S3Tables: authenticated identity Name=%s Account.Id=%s", identity.Name, identity.Account.Id) - ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name) - ctx = s3_constants.SetIdentityInContext(ctx, identity) - r = r.WithContext(ctx) + r = r.WithContext(recordIdentityInContext(r, identity)) } else { glog.V(2).Infof("S3Tables: authenticated identity is nil or empty name") } diff --git a/weed/s3api/s3err/audit_fluent.go b/weed/s3api/s3err/audit_fluent.go index 45374c6ec..feac76b96 100644 --- a/weed/s3api/s3err/audit_fluent.go +++ b/weed/s3api/s3err/audit_fluent.go @@ -23,13 +23,14 @@ type AccessLogExtend struct { } type AccessLog struct { - Bucket string `msg:"bucket" json:"bucket"` // awsexamplebucket1 - Time int64 `msg:"time" json:"time"` // [06/Feb/2019:00:00:38 +0000] - RemoteIP string `msg:"remote_ip" json:"remote_ip,omitempty"` // 192.0.2.3 - Requester string `msg:"requester" json:"requester,omitempty"` // IAM user 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 + Bucket string `msg:"bucket" json:"bucket"` // awsexamplebucket1 + Time int64 `msg:"time" json:"time"` // [06/Feb/2019:00:00:38 +0000] + RemoteIP string `msg:"remote_ip" json:"remote_ip,omitempty"` // 192.0.2.3 + 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 + 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 ErrorCode string `msg:"error_code" json:"error_code,omitempty"` HostId string `msg:"host_id" json:"host_id,omitempty"` HostHeader string `msg:"host_header" json:"host_header,omitempty"` // s3.us-west-2.amazonaws.com @@ -170,6 +171,7 @@ func GetAccessLog(r *http.Request, HTTPStatusCode int, s3errCode ErrorCode) *Acc RequestID: request_id.GetFromRequest(r), RemoteIP: remoteIP, Requester: s3_constants.GetIdentityNameFromContext(r), // Get from context, not header (secure) + RequesterArn: s3_constants.GetPrincipalArnFromContext(r), SignatureVersion: r.Header.Get(s3_constants.AmzAuthType), UserAgent: r.Header.Get("user-agent"), HostId: hostname, diff --git a/weed/s3api/s3err/audit_fluent_test.go b/weed/s3api/s3err/audit_fluent_test.go index d2e3747ca..bb5782d90 100644 --- a/weed/s3api/s3err/audit_fluent_test.go +++ b/weed/s3api/s3err/audit_fluent_test.go @@ -112,6 +112,23 @@ func TestGetAccessLogRequesterAnonymous(t *testing.T) { 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) } func TestAuditTrackingFlag(t *testing.T) {