From 966ada47add2cd0cc04a4d4de42f4efa541fb615 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 19 Feb 2026 16:00:31 -0800 Subject: [PATCH] Enforce IAM for s3tables bucket creation --- weed/s3api/s3api_tables.go | 8 + weed/s3api/s3tables/handler.go | 7 +- weed/s3api/s3tables/handler_bucket_create.go | 34 ++- weed/s3api/s3tables/iam.go | 209 +++++++++++++++++++ 4 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 weed/s3api/s3tables/iam.go diff --git a/weed/s3api/s3api_tables.go b/weed/s3api/s3api_tables.go index ac4a37604..b27943334 100644 --- a/weed/s3api/s3api_tables.go +++ b/weed/s3api/s3api_tables.go @@ -48,6 +48,11 @@ func (st *S3TablesApiServer) SetDefaultAllow(allow bool) { st.handler.SetDefaultAllow(allow) } +// SetIAMAuthorizer injects the IAM authorizer for S3 Tables IAM checks. +func (st *S3TablesApiServer) SetIAMAuthorizer(authorizer s3tables.IAMAuthorizer) { + st.handler.SetIAMAuthorizer(authorizer) +} + // S3TablesHandler handles S3 Tables API requests func (st *S3TablesApiServer) S3TablesHandler(w http.ResponseWriter, r *http.Request) { st.handler.HandleRequest(w, r, st) @@ -64,6 +69,9 @@ func (s3a *S3ApiServer) registerS3TablesRoutes(router *mux.Router) { s3TablesApi := NewS3TablesApiServer(s3a) if s3a.iam != nil && s3a.iam.iamIntegration != nil { s3TablesApi.SetDefaultAllow(s3a.iam.iamIntegration.DefaultAllow()) + if s3Integration, ok := s3a.iam.iamIntegration.(*S3IAMIntegration); ok && s3Integration.iamManager != nil { + s3TablesApi.SetIAMAuthorizer(s3Integration.iamManager) + } } else { // If IAM is not configured, allow all access by default s3TablesApi.SetDefaultAllow(true) diff --git a/weed/s3api/s3tables/handler.go b/weed/s3api/s3tables/handler.go index 962d3bfc9..b69b6b662 100644 --- a/weed/s3api/s3tables/handler.go +++ b/weed/s3api/s3tables/handler.go @@ -44,9 +44,10 @@ const ( // S3TablesHandler handles S3 Tables API requests type S3TablesHandler struct { - region string - accountID string - defaultAllow bool // Whether to allow access by default (for zero-config IAM) + region string + accountID string + defaultAllow bool // Whether to allow access by default (for zero-config IAM) + iamAuthorizer IAMAuthorizer } // NewS3TablesHandler creates a new S3 Tables handler diff --git a/weed/s3api/s3tables/handler_bucket_create.go b/weed/s3api/s3tables/handler_bucket_create.go index c093931dd..0251d989a 100644 --- a/weed/s3api/s3tables/handler_bucket_create.go +++ b/weed/s3api/s3tables/handler_bucket_create.go @@ -14,15 +14,6 @@ import ( // handleCreateTableBucket creates a new table bucket func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error { - // Check permission - principal := h.getAccountID(r) - if !CheckPermissionWithContext("CreateTableBucket", principal, principal, "", "", &PolicyContext{ - DefaultAllow: h.defaultAllow, - }) { - h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table buckets") - return NewAuthError("CreateTableBucket", principal, "not authorized to create table buckets") - } - var req CreateTableBucketRequest if err := h.readRequestBody(r, &req); err != nil { h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error()) @@ -35,6 +26,31 @@ func (h *S3TablesHandler) handleCreateTableBucket(w http.ResponseWriter, r *http return err } + principal := h.getAccountID(r) + identityActions := getIdentityActions(r) + if h.shouldUseIAM(r, identityActions) && !h.defaultAllow { + ownerAccountID := h.getAccountID(r) + tableBucketARN := h.generateTableBucketARN(ownerAccountID, req.Name) + s3BucketARN := fmt.Sprintf("arn:aws:s3:::%s", req.Name) + allowed, err := h.authorizeIAMAction(r, "s3tables:CreateTableBucket", tableBucketARN, s3BucketARN) + if err != nil || !allowed { + h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table buckets") + return NewAuthError("CreateTableBucket", principal, "not authorized to create table buckets") + } + } else { + owner := h.accountID + if owner == "" { + owner = DefaultAccountID + } + if !CheckPermissionWithContext("CreateTableBucket", principal, owner, "", "", &PolicyContext{ + IdentityActions: identityActions, + DefaultAllow: h.defaultAllow, + }) { + h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, "not authorized to create table buckets") + return NewAuthError("CreateTableBucket", principal, "not authorized to create table buckets") + } + } + bucketPath := GetTableBucketPath(req.Name) // Check if bucket already exists and ensure no conflict with object store buckets diff --git a/weed/s3api/s3tables/iam.go b/weed/s3api/s3tables/iam.go new file mode 100644 index 000000000..008a00859 --- /dev/null +++ b/weed/s3api/s3tables/iam.go @@ -0,0 +1,209 @@ +package s3tables + +import ( + "context" + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// IAMAuthorizer allows s3tables handlers to evaluate IAM policies without importing s3api. +type IAMAuthorizer interface { + IsActionAllowed(ctx context.Context, request *integration.ActionRequest) (bool, error) +} + +// SetIAMAuthorizer injects the IAM authorizer for policy-based access checks. +func (h *S3TablesHandler) SetIAMAuthorizer(authorizer IAMAuthorizer) { + h.iamAuthorizer = authorizer +} + +func (h *S3TablesHandler) shouldUseIAM(r *http.Request, identityActions []string) bool { + if h.iamAuthorizer == nil || r == nil { + return false + } + if hasSessionToken(r) { + return true + } + return len(identityActions) == 0 +} + +func hasSessionToken(r *http.Request) bool { + if r.Header.Get("X-SeaweedFS-Session-Token") != "" { + return true + } + if r.Header.Get("X-Amz-Security-Token") != "" { + return true + } + return r.URL.Query().Get("X-Amz-Security-Token") != "" +} + +func (h *S3TablesHandler) authorizeIAMAction(r *http.Request, action string, resources ...string) (bool, error) { + if h.iamAuthorizer == nil { + return false, nil + } + principal := r.Header.Get("X-SeaweedFS-Principal") + if principal == "" { + principal = getIdentityPrincipalArn(r) + } + if principal == "" { + return false, fmt.Errorf("missing principal for IAM authorization") + } + + if !strings.Contains(action, ":") { + action = "s3tables:" + action + } + + sessionToken := r.Header.Get("X-SeaweedFS-Session-Token") + if sessionToken == "" { + sessionToken = r.Header.Get("X-Amz-Security-Token") + if sessionToken == "" { + sessionToken = r.URL.Query().Get("X-Amz-Security-Token") + } + } + + requestContext := buildIAMRequestContext(r, getIdentityClaims(r)) + policyNames := getIdentityPolicyNames(r) + + var lastErr error + for _, resource := range resources { + if resource == "" { + continue + } + allowed, err := h.iamAuthorizer.IsActionAllowed(r.Context(), &integration.ActionRequest{ + Principal: principal, + Action: action, + Resource: resource, + SessionToken: sessionToken, + RequestContext: requestContext, + PolicyNames: policyNames, + }) + if err != nil { + lastErr = err + glog.V(2).Infof("S3Tables: IAM authorization error action=%s resource=%s principal=%s: %v", action, resource, principal, err) + continue + } + if allowed { + return true, nil + } + } + return false, lastErr +} + +func getIdentityPrincipalArn(r *http.Request) string { + identityRaw := s3_constants.GetIdentityFromContext(r) + if identityRaw == nil { + return "" + } + val := reflect.ValueOf(identityRaw) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if val.Kind() != reflect.Struct { + return "" + } + field := val.FieldByName("PrincipalArn") + if field.IsValid() && field.Kind() == reflect.String { + return field.String() + } + return "" +} + +func getIdentityPolicyNames(r *http.Request) []string { + identityRaw := s3_constants.GetIdentityFromContext(r) + if identityRaw == nil { + return nil + } + val := reflect.ValueOf(identityRaw) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if val.Kind() != reflect.Struct { + return nil + } + field := val.FieldByName("PolicyNames") + if !field.IsValid() || field.Kind() != reflect.Slice { + return nil + } + policies := make([]string, 0, field.Len()) + for i := 0; i < field.Len(); i++ { + item := field.Index(i) + if item.Kind() == reflect.String { + policies = append(policies, item.String()) + } else if item.CanInterface() { + policies = append(policies, fmt.Sprint(item.Interface())) + } + } + if len(policies) == 0 { + return nil + } + return policies +} + +func getIdentityClaims(r *http.Request) map[string]interface{} { + identityRaw := s3_constants.GetIdentityFromContext(r) + if identityRaw == nil { + return nil + } + val := reflect.ValueOf(identityRaw) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if val.Kind() != reflect.Struct { + return nil + } + field := val.FieldByName("Claims") + if !field.IsValid() || field.Kind() != reflect.Map || field.IsNil() { + return nil + } + if field.Type().Key().Kind() != reflect.String { + return nil + } + claims := make(map[string]interface{}, field.Len()) + for _, key := range field.MapKeys() { + if key.Kind() != reflect.String { + continue + } + val := field.MapIndex(key) + if !val.IsValid() { + continue + } + claims[key.String()] = val.Interface() + } + if len(claims) == 0 { + return nil + } + return claims +} + +func buildIAMRequestContext(r *http.Request, claims map[string]interface{}) map[string]interface{} { + ctx := make(map[string]interface{}) + if ua := r.Header.Get("User-Agent"); ua != "" { + ctx["userAgent"] = ua + } + if referer := r.Header.Get("Referer"); referer != "" { + ctx["referer"] = referer + } + if requestTime := r.Context().Value("requestTime"); requestTime != nil { + ctx["requestTime"] = requestTime + } + for k, v := range claims { + if _, exists := ctx[k]; !exists { + ctx[k] = v + } + if !strings.Contains(k, ":") { + jwtKey := "jwt:" + k + if _, exists := ctx[jwtKey]; !exists { + ctx[jwtKey] = v + } + } + } + if len(ctx) == 0 { + return nil + } + return ctx +}