mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-17 12:00:44 +02:00
* feat(iam): claim-based policy mode for AssumeRoleWithWebIdentity When the caller passes the sentinel RoleArn arn:aws:iam:::role/sts-claim-based (or omits it entirely) and the matched OIDC provider has policyClaim set, mint a session whose effective policies come from that JWT claim instead of from a server-side role mapping. Accepts string, comma-separated string, or array shapes — MinIO-compatible behaviour for IDPs that already attach policies to the user. Trust-policy validation is skipped in claim-mode: the IDP is the sole authority for both authentication and authorization, mirroring the contract MinIO documents for its DummyRoleARN flow. Concrete-role mode is unchanged and still requires the role definition + trust policy. * fix(iam): trim policy-claim array elements + clean up stale comments Three medium-priority cleanups gemini flagged on the claim-based path: - extractClaimPolicies's array branch was leaving whitespace on each element while the string/comma-separated branch trimmed via splitPolicyClaimString. An IDP that emits ["readonly", " billing "] would create a "billing" policy lookup that didn't match the stored name. Trim every array element, drop empties. - The "synthetic ARN keyed on the session name" comment was wrong — effectiveRoleArn here is the literal sentinel; it's the assumed-role ARN generated downstream that's session-keyed. Reword. - The empty if/else block at the start of validateAssumeRoleWithWebIdentityRequest existed only to host a comment about deferred validation; the comment now lives in the function godoc and the empty branch is gone. Addresses three gemini medium reviews on PR #9322. * feat(iam): account-scoped OIDC providers Add OIDCProviderRecord.AccountID enforcement: when a role lives in account A, the OIDC provider validating the assume-role token must be either global (AccountID="") or also live in account A. Cross-account use is rejected at the IAM-manager layer before reaching the trust policy validator. OIDCProviderStore gains GetProviderByIssuerAndAccount; both the in- memory and filer-backed stores implement it. Static-config-only deployments are unaffected since they don't populate the store. * fix(iam): account-scoped lookup for cross-account check enforceProviderAccountScope was calling GetProviderByIssuer, which returns the first match arbitrarily when multiple providers share an issuer (one global + one per tenant is the canonical setup). On a two-record collision the wrong record could come back first and falsely reject a valid same-account or global-provider request. Use GetProviderByIssuerAndAccount as the primary lookup so the filter happens in the store. On miss, fall back to GetProviderByIssuer purely to distinguish "issuer entirely unknown" (let the STS layer reject) from "issuer registered in a different account" (surface a precise cross-account error). Addresses gemini high-priority review on PR #9323. * feat(iam): opt-in session revocation via JTI blocklist Add SessionRevocationStore (memory + filer implementations) and wire it into the IAMManager.IsActionAllowed path so a revoked session is rejected on the next signed request. Session JWTs now embed the session id as the JTI claim, giving the blocklist a stable key without requiring a second secret. Operators who don't configure a store keep the existing fully-stateless behaviour: every session stays valid until natural expiry. Operators who do configure one accept one filer lookup per signed request in exchange for being able to invalidate compromised tokens before expiry. Revocation entries carry the original session expiry so the blocklist self-trims via PurgeRevokedSessions. * fix(iam): hash JTI filenames + paginate Purge with proper EOF handling Three reviewer-flagged issues on the filer-backed revocation store: 1. Path traversal (security-medium): RevokeSession is exported and takes an arbitrary string. Using the JTI verbatim as a filename meant a caller could pass "../../etc/passwd" to write outside the basePath. SHA-1 hash the JTI to a fixed-width hex name; lookups still find the entry because Revoke and IsRevoked share the same hash function. 2. Purge swallowed errors. The inner `err` from stream.Recv() shadowed the outer err and the loop just broke on any failure, so a mid-stream gRPC error returned (count, nil) and the caller had no idea the purge was incomplete. Switch to errors.Is(io.EOF) for end-of-stream and propagate everything else. 3. Purge had a hardcoded 10000-entry cap. Stream-paginate via StartFromFileName so the operator-cron can clean a backlog larger than that without losing rows.
219 lines
7.4 KiB
Go
219 lines
7.4 KiB
Go
package sts
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
)
|
|
|
|
// ComputeParentUser returns a stable per-identity hash derived from the OIDC
|
|
// (sub, iss) tuple. Only the (sub, iss) pair is guaranteed stable across token
|
|
// refreshes per OpenID Connect Core 1.0 §5.7, so any per-user state (audit
|
|
// logs, quotas) must key off this value rather than the access-key or session
|
|
// id. The hash is base64-rawurl-encoded SHA-256 over "openid:<sub>:<iss>" so
|
|
// it stays filesystem-safe and bounded in length for storage in audit paths.
|
|
func ComputeParentUser(sub, iss string) string {
|
|
if sub == "" || iss == "" {
|
|
return ""
|
|
}
|
|
h := sha256.Sum256([]byte("openid:" + sub + ":" + iss))
|
|
return base64.RawURLEncoding.EncodeToString(h[:])
|
|
}
|
|
|
|
// defaultCredentialGenerator is a reusable instance for generating temporary credentials
|
|
// Reusing a single instance across all calls to ToSessionInfo() reduces allocation overhead
|
|
// since this method may be called frequently during signature verification
|
|
var defaultCredentialGenerator = NewCredentialGenerator()
|
|
|
|
// STSSessionClaims represents comprehensive session information embedded in JWT tokens
|
|
// This eliminates the need for separate session storage by embedding all session
|
|
// metadata directly in the token itself - enabling true stateless operation
|
|
type STSSessionClaims struct {
|
|
jwt.RegisteredClaims
|
|
|
|
// Session identification
|
|
SessionId string `json:"sid"` // session_id (abbreviated for smaller tokens)
|
|
SessionName string `json:"snam"` // session_name (abbreviated for smaller tokens)
|
|
TokenType string `json:"typ"` // token_type
|
|
|
|
// Role information
|
|
RoleArn string `json:"role"` // role_arn
|
|
AssumedRole string `json:"assumed"` // assumed_role_user
|
|
Principal string `json:"principal"` // principal_arn
|
|
|
|
// Authorization data
|
|
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
|
|
// SessionPolicy contains inline session policy JSON (optional)
|
|
SessionPolicy string `json:"spol,omitempty"`
|
|
|
|
// Identity provider information
|
|
IdentityProvider string `json:"idp"` // identity_provider
|
|
ExternalUserId string `json:"ext_uid"` // external_user_id
|
|
ProviderIssuer string `json:"prov_iss"` // provider_issuer
|
|
|
|
// Request context (optional, for policy evaluation)
|
|
RequestContext map[string]interface{} `json:"req_ctx,omitempty"`
|
|
|
|
// Session metadata
|
|
AssumedAt time.Time `json:"assumed_at"` // when role was assumed
|
|
MaxDuration int64 `json:"max_dur,omitempty"` // maximum session duration in seconds
|
|
|
|
// ParentUser is a stable hash of (sub, iss) for tokens minted from an OIDC
|
|
// identity. It survives token rotation since only the (sub, iss) tuple is
|
|
// guaranteed stable per OpenID Connect Core 1.0. Empty for non-federated
|
|
// session types.
|
|
ParentUser string `json:"puid,omitempty"`
|
|
}
|
|
|
|
// NewSTSSessionClaims creates new STS session claims with all required information
|
|
func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSessionClaims {
|
|
now := time.Now()
|
|
return &STSSessionClaims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: issuer,
|
|
Subject: sessionId,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
// jti = sessionId. The session id is already a unique random
|
|
// identifier, so reusing it as the JWT id avoids a second secret
|
|
// while still giving the revocation layer a stable lookup key.
|
|
ID: sessionId,
|
|
},
|
|
SessionId: sessionId,
|
|
TokenType: TokenTypeSession,
|
|
AssumedAt: now,
|
|
}
|
|
}
|
|
|
|
// ToSessionInfo converts JWT claims back to SessionInfo structure
|
|
// This enables seamless integration with existing code expecting SessionInfo
|
|
func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
|
var expiresAt time.Time
|
|
if c.ExpiresAt != nil {
|
|
expiresAt = c.ExpiresAt.Time
|
|
}
|
|
|
|
// Generate temporary credentials from the session ID
|
|
// This is deterministic based on the session ID, so the same credentials are regenerated
|
|
credentials, err := defaultCredentialGenerator.GenerateTemporaryCredentials(c.SessionId, expiresAt)
|
|
if err != nil {
|
|
// Log the error with context - credential generation failure is important for debugging
|
|
errMsg := fmt.Errorf("generate temporary credentials for session %s: %w", c.SessionId, err)
|
|
glog.Warningf("Failed to generate credentials for STS session: %v", errMsg)
|
|
// Return session info without credentials - validation will catch this as invalid
|
|
credentials = nil
|
|
}
|
|
|
|
return &SessionInfo{
|
|
SessionId: c.SessionId,
|
|
SessionName: c.SessionName,
|
|
RoleArn: c.RoleArn,
|
|
AssumedRoleUser: c.AssumedRole,
|
|
Principal: c.Principal,
|
|
Policies: c.Policies,
|
|
SessionPolicy: c.SessionPolicy,
|
|
ExpiresAt: expiresAt,
|
|
IdentityProvider: c.IdentityProvider,
|
|
ExternalUserId: c.ExternalUserId,
|
|
ProviderIssuer: c.ProviderIssuer,
|
|
RequestContext: c.RequestContext,
|
|
ParentUser: c.ParentUser,
|
|
// Provide the Subject (sub) from registered claims
|
|
Subject: c.Subject,
|
|
Credentials: credentials,
|
|
}
|
|
}
|
|
|
|
// IsValid checks if the session claims are valid (not expired, etc.)
|
|
func (c *STSSessionClaims) IsValid() bool {
|
|
now := time.Now()
|
|
|
|
// Check expiration
|
|
if c.ExpiresAt != nil && c.ExpiresAt.Before(now) {
|
|
return false
|
|
}
|
|
|
|
// Check not-before
|
|
if c.NotBefore != nil && c.NotBefore.After(now) {
|
|
return false
|
|
}
|
|
|
|
// Ensure required fields are present
|
|
if c.SessionId == "" || c.RoleArn == "" || c.Principal == "" {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// GetSessionId returns the session identifier
|
|
func (c *STSSessionClaims) GetSessionId() string {
|
|
return c.SessionId
|
|
}
|
|
|
|
// GetExpiresAt returns the expiration time
|
|
func (c *STSSessionClaims) GetExpiresAt() time.Time {
|
|
if c.ExpiresAt != nil {
|
|
return c.ExpiresAt.Time
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// WithRoleInfo sets role-related information in the claims
|
|
func (c *STSSessionClaims) WithRoleInfo(roleArn, assumedRole, principal string) *STSSessionClaims {
|
|
c.RoleArn = roleArn
|
|
c.AssumedRole = assumedRole
|
|
c.Principal = principal
|
|
return c
|
|
}
|
|
|
|
// WithPolicies sets the policies associated with this session
|
|
func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
|
|
c.Policies = policies
|
|
return c
|
|
}
|
|
|
|
// WithSessionPolicy sets the inline session policy JSON for this session
|
|
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
|
|
c.SessionPolicy = policy
|
|
return c
|
|
}
|
|
|
|
// WithIdentityProvider sets identity provider information
|
|
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
|
|
c.IdentityProvider = providerName
|
|
c.ExternalUserId = externalUserId
|
|
c.ProviderIssuer = providerIssuer
|
|
return c
|
|
}
|
|
|
|
// WithRequestContext sets request context for policy evaluation
|
|
func (c *STSSessionClaims) WithRequestContext(ctx map[string]interface{}) *STSSessionClaims {
|
|
c.RequestContext = ctx
|
|
return c
|
|
}
|
|
|
|
// WithMaxDuration sets the maximum session duration
|
|
func (c *STSSessionClaims) WithMaxDuration(duration time.Duration) *STSSessionClaims {
|
|
c.MaxDuration = int64(duration.Seconds())
|
|
return c
|
|
}
|
|
|
|
// WithSessionName sets the session name
|
|
func (c *STSSessionClaims) WithSessionName(sessionName string) *STSSessionClaims {
|
|
c.SessionName = sessionName
|
|
return c
|
|
}
|
|
|
|
// WithParentUser sets the stable per-identity hash for the session. See
|
|
// ComputeParentUser for the derivation rule.
|
|
func (c *STSSessionClaims) WithParentUser(parentUser string) *STSSessionClaims {
|
|
c.ParentUser = parentUser
|
|
return c
|
|
}
|