mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-17 03:50:51 +02:00
* iam: surface OIDC groups into STS session request context for resource-policy ABAC The groups claim was added to ExternalIdentity.Groups but excluded from Attributes (processedClaims), so it never reached the session RequestContext. As a result group membership was usable only in role trust policies (assume-time), not in resource/permission policies (request-time) - so a single role could not scope access by the caller's groups (aggregate ABAC). Surface Groups as a []string in the request context; the string-condition evaluator already handles multi-valued context keys, and the S3 middleware exposes it as jwt:groups. * iam: also surface OIDC roles into the STS request context (companion to groups) The 'roles' claim is excluded from the OIDC attributes by the same processedClaims set that excluded 'groups', so it never reached the session request context and was usable only via provider-configured role mapping - not a raw token roles claim. Add ExternalIdentity.Roles, populate it from the token's roles claim, and surface it as a []string in the request context so resource policies can gate on jwt:roles, exactly as the previous commit did for jwt:groups.
256 lines
7.7 KiB
Go
256 lines
7.7 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"`
|
|
|
|
// Roles are the roles the user holds (from the token's `roles` claim).
|
|
// Surfaced into the STS request context so resource policies can gate on
|
|
// jwt:roles - same rationale as Groups (the claim is otherwise excluded from
|
|
// attributes by the provider's processedClaims set, so it never reached the
|
|
// request context and was usable only via provider-configured role mapping).
|
|
Roles []string `json:"roles,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"`
|
|
|
|
// Issuer is the OIDC `iss` claim (or equivalent) from the source token.
|
|
// Stable per (provider, identity) and used together with UserID to derive
|
|
// a stable parent-user hash that survives token rotation.
|
|
Issuer string `json:"issuer,omitempty"`
|
|
|
|
// PrincipalTags are key/value pairs extracted from the AWS principal-tags
|
|
// namespace claim (`https://aws.amazon.com/tags/principal_tags`). They are
|
|
// surfaced as `aws:PrincipalTag/<key>` in the policy request context
|
|
// (subject to per-provider allowlist filtering at the STS layer).
|
|
PrincipalTags map[string]string `json:"principalTags,omitempty"`
|
|
|
|
// ClaimPolicies are policy names pulled from the provider-configured
|
|
// PolicyClaim. Empty when the provider isn't running in claim-based
|
|
// policy mode or the claim was absent.
|
|
ClaimPolicies []string `json:"claimPolicies,omitempty"`
|
|
|
|
// 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
|
|
}
|