diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index 9ce4701bc8..f9fc01d57c 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -27,15 +27,58 @@ const maxPoliciesForEvaluation = 1024 // IAMManager orchestrates all IAM components type IAMManager struct { - stsService *sts.STSService - policyEngine *policy.PolicyEngine - roleStore RoleStore - userStore UserStore - oidcProviderStore OIDCProviderStore - filerAddressProvider func() string // Function to get current filer address - initialized bool - runtimePolicyMu sync.Mutex - runtimePolicyNames map[string]struct{} + stsService *sts.STSService + policyEngine *policy.PolicyEngine + roleStore RoleStore + userStore UserStore + oidcProviderStore OIDCProviderStore + revocationStore SessionRevocationStore + filerAddressProvider func() string // Function to get current filer address + initialized bool + runtimePolicyMu sync.Mutex + runtimePolicyNames map[string]struct{} +} + +// SetSessionRevocationStore configures the per-session revocation list. When +// nil, RevokeSession returns an error and IsSessionRevoked is a no-op (every +// session is considered live until natural expiry). Operators who want +// revocation must wire a store explicitly. +func (m *IAMManager) SetSessionRevocationStore(store SessionRevocationStore) { + m.revocationStore = store +} + +// RevokeSession marks a session as revoked using its JTI (which equals the +// session ID for STS-issued tokens). +func (m *IAMManager) RevokeSession(ctx context.Context, jti string, expiresAt time.Time, reason string) error { + if m.revocationStore == nil { + return fmt.Errorf("session revocation store not configured") + } + if jti == "" { + return fmt.Errorf("jti cannot be empty") + } + return m.revocationStore.Revoke(ctx, m.getFilerAddress(), &RevocationEntry{ + JTI: jti, + ExpiresAt: expiresAt, + Reason: reason, + }) +} + +// IsSessionRevoked returns true if the given JTI has been revoked. Returns +// false (with nil error) when no revocation store is configured. +func (m *IAMManager) IsSessionRevoked(ctx context.Context, jti string) (bool, error) { + if m.revocationStore == nil || jti == "" { + return false, nil + } + return m.revocationStore.IsRevoked(ctx, m.getFilerAddress(), jti) +} + +// PurgeRevokedSessions removes revocation entries whose underlying session +// has already expired. Safe to call on a cron schedule. +func (m *IAMManager) PurgeRevokedSessions(ctx context.Context) (int, error) { + if m.revocationStore == nil { + return 0, nil + } + return m.revocationStore.Purge(ctx, m.getFilerAddress(), time.Now().UTC()) } // SetOIDCProviderStore configures the IAM-managed OIDC provider store. When @@ -915,6 +958,18 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest if err != nil { return false, fmt.Errorf("invalid session: %w", err) } + // Reject sessions whose JTI has been added to the revocation + // blocklist. SessionId == JTI for STS-issued tokens; this lookup + // is hot, so the store implementation must be O(1). + if sessionInfo != nil && sessionInfo.SessionId != "" { + revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId) + if rerr != nil { + return false, fmt.Errorf("revocation check failed: %w", rerr) + } + if revoked { + return false, fmt.Errorf("session has been revoked") + } + } } } diff --git a/weed/iam/integration/session_revocation.go b/weed/iam/integration/session_revocation.go new file mode 100644 index 0000000000..4b584de9a4 --- /dev/null +++ b/weed/iam/integration/session_revocation.go @@ -0,0 +1,257 @@ +package integration + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "google.golang.org/grpc" +) + +// RevocationEntry is one row in the revocation list. ExpiresAt is set to the +// session's natural expiration so the store can garbage-collect entries that +// can no longer be reused. +type RevocationEntry struct { + JTI string `json:"jti"` + RevokedAt time.Time `json:"revokedAt"` + ExpiresAt time.Time `json:"expiresAt"` + Reason string `json:"reason,omitempty"` +} + +// SessionRevocationStore is the per-deployment blocklist of revoked sessions. +// Implementations must be safe for concurrent use; the IsRevoked path is hot +// (checked on every signed request) and must not block on slow IO. +type SessionRevocationStore interface { + Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error + IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error) + Purge(ctx context.Context, filerAddress string, before time.Time) (int, error) +} + +// MemorySessionRevocationStore keeps the blocklist in process memory. Suitable +// for single-node deployments and tests; for HA, swap in the filer-backed +// implementation so revocations propagate across `weed` instances. +type MemorySessionRevocationStore struct { + mu sync.RWMutex + entries map[string]*RevocationEntry +} + +// NewMemorySessionRevocationStore returns an empty in-memory blocklist. +func NewMemorySessionRevocationStore() *MemorySessionRevocationStore { + return &MemorySessionRevocationStore{entries: make(map[string]*RevocationEntry)} +} + +func (m *MemorySessionRevocationStore) Revoke(ctx context.Context, _ string, entry *RevocationEntry) error { + if entry == nil || entry.JTI == "" { + return fmt.Errorf("entry with JTI is required") + } + m.mu.Lock() + defer m.mu.Unlock() + cp := *entry + if cp.RevokedAt.IsZero() { + cp.RevokedAt = time.Now().UTC() + } + m.entries[entry.JTI] = &cp + return nil +} + +func (m *MemorySessionRevocationStore) IsRevoked(ctx context.Context, _ string, jti string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + _, ok := m.entries[jti] + return ok, nil +} + +func (m *MemorySessionRevocationStore) Purge(ctx context.Context, _ string, before time.Time) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + count := 0 + for k, v := range m.entries { + if !v.ExpiresAt.IsZero() && v.ExpiresAt.Before(before) { + delete(m.entries, k) + count++ + } + } + return count, nil +} + +// FilerSessionRevocationStore persists entries under a filer directory. +// Files are named after the JTI so IsRevoked is a single LookupDirectoryEntry +// call — O(1) on a name-indexed filer. Purge enumerates the directory; that +// cost is operator-controlled (cron-driven) so the hot path stays cheap. +type FilerSessionRevocationStore struct { + grpcDialOption grpc.DialOption + basePath string + filerAddressProvider func() string +} + +// NewFilerSessionRevocationStore returns a filer-backed blocklist store. +// Default basePath `/etc/iam/revoked-sessions` aligns with the other IAM +// directories and is safe to back up alongside roles + providers. +func NewFilerSessionRevocationStore(config map[string]interface{}, filerAddressProvider func() string) *FilerSessionRevocationStore { + store := &FilerSessionRevocationStore{ + basePath: "/etc/iam/revoked-sessions", + filerAddressProvider: filerAddressProvider, + } + if config != nil { + if bp, ok := config["basePath"].(string); ok && bp != "" { + store.basePath = strings.TrimSuffix(bp, "/") + } + } + glog.V(2).Infof("Initialized FilerSessionRevocationStore with basePath %s", store.basePath) + return store +} + +func (f *FilerSessionRevocationStore) resolveFilerAddress(filerAddress string) string { + if filerAddress != "" { + return filerAddress + } + if f.filerAddressProvider != nil { + return f.filerAddressProvider() + } + return "" +} + +// fileName hashes the JTI before using it as a filename. RevokeSession is +// an exported API that accepts an arbitrary string; even though every +// SeaweedFS-issued session id is a safe random token, an external caller +// could pass "../../etc/passwd" or similar. Hashing produces a fixed-width +// hex name that's both filesystem-safe and stable, so the lookup-on-revoke +// path still finds the right entry. +func (f *FilerSessionRevocationStore) fileName(jti string) string { + sum := sha1.Sum([]byte(jti)) + return hex.EncodeToString(sum[:]) + ".json" +} + +func (f *FilerSessionRevocationStore) Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error { + filerAddress = f.resolveFilerAddress(filerAddress) + if filerAddress == "" { + return fmt.Errorf("filer address is required") + } + if entry == nil || entry.JTI == "" { + return fmt.Errorf("entry with JTI is required") + } + if entry.RevokedAt.IsZero() { + entry.RevokedAt = time.Now().UTC() + } + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return fmt.Errorf("marshal revocation entry: %v", err) + } + return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { + _, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{ + Directory: f.basePath, + Entry: &filer_pb.Entry{ + Name: f.fileName(entry.JTI), + IsDirectory: false, + Attributes: &filer_pb.FuseAttributes{ + Mtime: time.Now().Unix(), + Crtime: time.Now().Unix(), + FileMode: uint32(0o600), + }, + Content: data, + }, + }) + if err != nil { + return fmt.Errorf("revoke %s: %v", entry.JTI, err) + } + return nil + }) +} + +func (f *FilerSessionRevocationStore) IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error) { + filerAddress = f.resolveFilerAddress(filerAddress) + if filerAddress == "" { + return false, fmt.Errorf("filer address is required") + } + revoked := false + err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { + resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{ + Directory: f.basePath, + Name: f.fileName(jti), + }) + if err != nil { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no such") { + return nil + } + return err + } + revoked = resp.Entry != nil + return nil + }) + return revoked, err +} + +func (f *FilerSessionRevocationStore) Purge(ctx context.Context, filerAddress string, before time.Time) (int, error) { + filerAddress = f.resolveFilerAddress(filerAddress) + if filerAddress == "" { + return 0, fmt.Errorf("filer address is required") + } + count := 0 + err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { + // Stream-paginate the directory: ListEntriesRequest has no built-in + // "page until done" semantics, so we use StartFromFileName to walk + // the directory in chunks and avoid the previous hardcoded 10k cap. + const pageSize = 1000 + startFrom := "" + for { + stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{ + Directory: f.basePath, + Limit: pageSize, + StartFromFileName: startFrom, + InclusiveStartFrom: false, + }) + if err != nil { + return fmt.Errorf("list revocation entries: %w", err) + } + lastName := "" + pageCount := 0 + for { + resp, recvErr := stream.Recv() + if recvErr != nil { + if errors.Is(recvErr, io.EOF) { + break + } + return fmt.Errorf("recv revocation entry: %w", recvErr) + } + if resp.Entry == nil || resp.Entry.IsDirectory { + continue + } + lastName = resp.Entry.Name + pageCount++ + var entry RevocationEntry + if err := json.Unmarshal(resp.Entry.Content, &entry); err != nil { + continue + } + if entry.ExpiresAt.IsZero() || entry.ExpiresAt.After(before) { + continue + } + if _, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{ + Directory: f.basePath, + Name: resp.Entry.Name, + IsDeleteData: true, + }); err == nil { + count++ + } + } + if pageCount < pageSize { + return nil + } + startFrom = lastName + } + }) + return count, err +} + +func (f *FilerSessionRevocationStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error { + return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn) +} diff --git a/weed/iam/integration/session_revocation_test.go b/weed/iam/integration/session_revocation_test.go new file mode 100644 index 0000000000..69c87b09ac --- /dev/null +++ b/weed/iam/integration/session_revocation_test.go @@ -0,0 +1,94 @@ +package integration + +import ( + "context" + "testing" + "time" +) + +func TestMemoryRevocationStoreCRUD(t *testing.T) { + ctx := context.Background() + store := NewMemorySessionRevocationStore() + + if revoked, _ := store.IsRevoked(ctx, "", "abc"); revoked { + t.Fatal("empty store should report not revoked") + } + + if err := store.Revoke(ctx, "", &RevocationEntry{JTI: "abc", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatalf("revoke: %v", err) + } + if revoked, _ := store.IsRevoked(ctx, "", "abc"); !revoked { + t.Fatal("expected revoked") + } + // Different JTIs are independent. + if revoked, _ := store.IsRevoked(ctx, "", "xyz"); revoked { + t.Fatal("xyz should not be revoked") + } +} + +func TestMemoryRevocationStoreRejectsBadInput(t *testing.T) { + store := NewMemorySessionRevocationStore() + if err := store.Revoke(context.Background(), "", nil); err == nil { + t.Fatal("expected error for nil entry") + } + if err := store.Revoke(context.Background(), "", &RevocationEntry{}); err == nil { + t.Fatal("expected error for empty JTI") + } +} + +func TestMemoryRevocationStorePurge(t *testing.T) { + ctx := context.Background() + store := NewMemorySessionRevocationStore() + now := time.Now() + + must := func(jti string, expiresAt time.Time) { + if err := store.Revoke(ctx, "", &RevocationEntry{JTI: jti, ExpiresAt: expiresAt}); err != nil { + t.Fatalf("revoke %s: %v", jti, err) + } + } + must("expired-1", now.Add(-2*time.Hour)) + must("expired-2", now.Add(-time.Minute)) + must("future-1", now.Add(time.Hour)) + + count, err := store.Purge(ctx, "", now) + if err != nil { + t.Fatalf("purge: %v", err) + } + if count != 2 { + t.Fatalf("expected 2 purged, got %d", count) + } + if revoked, _ := store.IsRevoked(ctx, "", "expired-1"); revoked { + t.Fatal("expired-1 should be gone") + } + if revoked, _ := store.IsRevoked(ctx, "", "future-1"); !revoked { + t.Fatal("future-1 should still be revoked") + } +} + +func TestIAMManagerRevocationDefaultIsNoop(t *testing.T) { + mgr := NewIAMManager() + // Without SetSessionRevocationStore, revocation is a no-op. + if got, err := mgr.IsSessionRevoked(context.Background(), "anything"); err != nil || got { + t.Fatalf("expected (false, nil); got (%v, %v)", got, err) + } + if err := mgr.RevokeSession(context.Background(), "abc", time.Now().Add(time.Hour), "test"); err == nil { + t.Fatal("expected error when no store configured") + } +} + +func TestIAMManagerRevocationFlow(t *testing.T) { + mgr := NewIAMManager() + mgr.SetSessionRevocationStore(NewMemorySessionRevocationStore()) + + jti := "session-1" + if err := mgr.RevokeSession(context.Background(), jti, time.Now().Add(time.Hour), "logout"); err != nil { + t.Fatalf("RevokeSession: %v", err) + } + revoked, err := mgr.IsSessionRevoked(context.Background(), jti) + if err != nil { + t.Fatalf("IsSessionRevoked: %v", err) + } + if !revoked { + t.Fatal("expected revoked") + } +} diff --git a/weed/iam/sts/session_claims.go b/weed/iam/sts/session_claims.go index 4b00b4eb9e..ede04b2a3a 100644 --- a/weed/iam/sts/session_claims.go +++ b/weed/iam/sts/session_claims.go @@ -79,6 +79,10 @@ func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSess 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,