mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-06-13 23:36:45 +03:00
79a48256f5
* fix(s3): populate s3:prefix from query param for ListObjects policy conditions (#8969) ListObjectsV2/V1 requests with prefix-restricted STS session policies were denied because: 1. s3:prefix was derived from objectKey, which the auth middleware set to the prefix value, but the resource ARN then included the prefix (e.g. arn:aws:s3:::bucket/prefix) instead of staying at bucket level (arn:aws:s3:::bucket) as AWS requires for ListBucket. 2. When objectKey was empty (no middleware propagation), s3:prefix was never populated from the query parameter at all. Now AuthorizeAction extracts the prefix query parameter directly, sets it as s3:prefix in the request context, and uses a bucket-level resource ARN when the objectKey matches the propagated prefix. * fix(s3): use AWS-style wildcard matching for StringLike policy conditions filepath.Match treats * as not matching /, which breaks IAM StringLike conditions on paths (e.g. arn:aws:s3:::bucket/* won't match nested keys). Replace with a case-sensitive variant of AwsWildcardMatch that correctly treats * as matching any character including /. * refactor(s3): replace regex wildcard matching with string-based matcher Use the existing wildcard.MatchesWildcard utility instead of compiling and caching regexes for IAM wildcard matching. Removes the regexCache, its mutex, and the sync import. * refactor(s3): inline and remove AwsWildcardMatch wrapper functions Replace all call sites with direct wildcard.MatchesWildcard calls. * fix(s3): scope s3:prefix condition key to list operations only The s3:prefix logic was running for all actions, so a GetObject on "foo/bar" would wrongly populate s3:prefix. Restrict it to action "List" and always reset resourceObjectKey to "" so the resource ARN stays at bucket level. Also set s3:prefix to "" when no prefix is provided, so policies with StringEquals {"s3:prefix": ""} evaluate correctly.
233 lines
6.5 KiB
Go
233 lines
6.5 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
|
)
|
|
|
|
// IdentityProvider defines the interface for external identity providers
|
|
type IdentityProvider interface {
|
|
// Name returns the unique name of the provider
|
|
Name() string
|
|
|
|
// Initialize initializes the provider with configuration
|
|
Initialize(config interface{}) error
|
|
|
|
// Authenticate authenticates a user with a token and returns external identity
|
|
Authenticate(ctx context.Context, token string) (*ExternalIdentity, error)
|
|
|
|
// GetUserInfo retrieves user information by user ID
|
|
GetUserInfo(ctx context.Context, userID string) (*ExternalIdentity, error)
|
|
|
|
// ValidateToken validates a token and returns claims
|
|
ValidateToken(ctx context.Context, token string) (*TokenClaims, error)
|
|
}
|
|
|
|
// ExternalIdentity represents an identity from an external provider
|
|
type ExternalIdentity struct {
|
|
// UserID is the unique identifier from the external provider
|
|
UserID string `json:"userId"`
|
|
|
|
// Email is the user's email address
|
|
Email string `json:"email"`
|
|
|
|
// DisplayName is the user's display name
|
|
DisplayName string `json:"displayName"`
|
|
|
|
// Groups are the groups the user belongs to
|
|
Groups []string `json:"groups,omitempty"`
|
|
|
|
// Attributes are additional user attributes
|
|
Attributes map[string]string `json:"attributes,omitempty"`
|
|
|
|
// Provider is the name of the identity provider
|
|
Provider string `json:"provider"`
|
|
|
|
// TokenExpiration is the expiration time of the source identity token
|
|
// This is used to limit session duration to not exceed the token's exp claim
|
|
TokenExpiration *time.Time `json:"tokenExpiration,omitempty"`
|
|
}
|
|
|
|
// Validate validates the external identity structure
|
|
func (e *ExternalIdentity) Validate() error {
|
|
if e.UserID == "" {
|
|
return fmt.Errorf("user ID is required")
|
|
}
|
|
|
|
if e.Provider == "" {
|
|
return fmt.Errorf("provider is required")
|
|
}
|
|
|
|
if e.Email != "" {
|
|
if _, err := mail.ParseAddress(e.Email); err != nil {
|
|
return fmt.Errorf("invalid email format: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TokenClaims represents claims from a validated token
|
|
type TokenClaims struct {
|
|
// Subject (sub) - user identifier
|
|
Subject string `json:"sub"`
|
|
|
|
// Issuer (iss) - token issuer
|
|
Issuer string `json:"iss"`
|
|
|
|
// Audience (aud) - intended audience
|
|
Audience string `json:"aud"`
|
|
|
|
// ExpiresAt (exp) - expiration time
|
|
ExpiresAt time.Time `json:"exp"`
|
|
|
|
// IssuedAt (iat) - issued at time
|
|
IssuedAt time.Time `json:"iat"`
|
|
|
|
// NotBefore (nbf) - not valid before time
|
|
NotBefore time.Time `json:"nbf,omitempty"`
|
|
|
|
// Claims are additional claims from the token
|
|
Claims map[string]interface{} `json:"claims,omitempty"`
|
|
}
|
|
|
|
// IsValid checks if the token claims are valid (not expired, etc.)
|
|
func (c *TokenClaims) IsValid() bool {
|
|
now := time.Now()
|
|
|
|
// Check expiration
|
|
if !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) {
|
|
return false
|
|
}
|
|
|
|
// Check not before
|
|
if !c.NotBefore.IsZero() && now.Before(c.NotBefore) {
|
|
return false
|
|
}
|
|
|
|
// Check issued at (shouldn't be in the future)
|
|
if !c.IssuedAt.IsZero() && now.Before(c.IssuedAt) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// GetClaimString returns a string claim value
|
|
func (c *TokenClaims) GetClaimString(key string) (string, bool) {
|
|
if value, exists := c.Claims[key]; exists {
|
|
if str, ok := value.(string); ok {
|
|
return str, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// GetClaimStringSlice returns a string slice claim value
|
|
func (c *TokenClaims) GetClaimStringSlice(key string) ([]string, bool) {
|
|
if value, exists := c.Claims[key]; exists {
|
|
switch v := value.(type) {
|
|
case []string:
|
|
return v, true
|
|
case []interface{}:
|
|
var result []string
|
|
for _, item := range v {
|
|
if str, ok := item.(string); ok {
|
|
result = append(result, str)
|
|
}
|
|
}
|
|
return result, len(result) > 0
|
|
case string:
|
|
// Single string can be treated as slice
|
|
return []string{v}, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// ProviderConfig represents configuration for identity providers
|
|
type ProviderConfig struct {
|
|
// Type of provider (oidc, ldap, saml)
|
|
Type string `json:"type"`
|
|
|
|
// Name of the provider instance
|
|
Name string `json:"name"`
|
|
|
|
// Enabled indicates if the provider is active
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// Config is provider-specific configuration
|
|
Config map[string]interface{} `json:"config"`
|
|
|
|
// RoleMapping defines how to map external identities to roles
|
|
RoleMapping *RoleMapping `json:"roleMapping,omitempty"`
|
|
}
|
|
|
|
// RoleMapping defines rules for mapping external identities to roles
|
|
type RoleMapping struct {
|
|
// Rules are the mapping rules
|
|
Rules []MappingRule `json:"rules"`
|
|
|
|
// DefaultRole is assigned if no rules match
|
|
DefaultRole string `json:"defaultRole,omitempty"`
|
|
}
|
|
|
|
// MappingRule defines a single mapping rule
|
|
type MappingRule struct {
|
|
// Claim is the claim key to check
|
|
Claim string `json:"claim"`
|
|
|
|
// Value is the expected claim value (supports wildcards)
|
|
Value string `json:"value"`
|
|
|
|
// Role is the role ARN to assign
|
|
Role string `json:"role"`
|
|
|
|
// Condition is additional condition logic (optional)
|
|
Condition string `json:"condition,omitempty"`
|
|
}
|
|
|
|
// Matches checks if a rule matches the given claims
|
|
func (r *MappingRule) Matches(claims *TokenClaims) bool {
|
|
if r.Claim == "" || r.Value == "" {
|
|
glog.V(3).Infof("Rule invalid: claim=%s, value=%s", r.Claim, r.Value)
|
|
return false
|
|
}
|
|
|
|
claimValue, exists := claims.GetClaimString(r.Claim)
|
|
if !exists {
|
|
glog.V(3).Infof("Claim '%s' not found as string, trying as string slice", r.Claim)
|
|
// Try as string slice
|
|
if claimSlice, sliceExists := claims.GetClaimStringSlice(r.Claim); sliceExists {
|
|
glog.V(3).Infof("Claim '%s' found as string slice: %v", r.Claim, claimSlice)
|
|
for _, val := range claimSlice {
|
|
glog.V(3).Infof("Checking if '%s' matches rule value '%s'", val, r.Value)
|
|
if r.matchValue(val) {
|
|
glog.V(3).Infof("Match found: '%s' matches '%s'", val, r.Value)
|
|
return true
|
|
}
|
|
}
|
|
} else {
|
|
glog.V(3).Infof("Claim '%s' not found in any format", r.Claim)
|
|
}
|
|
return false
|
|
}
|
|
|
|
glog.V(3).Infof("Claim '%s' found as string: '%s'", r.Claim, claimValue)
|
|
return r.matchValue(claimValue)
|
|
}
|
|
|
|
// matchValue checks if a value matches the rule value (with wildcard support)
|
|
// Uses AWS IAM-compliant case-insensitive wildcard matching for consistency with policy engine
|
|
func (r *MappingRule) matchValue(value string) bool {
|
|
matched := wildcard.MatchesWildcard(strings.ToLower(r.Value), strings.ToLower(value))
|
|
glog.V(3).Infof("AWS IAM pattern match result: '%s' matches '%s' = %t", value, r.Value, matched)
|
|
return matched
|
|
}
|