mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 04:06:44 +00:00
s3: audit the assumed-role principal and the STS caller (#10519)
* s3: log the requester's principal ARN in the audit entry An STS session authenticates as an opaque session subject, so requester alone gave an operator no way back to the assumed role or the session name. Record the principal ARN next to the identity name and emit it as requester_arn. * s3: record the caller identity in the STS handlers AssumeRole, GetFederationToken and GetCallerIdentity verify the caller themselves and are not wrapped by the auth middleware that records the identity, so every audit entry for minting a session had an empty requester. * s3: resolve the audit principal ARN the way policy evaluation does A JWT-authenticated identity carries no PrincipalArn — the auth layer hands the principal over in a request header — so reading the field directly left requester_arn empty for OIDC callers. buildPrincipalARN is the resolver the policy path already uses: header first, then the identity's own ARN, then a synthesized user ARN for legacy identities that have none.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user