Files
seaweedfs/weed/s3api/s3api_bucket_policy_engine.go
T
Chris LuandClaude Opus 5.5 1618d613a3 s3: resolve a role session's aws:username from its user, not its session name (#12572)
* s3api: record the caller on AssumeRole sessions

The session name is the caller's choice; the session now carries the
caller itself as the user it was issued to, as the web identity, LDAP
and credential paths already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: take a role session's aws:username from its user, not its name

An assumed-role ARN ends in the session name the caller picked, so
naming a session after another user resolved ${aws:username} and
${aws:userid} to that user in bucket and attached policies. Resolve
them from the user the session was issued to instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iam: take a role session's aws:username from its user, not its name

IsActionAllowed set aws:username to the last segment of an assumed-role
ARN, the caller-chosen session name, for role and bucket policies. Use
the user the session was issued to, and drop a forwarded value when the
session names none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: pass a role session caller's user on to the session it assumes

A role session that calls AssumeRole is named by its session ID, so the
new session recorded that ID as its user. An STS session caller now
passes on its own recorded user, or none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: size the session claims map from the request context

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:32:13 -08:00

208 lines
7.7 KiB
Go

package s3api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
// BucketPolicyEngine wraps the policy_engine to provide bucket policy evaluation
type BucketPolicyEngine struct {
engine *policy_engine.PolicyEngine
// MultipartSSELookup retrieves the canonical SSE algorithm ("AES256" or
// "aws:kms") that was stored when a multipart upload was initiated.
// Returns "" when the upload had no SSE or when the entry cannot be found.
// Set by S3ApiServer after construction to give the engine filer access.
MultipartSSELookup func(bucket, uploadID string) string
}
// NewBucketPolicyEngine creates a new bucket policy engine
func NewBucketPolicyEngine() *BucketPolicyEngine {
return &BucketPolicyEngine{
engine: policy_engine.NewPolicyEngine(),
}
}
// LoadBucketPolicy loads a bucket policy into the engine from the filer entry
func (bpe *BucketPolicyEngine) LoadBucketPolicy(bucket string, entry *filer_pb.Entry) error {
if entry == nil || entry.Extended == nil {
return nil
}
policyJSON, exists := entry.Extended[BUCKET_POLICY_METADATA_KEY]
if !exists || len(policyJSON) == 0 {
// No policy for this bucket - remove it if it exists
bpe.engine.DeleteBucketPolicy(bucket)
return nil
}
// Set the policy in the engine
if err := bpe.engine.SetBucketPolicy(bucket, string(policyJSON)); err != nil {
glog.Errorf("Failed to load bucket policy for %s: %v", bucket, err)
return err
}
glog.V(3).Infof("Loaded bucket policy for %s into policy engine", bucket)
return nil
}
// LoadBucketPolicyFromCache loads a bucket policy from a cached BucketConfig
//
// This function loads the policy directly into the engine
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy_engine.PolicyDocument) error {
if policyDoc == nil {
// No policy for this bucket - remove it if it exists
bpe.engine.DeleteBucketPolicy(bucket)
return nil
}
// Policy is already in correct format, just load it
// We need to re-marshal to string because SetBucketPolicy expects JSON string
policyJSON, err := json.Marshal(policyDoc)
if err != nil {
glog.Errorf("Failed to marshal bucket policy for %s: %v", bucket, err)
return err
}
// Set the policy in the engine
if err := bpe.engine.SetBucketPolicy(bucket, string(policyJSON)); err != nil {
glog.Errorf("Failed to load bucket policy for %s: %v", bucket, err)
return err
}
glog.V(4).Infof("Loaded bucket policy for %s into policy engine from cache", bucket)
return nil
}
// DeleteBucketPolicy removes a bucket policy from the engine
func (bpe *BucketPolicyEngine) DeleteBucketPolicy(bucket string) error {
return bpe.engine.DeleteBucketPolicy(bucket)
}
// HasPolicyForBucket checks if a bucket has a policy configured
func (bpe *BucketPolicyEngine) HasPolicyForBucket(bucket string) bool {
return bpe.engine.HasPolicyForBucket(bucket)
}
// GetBucketPolicy gets the policy for a bucket
func (bpe *BucketPolicyEngine) GetBucketPolicy(bucket string) (*policy_engine.PolicyDocument, error) {
return bpe.engine.GetBucketPolicy(bucket)
}
// ListBucketPolicies returns all buckets that have policies
func (bpe *BucketPolicyEngine) ListBucketPolicies() []string {
return bpe.engine.GetAllBucketsWithPolicies()
}
// EvaluatePolicy evaluates whether an action is allowed by bucket policy
//
// Parameters:
// - bucket: the bucket name
// - object: the object key (can be empty for bucket-level operations)
// - action: the action being performed (e.g., "Read", "Write")
// - principal: the principal ARN or identifier
// - r: the HTTP request (optional, used for condition evaluation and action resolution)
// - objectEntry: the object's metadata from entry.Extended (can be nil at auth time,
// should be passed when available for tag-based conditions like s3:ExistingObjectTag)
//
// Returns:
// - allowed: whether the policy allows the action
// - evaluated: whether a policy was found and evaluated (false = no policy exists)
// - error: any error during evaluation
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string, r *http.Request, claims map[string]interface{}, objectEntry map[string][]byte) (allowed bool, evaluated bool, err error) {
// Validate required parameters
if bucket == "" {
return false, false, fmt.Errorf("bucket cannot be empty")
}
if action == "" {
return false, false, fmt.Errorf("action cannot be empty")
}
// Convert action to S3 action format
// ResolveS3Action handles nil request internally (falls back to mapBaseActionToS3Format)
s3Action := ResolveS3Action(r, action, bucket, object)
// Build resource ARN
resource := buildResourceARN(bucket, object)
glog.V(4).Infof("EvaluatePolicy: bucket=%s, resource=%s, action=%s, principal=%s",
bucket, resource, s3Action, principal)
// Evaluate using the policy engine
args := &policy_engine.PolicyEvaluationArgs{
Action: s3Action,
Resource: resource,
Principal: principal,
ObjectEntry: objectEntry,
}
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: bucket=%s, resource=%s, action=%s, principal=%s",
// bucket, resource, s3Action, principal)
// Extract conditions and claims from request if available
if r != nil {
args.OriginalGrantConditions = policy_engine.OriginalGrantConditionsFromRequest(r)
args.Conditions = bpe.engine.ExtractConditionValuesFromRequest(r)
// Extract principal-related variables (aws:username, etc.) from principal ARN
principalVars := principalVariables(principal, claims)
for k, v := range principalVars {
args.Conditions[k] = v
}
// Extract JWT claims if authenticated via JWT or STS
if claims != nil {
args.Claims = claims
} else {
// If claims were not provided directly, try to get them from context Identity?
// But the caller is responsible for passing them.
// Falling back to empty claims if not provided.
}
// For multipart continuation actions look up the SSE algorithm that was
// set at CreateMultipartUpload time. UploadPart/UploadPartCopy do not
// re-send the SSE header, so we inject the stored value so that bucket
// policy conditions on s3:x-amz-server-side-encryption evaluate correctly.
if policy_engine.IsMultipartContinuationAction(s3Action) && bpe.MultipartSSELookup != nil {
if uploadID := r.URL.Query().Get("uploadId"); uploadID != "" {
args.InheritedSSEAlgorithm = bpe.MultipartSSELookup(bucket, uploadID)
}
}
}
result := bpe.engine.EvaluatePolicy(bucket, args)
switch result {
case policy_engine.PolicyResultAllow:
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
return true, true, nil
case policy_engine.PolicyResultDeny:
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
return false, true, nil
case policy_engine.PolicyResultIndeterminate:
// No policy exists for this bucket
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: INDETERMINATE (no policy) - bucket=%s", bucket)
return false, false, nil
default:
return false, false, fmt.Errorf("unknown policy result: %v", result)
}
}
// principalVariables resolves the policy variables of a principal. A role
// session's ARN ends in the session name the caller chose, so its user comes
// from the session itself: the user the session was issued to.
func principalVariables(principal string, claims map[string]interface{}) map[string][]string {
vars := policy_engine.ExtractPrincipalVariables(principal)
if types := vars["aws:principaltype"]; len(types) == 1 && types[0] == "AssumedRole" {
if user, ok := claims["aws:username"].(string); ok && user != "" {
vars["aws:username"] = []string{user}
vars["aws:userid"] = []string{user}
}
}
return vars
}