diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go index e7f4ce89c5..0dd0361718 100644 --- a/weed/iam/sts/session_claims.go +++ b/weed/iam/sts/session_claims.go @@ -105,6 +105,13 @@ type STSSessionClaims struct { // a SeaweedFS service minted the session for it (AssumeRoleForPrincipal); // empty for every other session type. SourceAccessKeyId string `json:"src_ak,omitempty"` + + // BoundAccessKeyId is the stored credential the session is bound to: the + // session ends when that key is gone. It is a claim of its own because + // tokens minted before the binding existed carry SourceAccessKeyId as an + // audit field, never a binding — enforcing those would break them. + BoundAccessKeyId string `json:"bind_ak,omitempty"` + BoundAccessKeyFingerprint string `json:"bind_fp,omitempty"` } // NewSTSSessionClaims creates new STS session claims with all required information @@ -163,6 +170,9 @@ func (c *STSSessionClaims) ToSessionInfo(credGen *CredentialGenerator) *SessionI ParentUser: c.ParentUser, // The access key the session was minted for, if recorded SourceAccessKeyId: c.SourceAccessKeyId, + // The stored credential the session is bound to, if any + BoundAccessKeyId: c.BoundAccessKeyId, + BoundAccessKeyFingerprint: c.BoundAccessKeyFingerprint, // Provide the Subject (sub) from registered claims Subject: c.Subject, Credentials: credentials, @@ -263,6 +273,14 @@ func (c *STSSessionClaims) WithSourceAccessKeyId(accessKeyId string) *STSSession return c } +// WithBoundAccessKey binds the session to a stored credential: the key id it +// was vended for and a fingerprint of its secret at mint time. +func (c *STSSessionClaims) WithBoundAccessKey(accessKeyId, fingerprint string) *STSSessionClaims { + c.BoundAccessKeyId = accessKeyId + c.BoundAccessKeyFingerprint = fingerprint + 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 { diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index 9083b221e5..99f48dc14d 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -320,6 +320,16 @@ type SessionInfo struct { // SourceAccessKeyId is the access key the session was minted for, when the // minting service recorded it (AssumeRoleForPrincipal). SourceAccessKeyId string `json:"sourceAccessKeyId,omitempty"` + + // BoundAccessKeyId is the stored credential the session is bound to, when + // the minting service recorded one (AssumeRoleForPrincipal). A legacy + // SourceAccessKeyId is never treated as a binding. + BoundAccessKeyId string `json:"boundAccessKeyId,omitempty"` + + // BoundAccessKeyFingerprint identifies the credential instance the + // session is bound to, so a key re-created under the same id does not + // match. + BoundAccessKeyFingerprint string `json:"boundAccessKeyFingerprint,omitempty"` } // NewSTSService creates a new STS service @@ -901,6 +911,14 @@ type AssumeRoleForPrincipalRequest struct { // SourceAccessKeyId optionally records the access key the caller // authenticated with, so the session can be tied to that key. SourceAccessKeyId string + + // BoundAccessKeyId binds the session to a stored credential: the session + // ends when that key is gone. Distinct from SourceAccessKeyId, which is + // audit-only on sessions minted before the binding existed. + BoundAccessKeyId string + + // BoundAccessKeyFingerprint identifies the bound credential instance. + BoundAccessKeyFingerprint string } // AssumeRoleForPrincipal issues session credentials for a caller that a @@ -947,7 +965,10 @@ func (s *STSService) AssumeRoleForPrincipal(ctx context.Context, request *Assume // carry no role ID and stay bound by name. return s.issueSession(request.RoleArn, "", request.RoleSessionName, sessionPolicy, request.DurationSeconds, request.ProviderName, request.Principal, - func(c *STSSessionClaims) { c.WithSourceAccessKeyId(request.SourceAccessKeyId) }) + func(c *STSSessionClaims) { + c.WithSourceAccessKeyId(request.SourceAccessKeyId) + c.WithBoundAccessKey(request.BoundAccessKeyId, request.BoundAccessKeyFingerprint) + }) } // ValidateSessionToken validates a session token and returns session information diff --git a/weed/s3api/auth_signature_v2.go b/weed/s3api/auth_signature_v2.go index 3bda791e64..5148be2218 100644 --- a/weed/s3api/auth_signature_v2.go +++ b/weed/s3api/auth_signature_v2.go @@ -110,6 +110,7 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(ctx context.Cont return nil, s3err.ErrSignatureDoesNotMatch } s3_constants.RecordAccessKeyId(ctx, cred.AccessKey) + s3_constants.RecordStoredAccessKeyId(ctx, cred.AccessKey, cred.SecretKey) return identity, s3err.ErrNone } @@ -168,6 +169,7 @@ func (iam *IdentityAccessManagement) doesSignV2Match(r *http.Request) (*Identity return nil, s3err.ErrSignatureDoesNotMatch } s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey) + s3_constants.RecordStoredAccessKeyId(r.Context(), cred.AccessKey, cred.SecretKey) return identity, s3err.ErrNone } @@ -227,6 +229,7 @@ func (iam *IdentityAccessManagement) doesPresignV2SignatureMatch(r *http.Request return nil, s3err.ErrSignatureDoesNotMatch } s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey) + s3_constants.RecordStoredAccessKeyId(r.Context(), cred.AccessKey, cred.SecretKey) return identity, s3err.ErrNone } diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index b0ec7fd292..5f96669554 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -412,6 +412,9 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh // catalog's vended sessions name it (never a key re-read from the request). // Recorded before the retarget check so that refusal names it too. s3_constants.RecordAccessKeyId(r.Context(), cred.AccessKey) + if found { + s3_constants.RecordStoredAccessKeyId(r.Context(), cred.AccessKey, cred.SecretKey) + } if signedBucket, ok := bucketFromVirtualHost(matchedHost, iam.domain); ok { if routedBucket, _ := s3_constants.GetBucketAndObject(r); routedBucket != "" && routedBucket != signedBucket { glog.V(2).Infof("reject %s %s: signed host %q implies bucket %q but routed to %q", @@ -496,6 +499,19 @@ func (iam *IdentityAccessManagement) validateSTSSessionToken(r *http.Request, se return nil, nil, s3err.ErrExpiredToken } + // A session the table catalog vended for a static access key is bound to + // that key, as a role session is bound to its role's ID: deleting or + // deactivating the key, or deleting or disabling its identity, ends the + // session at its next request instead of at its expiry. Sessions minted + // before the binding existed carry only the audit field and stay unbound. + if sessionInfo.IdentityProvider == icebergVendedCredentialsProvider && sessionInfo.BoundAccessKeyId != "" { + if errCode := iam.vendedSessionKeyLive(sessionInfo.ExternalUserId, sessionInfo.BoundAccessKeyId, sessionInfo.BoundAccessKeyFingerprint); errCode != s3err.ErrNone { + glog.V(2).Infof("vended STS session %s: identity %q or the access key it was vended for is gone, disabled or inactive", + sessionInfo.SessionId, sessionInfo.ExternalUserId) + return nil, nil, errCode + } + } + // Validate required credential fields if sessionInfo.Credentials.SecretAccessKey == "" { glog.Warningf("STS service returned empty SecretAccessKey in credentials") @@ -554,6 +570,38 @@ func (iam *IdentityAccessManagement) validateSTSSessionToken(r *http.Request, se return identity, cred, s3err.ErrNone } +// vendedSessionKeyLive answers ErrNone while owner is a known, enabled +// identity holding accessKey as an active, unexpired credential, and +// ErrInvalidAccessKeyID (as for an unknown key) otherwise. A fingerprint set +// at mint time must still match, so a key re-created under the same id does +// not revive sessions the deleted original had carried. One map lookup under +// the read lock. +func (iam *IdentityAccessManagement) vendedSessionKeyLive(owner, accessKey, fingerprint string) s3err.ErrorCode { + if owner == "" || accessKey == "" { + return s3err.ErrInvalidAccessKeyID + } + fingerprintKey := iam.stsSigningKey() + iam.m.RLock() + defer iam.m.RUnlock() + identity, ok := iam.nameToIdentity[owner] + if !ok || identity == nil || identity.Disabled { + return s3err.ErrInvalidAccessKeyID + } + for _, credential := range identity.Credentials { + if credential == nil || credential.AccessKey != accessKey { + continue + } + if credential.Status == iamAccessKeyStatusInactive || credential.isCredentialExpired() { + return s3err.ErrInvalidAccessKeyID + } + if fingerprint != "" && s3_constants.StoredCredentialFingerprint(fingerprintKey, accessKey, credential.SecretKey) != fingerprint { + return s3err.ErrInvalidAccessKeyID + } + return s3err.ErrNone + } + return s3err.ErrInvalidAccessKeyID +} + // calculateAndVerifySignature contains the core logic for creating the canonical request, // string-to-sign, and comparing the final signature. func calculateAndVerifySignature(secretKey, method, urlPath, queryStr string, extractedSignedHeaders http.Header, authInfo *v4AuthInfo) (string, s3err.ErrorCode) { @@ -874,6 +922,7 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(ctx context.Cont return nil, s3err.ErrSignatureDoesNotMatch } s3_constants.RecordAccessKeyId(ctx, cred.AccessKey) + s3_constants.RecordStoredAccessKeyId(ctx, cred.AccessKey, cred.SecretKey) return identity, s3err.ErrNone } diff --git a/weed/s3api/iceberg/handlers_oauth.go b/weed/s3api/iceberg/handlers_oauth.go index 8e6933c461..54391d04e4 100644 --- a/weed/s3api/iceberg/handlers_oauth.go +++ b/weed/s3api/iceberg/handlers_oauth.go @@ -324,28 +324,28 @@ func mintIcebergToken(identityName, accessKey, secret string, ttlSeconds int) (s // authenticateBearer validates a Bearer token from the Authorization header. // Returns the identity name, identity object, and whether auth succeeded. func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) { - identityName, identity, _, ok := s.authenticateBearerKey(r) + identityName, identity, _, _, ok := s.authenticateBearerKey(r) return identityName, identity, ok } // authenticateBearerKey is authenticateBearer that also returns the access key // the token was minted from (its verified access_key claim), so the catalog // can tell apart the holders of several keys on one identity. -func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, bool) { +func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, string, bool) { auth := r.Header.Get("Authorization") if auth == "" { - return "", nil, "", false + return "", nil, "", "", false } if !strings.HasPrefix(strings.ToLower(auth), "bearer ") { - return "", nil, "", false + return "", nil, "", "", false } tokenString := strings.TrimSpace(auth[7:]) if tokenString == "" { - return "", nil, "", false + return "", nil, "", "", false } if s.credentialValidator == nil { - return "", nil, "", false + return "", nil, "", "", false } // Parse the token without verification first to get the access key, @@ -355,18 +355,18 @@ func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, st _, _, err := parser.ParseUnverified(tokenString, unverified) if err != nil { glog.V(2).Infof("Iceberg OAuth: failed to parse token: %v", err) - return "", nil, "", false + return "", nil, "", "", false } if unverified.AccessKey == "" { - return "", nil, "", false + return "", nil, "", "", false } // Look up the credential by access key to get the signing key for verification identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey) if err != nil { glog.V(2).Infof("Iceberg OAuth: failed to get credential for access key: %v", err) - return "", nil, "", false + return "", nil, "", "", false } signingKey := deriveSigningKey(unverified.AccessKey, secretKey) @@ -379,12 +379,12 @@ func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, st }) if err != nil || !verified.Valid { glog.V(2).Infof("Iceberg OAuth: token verification failed: %v", err) - return "", nil, "", false + return "", nil, "", "", false } // The token's signing key is derived from this access key, so a verified // token was minted from it. - return identityName, identity, unverified.AccessKey, true + return identityName, identity, unverified.AccessKey, secretKey, true } // deriveSigningKey derives a signing key from the access key and secret using HMAC-SHA256. diff --git a/weed/s3api/iceberg/server.go b/weed/s3api/iceberg/server.go index 5ec8de2160..1849f2e6e8 100644 --- a/weed/s3api/iceberg/server.go +++ b/weed/s3api/iceberg/server.go @@ -269,10 +269,11 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { // The auth scheme is case-insensitive (RFC 7235), matching // authenticateBearer below. if strings.HasPrefix(strings.ToLower(r.Header.Get("Authorization")), "bearer ") { - if identityName, identity, accessKey, ok := s.authenticateBearerKey(r); ok { + if identityName, identity, accessKey, secretKey, ok := s.authenticateBearerKey(r); ok { ctx := r.Context() ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) + ctx = s3_constants.SetStoredAccessKeyIdInContext(ctx, accessKey, secretKey) if identity != nil { ctx = s3_constants.SetIdentityInContext(ctx, identity) } diff --git a/weed/s3api/lance/handlers_oauth.go b/weed/s3api/lance/handlers_oauth.go index 20e791221e..d348a5c200 100644 --- a/weed/s3api/lance/handlers_oauth.go +++ b/weed/s3api/lance/handlers_oauth.go @@ -147,27 +147,27 @@ func mintToken(identityName, accessKey, secret string, ttlSeconds int) (string, // authenticateBearer validates a Bearer token from the Authorization header. // Returns the identity name, identity object, and whether auth succeeded. func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) { - identityName, identity, _, ok := s.authenticateBearerKey(r) + identityName, identity, _, _, ok := s.authenticateBearerKey(r) return identityName, identity, ok } // authenticateBearerKey is authenticateBearer that also returns the access key // the token was minted from (its verified access_key claim). -func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, bool) { +func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, string, string, bool) { auth := r.Header.Get("Authorization") if auth == "" { - return "", nil, "", false + return "", nil, "", "", false } if !strings.HasPrefix(strings.ToLower(auth), "bearer ") { - return "", nil, "", false + return "", nil, "", "", false } tokenString := strings.TrimSpace(auth[7:]) if tokenString == "" { - return "", nil, "", false + return "", nil, "", "", false } if s.credentialValidator == nil { - return "", nil, "", false + return "", nil, "", "", false } // Parse the token without verification first to get the access key, @@ -177,17 +177,17 @@ func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, st _, _, err := parser.ParseUnverified(tokenString, unverified) if err != nil { glog.V(2).Infof("Lance OAuth: failed to parse token: %v", err) - return "", nil, "", false + return "", nil, "", "", false } if unverified.AccessKey == "" { - return "", nil, "", false + return "", nil, "", "", false } identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey) if err != nil { glog.V(2).Infof("Lance OAuth: failed to get credential for access key: %v", err) - return "", nil, "", false + return "", nil, "", "", false } signingKey := deriveSigningKey(unverified.AccessKey, secretKey) @@ -200,12 +200,12 @@ func (s *Server) authenticateBearerKey(r *http.Request) (string, interface{}, st }) if err != nil || !verified.Valid { glog.V(2).Infof("Lance OAuth: token verification failed: %v", err) - return "", nil, "", false + return "", nil, "", "", false } // The token's signing key is derived from this access key, so a verified // token was minted from it. - return identityName, identity, unverified.AccessKey, true + return identityName, identity, unverified.AccessKey, secretKey, true } // authenticateApiKey validates an x-api-key header carrying an S3 credential diff --git a/weed/s3api/lance/server.go b/weed/s3api/lance/server.go index d0cb813daa..53effee9e5 100644 --- a/weed/s3api/lance/server.go +++ b/weed/s3api/lance/server.go @@ -177,10 +177,11 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { // different error that clients cannot act on. // The auth scheme is case-insensitive (RFC 7235). if strings.HasPrefix(strings.ToLower(r.Header.Get("Authorization")), "bearer ") { - if identityName, identity, accessKey, ok := s.authenticateBearerKey(r); ok { + if identityName, identity, accessKey, secretKey, ok := s.authenticateBearerKey(r); ok { ctx := r.Context() ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) + ctx = s3_constants.SetStoredAccessKeyIdInContext(ctx, accessKey, secretKey) if identity != nil { ctx = s3_constants.SetIdentityInContext(ctx, identity) } @@ -195,10 +196,11 @@ func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { if apiKey := r.Header.Get("x-api-key"); apiKey != "" { if identityName, identity, ok := s.authenticateApiKey(apiKey); ok { - accessKey, _, _ := strings.Cut(apiKey, ":") + accessKey, secretKey, _ := strings.Cut(apiKey, ":") ctx := r.Context() ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) + ctx = s3_constants.SetStoredAccessKeyIdInContext(ctx, accessKey, secretKey) if identity != nil { ctx = s3_constants.SetIdentityInContext(ctx, identity) } diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index cf26ac8f84..a49d40220e 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -18,6 +18,9 @@ package s3_constants import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "net/http" "strings" "sync/atomic" @@ -355,6 +358,7 @@ const ( contextKeyIgnoredSessionToken contextKey = "s3-ignored-session-token" contextKeyOriginalRequestPath contextKey = "s3-original-request-path" contextKeyAccessKeyId contextKey = "s3-access-key-id" + contextKeyStoredAccessKeyId contextKey = "s3-stored-access-key-id" ) // identityHolder is a mutable container for the authenticated identity name, @@ -365,10 +369,37 @@ const ( // holder installed before authentication is shared across all copies, so the // name written by the inner handler is readable by the outer middleware. type identityHolder struct { - name atomic.Pointer[string] - principalArn atomic.Pointer[string] - identityClaim atomic.Pointer[string] - accessKeyId atomic.Pointer[string] + name atomic.Pointer[string] + principalArn atomic.Pointer[string] + identityClaim atomic.Pointer[string] + accessKeyId atomic.Pointer[string] + storedCredential atomic.Pointer[storedCredential] +} + +// storedCredential is the stored-identity credential a request authenticated +// with: the access key id and its secret, so a credential vendor can bind +// what it mints to this exact credential instance. +type storedCredential struct { + accessKeyId string + secretKey string +} + +// StoredCredentialFingerprint identifies one credential instance. The key id +// alone cannot: an operator can delete a key and re-create it under the same +// id with a different secret. The fingerprint travels inside the vended +// session token, so it is keyed with a server-held key — an unkeyed hash of +// the secret would hand the token holder an offline guessing oracle. +func StoredCredentialFingerprint(fingerprintKey []byte, accessKeyId, secretKey string) string { + if len(fingerprintKey) == 0 { + return "" + } + h := hmac.New(sha256.New, fingerprintKey) + h.Write([]byte("seaweedfs-stored-credential")) + h.Write([]byte{0}) + h.Write([]byte(accessKeyId)) + h.Write([]byte{0}) + h.Write([]byte(secretKey)) + return hex.EncodeToString(h.Sum(nil)[:16]) } // EnsureIdentityHolder attaches a mutable identity holder to the request context @@ -554,6 +585,70 @@ func AccessKeyIdFromContext(ctx context.Context) string { return "" } +// SetStoredAccessKeyIdInContext records the stored-identity credential the +// request authenticated with — set by an authenticator that verified the +// credential against the identity store, so a credential vendor can tell the +// caller apart from an STS session or OIDC user holding a key the store never +// issued. +func SetStoredAccessKeyIdInContext(ctx context.Context, accessKeyId, secretKey string) context.Context { + if accessKeyId == "" { + return ctx + } + sc := storedCredential{accessKeyId, secretKey} + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + h.storedCredential.Store(&sc) + } + return context.WithValue(ctx, contextKeyStoredAccessKeyId, sc) +} + +// RecordStoredAccessKeyId stores the stored-credential marker into the +// request's identity holder: a verifier calls it when the credential that +// matched resolves to a key held by a configured identity. A no-op without a +// holder (EnsureIdentityHolder) or without a key. +func RecordStoredAccessKeyId(ctx context.Context, accessKeyId, secretKey string) { + if ctx == nil || accessKeyId == "" { + return + } + sc := storedCredential{accessKeyId, secretKey} + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + h.storedCredential.Store(&sc) + } +} + +// StoredAccessKeyIdFromContext retrieves the stored-identity access key id the +// request authenticated with, or "" when it authenticated another way (STS +// session, OIDC bearer, anonymous). +func StoredAccessKeyIdFromContext(ctx context.Context) string { + sc, _ := storedCredentialFromContext(ctx) + return sc.accessKeyId +} + +// StoredCredentialFromContext retrieves the stored-identity access key id and +// secret the request authenticated with, or "", "" when it authenticated +// another way (STS session, OIDC bearer, anonymous). +func StoredCredentialFromContext(ctx context.Context) (string, string) { + sc, ok := storedCredentialFromContext(ctx) + if !ok { + return "", "" + } + return sc.accessKeyId, sc.secretKey +} + +func storedCredentialFromContext(ctx context.Context) (storedCredential, bool) { + if ctx == nil { + return storedCredential{}, false + } + if sc, ok := ctx.Value(contextKeyStoredAccessKeyId).(storedCredential); ok && sc.accessKeyId != "" { + return sc, true + } + if h, ok := ctx.Value(contextKeyIdentityHolder).(*identityHolder); ok && h != nil { + if sc := h.storedCredential.Load(); sc != nil && sc.accessKeyId != "" { + return *sc, true + } + } + return storedCredential{}, false +} + // IgnoreSessionTokenInContext marks the request's session token as tolerated // rather than authenticated: a statically configured access key signed the // request, so the token (which credential vendors like Unity Catalog emit on diff --git a/weed/s3api/s3_constants/header_test.go b/weed/s3api/s3_constants/header_test.go index de780d0d51..2d30f3e859 100644 --- a/weed/s3api/s3_constants/header_test.go +++ b/weed/s3api/s3_constants/header_test.go @@ -313,3 +313,20 @@ func TestGetIdentityNameFromContextWithoutHolder(t *testing.T) { t.Errorf("requester = %q, want admin", got) } } + +func TestStoredCredentialFingerprintIsKeyed(t *testing.T) { + key := []byte("test-signing-key-32-characters!!") + fp := StoredCredentialFingerprint(key, "AKIAEXAMPLE", "secret") + if fp == "" { + t.Fatal("fingerprint is empty with a key") + } + if again := StoredCredentialFingerprint(key, "AKIAEXAMPLE", "secret"); again != fp { + t.Error("fingerprint is not deterministic") + } + if other := StoredCredentialFingerprint([]byte("another-signing-key-padded-out"), "AKIAEXAMPLE", "secret"); other == fp { + t.Error("fingerprint ignores the server key, leaving the secret guessable") + } + if noKey := StoredCredentialFingerprint(nil, "AKIAEXAMPLE", "secret"); noKey != "" { + t.Error("an empty key must yield an empty fingerprint") + } +} diff --git a/weed/s3api/s3api_iceberg_credentials.go b/weed/s3api/s3api_iceberg_credentials.go index 50af7cdb99..967a5a79df 100644 --- a/weed/s3api/s3api_iceberg_credentials.go +++ b/weed/s3api/s3api_iceberg_credentials.go @@ -40,8 +40,11 @@ type VendedCredentials struct { // // The access key the caller authenticated with (from ctx) is recorded in the // session: a short hash of it ends the session name, so the audit log's -// requester_arn tells apart the holders of several keys on one identity, and -// the key id itself is carried in the session token. +// requester_arn tells apart the holders of several keys on one identity. When +// the caller is a stored identity holding that key, the key id is also carried +// in the session token, binding the session to it: the session ends when the +// key is deleted or deactivated, or its identity removed or disabled +// (validateSTSSessionToken). func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, bucket, prefix string, readOnly bool) (*VendedCredentials, error) { if s3a.icebergCredentialRole == "" { return nil, nil @@ -72,6 +75,19 @@ func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, buc Policy: &policy, SourceAccessKeyId: accessKey, } + // Bind the session to the caller's key when the caller authenticated with a + // stored identity's key — a marker its verifier records, so a caller that + // signed with an STS session key or OIDC token stays unbound even when its + // principal names a stored identity, whose session a binding would refuse + // on every request. The marker survives the key being revoked between + // authentication and this vend: the session then dies on its first request + // instead of living unbound until it expires. The binding travels under + // its own claim — SourceAccessKeyId is audit-only for compatibility with + // tokens minted before the binding existed. + if storedKey, storedSecret := s3_constants.StoredCredentialFromContext(ctx); storedKey != "" { + request.BoundAccessKeyId = storedKey + request.BoundAccessKeyFingerprint = s3_constants.StoredCredentialFingerprint(s3a.iam.stsSigningKey(), storedKey, storedSecret) + } if s3a.icebergCredentialDuration > 0 { duration := s3a.icebergCredentialDuration request.DurationSeconds = &duration @@ -94,10 +110,17 @@ func (s3a *S3ApiServer) VendTableCredentials(ctx context.Context, principal, buc } func (s3a *S3ApiServer) stsService() *sts.STSService { - if s3a.iam == nil || s3a.iam.iamIntegration == nil { + if s3a.iam == nil { return nil } - provider, ok := s3a.iam.iamIntegration.(IAMManagerProvider) + return s3a.iam.stsService() +} + +func (iam *IdentityAccessManagement) stsService() *sts.STSService { + if iam.iamIntegration == nil { + return nil + } + provider, ok := iam.iamIntegration.(IAMManagerProvider) if !ok { return nil } @@ -108,6 +131,13 @@ func (s3a *S3ApiServer) stsService() *sts.STSService { return manager.GetSTSService() } +func (iam *IdentityAccessManagement) stsSigningKey() []byte { + if s := iam.stsService(); s != nil && s.Config != nil { + return s.Config.SigningKey + } + return nil +} + // tablePrefixSessionPolicy narrows a session to one table's files plus the // bucket listing that clients need to resolve them. A read-only session drops // the write verbs: the catalog vends no more authority than the caller holds. diff --git a/weed/s3api/s3api_iceberg_session_name_test.go b/weed/s3api/s3api_iceberg_session_name_test.go index 9a615bef8d..2fd679d24d 100644 --- a/weed/s3api/s3api_iceberg_session_name_test.go +++ b/weed/s3api/s3api_iceberg_session_name_test.go @@ -76,12 +76,14 @@ func TestVendTableCredentialsRecordsTheCallersKey(t *testing.T) { s3a := &S3ApiServer{iam: iam} s3a.SetIcebergCredentialRole("arn:aws:iam::role/IcebergTableAccess", 3600) - for _, key := range []string{sharedKeyA, sharedKeyB, ""} { + for _, k := range []struct{ id, secret string }{{sharedKeyA, sharedSecretA}, {sharedKeyB, sharedSecretB}, {"", ""}} { + key := k.id ctx := context.Background() if key != "" { // As the catalog's auth does: the holder is installed, the verifier records into it. r := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "/", nil)) s3_constants.RecordAccessKeyId(r.Context(), key) + s3_constants.RecordStoredAccessKeyId(r.Context(), k.id, k.secret) ctx = r.Context() } creds, err := s3a.VendTableCredentials(ctx, "shared-user", "warehouse", "ns/t", false) diff --git a/weed/s3api/s3api_iceberg_vended_session_test.go b/weed/s3api/s3api_iceberg_vended_session_test.go new file mode 100644 index 0000000000..9b9398cf0f --- /dev/null +++ b/weed/s3api/s3api_iceberg_vended_session_test.go @@ -0,0 +1,234 @@ +package s3api + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/seaweedfs/seaweedfs/weed/iam/integration" + "github.com/seaweedfs/seaweedfs/weed/iam/policy" + "github.com/seaweedfs/seaweedfs/weed/iam/sts" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// vendingServer is an S3ApiServer whose catalog role trusts the vendor, with +// the identity of newSharedIdentityIAM (one identity, two keys). +func vendingServer(t *testing.T) *S3ApiServer { + t.Helper() + mgr := newTestIAMManager(t) + require.NoError(t, mgr.CreateRole(context.Background(), "", "IcebergTableAccess", &integration.RoleDefinition{ + RoleName: "IcebergTableAccess", + TrustPolicy: &policy.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy.Statement{{ + Effect: "Allow", + Principal: map[string]interface{}{"Federated": icebergVendedCredentialsProvider}, + Action: []string{"sts:AssumeRoleWithCredentials"}, + }}, + }, + })) + iam := newSharedIdentityIAM() + iam.SetIAMIntegration(NewS3IAMIntegration(mgr, "")) + s3a := &S3ApiServer{iam: iam} + s3a.SetIcebergCredentialRole("arn:aws:iam::role/IcebergTableAccess", 3600) + return s3a +} + +// vend mints table credentials for principal as if its request had been +// signed with accessKey ("" for a request that recorded none); a non-empty +// secret marks the credential as a stored identity's, as a verifier would. +func vend(t *testing.T, s3a *S3ApiServer, principal, accessKey, secret string) *VendedCredentials { + t.Helper() + ctx := context.Background() + if accessKey != "" { + ctx = s3_constants.SetAccessKeyIdInContext(ctx, accessKey) + } + if secret != "" { + ctx = s3_constants.SetStoredAccessKeyIdInContext(ctx, accessKey, secret) + } + creds, err := s3a.VendTableCredentials(ctx, principal, "warehouse", "ns/t", false) + require.NoError(t, err) + require.NotNil(t, creds) + return creds +} + +// use validates a vended session the way a data-path request carrying it is +// validated. +func use(t *testing.T, s3a *S3ApiServer, creds *VendedCredentials) s3err.ErrorCode { + t.Helper() + r, err := newTestRequest("GET", "https://example.com/warehouse/ns/t/data/f.parquet", 0, nil) + require.NoError(t, err) + _, _, errCode := s3a.iam.validateSTSSessionToken(r, creds.SessionToken, creds.AccessKeyID) + return errCode +} + +func sessionInfo(t *testing.T, s3a *S3ApiServer, creds *VendedCredentials) *sts.SessionInfo { + t.Helper() + info, err := s3a.stsService().ValidateSessionToken(context.Background(), creds.SessionToken) + require.NoError(t, err) + return info +} + +func TestVendedSessionEndsWithItsAccessKey(t *testing.T) { + s3a := vendingServer(t) + sessionA := vend(t, s3a, "shared-user", sharedKeyA, sharedSecretA) + sessionB := vend(t, s3a, "shared-user", sharedKeyB, sharedSecretB) + require.Equal(t, s3err.ErrNone, use(t, s3a, sessionA), "control: the session works while its key does") + require.Equal(t, s3err.ErrNone, use(t, s3a, sessionB)) + + identity := s3a.iam.nameToIdentity["shared-user"] + identity.Credentials[0].Status = iamAccessKeyStatusInactive + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, sessionA), "key A deactivated") + assert.Equal(t, s3err.ErrNone, use(t, s3a, sessionB), "the session vended through key B keeps working") + + identity.Credentials[0].Status = iamAccessKeyStatusActive + assert.Equal(t, s3err.ErrNone, use(t, s3a, sessionA), "key A reactivated") + + identity.Credentials[0].Expiration = time.Now().Add(-time.Minute).Unix() + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, sessionA), "key A expired") + identity.Credentials[0].Expiration = 0 + + identity.Credentials = identity.Credentials[1:] + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, sessionA), "key A deleted") + assert.Equal(t, s3err.ErrNone, use(t, s3a, sessionB), "the session vended through key B keeps working") +} + +func TestVendedSessionEndsWithItsIdentity(t *testing.T) { + s3a := vendingServer(t) + session := vend(t, s3a, "shared-user", sharedKeyA, sharedSecretA) + require.Equal(t, s3err.ErrNone, use(t, s3a, session)) + + s3a.iam.nameToIdentity["shared-user"].Disabled = true + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, session), "identity disabled") + s3a.iam.nameToIdentity["shared-user"].Disabled = false + assert.Equal(t, s3err.ErrNone, use(t, s3a, session), "identity enabled again") + + delete(s3a.iam.nameToIdentity, "shared-user") + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, session), "identity deleted") +} + +// A caller that is not a stored identity (a caller signing with another STS +// session's key, an OIDC user, a request that recorded no key) still gets +// credentials, unbound as before: binding them would refuse every request +// made with them. +func TestVendedSessionIsBoundOnlyForAStoredIdentitysKey(t *testing.T) { + s3a := vendingServer(t) + control := sessionInfo(t, s3a, vend(t, s3a, "shared-user", sharedKeyA, sharedSecretA)) + assert.Equal(t, sharedKeyA, control.SourceAccessKeyId, "control") + assert.Equal(t, sharedKeyA, control.BoundAccessKeyId, "control") + + for _, tc := range []struct{ principal, key, why string }{ + {"47ad4828c45b3f337bc3146081ba8f0f", "ASIATEMPORARYKEY0001", "an STS session's subject and temporary key"}, + {"shared-user", "ASIATEMPORARYKEY0001", "an STS session whose subject names a stored identity"}, + {"alice@example.com", "", "an OIDC user"}, + {"shared-user", "", "no recorded key"}, + } { + session := vend(t, s3a, tc.principal, tc.key, "") + info := sessionInfo(t, s3a, session) + assert.Equal(t, tc.key, info.SourceAccessKeyId, tc.why) + assert.Empty(t, info.BoundAccessKeyId, tc.why) + assert.Equal(t, s3err.ErrNone, use(t, s3a, session), tc.why) + } +} + +// Tokens minted before the binding existed record the caller's key under the +// audit claim only. Validating them as bound would end live table credentials +// at upgrade — a chained STS caller's key can never resolve to a stored +// credential. They stay valid until expiry, as they always did. +func TestLegacyVendedSessionStaysUnbound(t *testing.T) { + iam := newSharedIdentityIAM() + iam.iamIntegration = &MockIAMIntegration{ + validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) { + return &sts.SessionInfo{ + SessionId: "sid", + AssumedRoleUser: "arn:aws:sts::assumed-role/Reader/session", + Principal: "arn:aws:sts::assumed-role/Reader/session", + IdentityProvider: icebergVendedCredentialsProvider, + ExternalUserId: "someone-not-in-the-identity-table", + SourceAccessKeyId: "ASIATEMPORARYKEY0001", + Credentials: &sts.Credentials{AccessKeyId: "ASIALEGACY", SecretAccessKey: "secret"}, + ExpiresAt: time.Now().Add(time.Hour), + }, nil + }, + } + r, err := newTestRequest("GET", "https://example.com/bucket/f.parquet", 0, nil) + require.NoError(t, err) + _, _, errCode := iam.validateSTSSessionToken(r, "token", "ASIALEGACY") + assert.Equal(t, s3err.ErrNone, errCode) +} + +// A key re-created under the same id with a different secret does not revive +// sessions bound to the deleted original: the fingerprint recorded at mint +// time no longer matches. +func TestRecreatedKeyDoesNotReviveVendedSession(t *testing.T) { + s3a := vendingServer(t) + session := vend(t, s3a, "shared-user", sharedKeyA, sharedSecretA) + require.Equal(t, s3err.ErrNone, use(t, s3a, session)) + + identity := s3a.iam.nameToIdentity["shared-user"] + identity.Credentials[0] = &Credential{AccessKey: sharedKeyA, SecretKey: "recreated-secret"} + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, session), "same id, different secret") + + identity.Credentials[0] = &Credential{AccessKey: sharedKeyA, SecretKey: sharedSecretA} + assert.Equal(t, s3err.ErrNone, use(t, s3a, session), "the identical credential is indistinguishable") +} + +// A session for a stored identity stays bound even when the key no longer +// resolves at vend time — revoked between the request's authentication and +// the vend, or held by a different identity now. The session dies on its +// first request instead of living unbound until it expires. +func TestVendedSessionStaysBoundWhenTheKeyIsGoneAtVend(t *testing.T) { + s3a := vendingServer(t) + + identity := s3a.iam.nameToIdentity["shared-user"] + identity.Credentials = identity.Credentials[1:] // sharedKeyA revoked mid-request + session := vend(t, s3a, "shared-user", sharedKeyA, sharedSecretA) + assert.Equal(t, sharedKeyA, sessionInfo(t, s3a, session).SourceAccessKeyId, "still bound") + assert.Equal(t, s3err.ErrInvalidAccessKeyID, use(t, s3a, session), "the dead key's session is refused") +} + +// Only sessions the catalog vended are bound; any other STS session (web +// identity, AssumeRole) is validated as before. +func TestOtherSessionsAreNotBoundToAKey(t *testing.T) { + iam := newSharedIdentityIAM() + iam.iamIntegration = &MockIAMIntegration{ + validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) { + return &sts.SessionInfo{ + SessionId: "sid", + AssumedRoleUser: "arn:aws:sts::assumed-role/Reader/session", + Principal: "arn:aws:sts::assumed-role/Reader/session", + IdentityProvider: "oidc-provider", + ExternalUserId: "someone-not-in-the-identity-table", + SourceAccessKeyId: "AKIAGONE", + Credentials: &sts.Credentials{AccessKeyId: "ASIAWEBID", SecretAccessKey: "secret"}, + ExpiresAt: time.Now().Add(time.Hour), + }, nil + }, + } + r, err := newTestRequest("GET", "https://example.com/bucket/f.parquet", 0, nil) + require.NoError(t, err) + _, _, errCode := iam.validateSTSSessionToken(r, "token", "ASIAWEBID") + assert.Equal(t, s3err.ErrNone, errCode) +} + +// The check on the request path is one read-locked map lookup and a scan of +// one identity's keys. +func BenchmarkVendedSessionKeyLive(b *testing.B) { + iam := newSharedIdentityIAM() + for i := 0; i < 1000; i++ { + name := fmt.Sprintf("user-%04d", i) + iam.nameToIdentity[name] = &Identity{Name: name, Credentials: []*Credential{{AccessKey: name}}} + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if iam.vendedSessionKeyLive("shared-user", sharedKeyB, "") != s3err.ErrNone { + b.Fatal("refused") + } + } +}