Adds support for filtering events by type or actor pattern within namespace subscriptions. Key changes: - Add SubscriptionFilter type with EventTypes and ActorPattern fields - Add SubscribeWithFilter to EventBroadcaster interface - Implement filtering in EventBus with full wildcard pattern support preserved - Implement filtering in NATSEventBus (server-side namespace, client-side filters) - Add MatchActorPattern function for actor ID pattern matching - Add comprehensive unit tests for all filtering scenarios Filter Processing: - EventTypes: Event must match at least one type in the list (OR within types) - ActorPattern: Event's ActorID must match the pattern (supports * and > wildcards) - Multiple filters are combined with AND logic This implementation works alongside the existing wildcard subscription support: - Namespace wildcards (* and >) work with event filters - Filters are applied after namespace pattern matching - Metrics are properly recorded for filtered subscriptions Closes #21 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
198 lines
6.4 KiB
Go
198 lines
6.4 KiB
Go
package aether
|
|
|
|
import "strings"
|
|
|
|
// MatchNamespacePattern checks if a namespace matches a pattern.
|
|
// Patterns follow NATS subject matching conventions where tokens are separated by dots:
|
|
// - "*" matches exactly one token (any sequence without ".")
|
|
// - ">" matches one or more tokens (only valid at the end of a pattern)
|
|
// - Exact strings match exactly
|
|
//
|
|
// Examples:
|
|
// - "tenant-a" matches "tenant-a" (exact match)
|
|
// - "*" matches any single-token namespace like "tenant-a" or "production"
|
|
// - ">" matches any namespace with one or more tokens
|
|
// - "prod.*" matches "prod.tenant", "prod.orders" (but not "prod.tenant.orders")
|
|
// - "prod.>" matches "prod.tenant", "prod.tenant.orders", "prod.a.b.c"
|
|
// - "*.tenant.*" matches "prod.tenant.orders", "staging.tenant.events"
|
|
//
|
|
// Security Considerations:
|
|
// Wildcard subscriptions provide cross-namespace visibility. Use with caution:
|
|
// - "*" or ">" patterns receive events from ALL matching namespaces
|
|
// - This bypasses namespace isolation for the subscriber
|
|
// - Only grant wildcard subscription access to trusted system components
|
|
// - Consider auditing wildcard subscription usage
|
|
// - For multi-tenant systems, wildcard access should be restricted to admin/ops
|
|
// - Use the most specific pattern possible to minimize exposure
|
|
func MatchNamespacePattern(pattern, namespace string) bool {
|
|
// Empty pattern matches nothing
|
|
if pattern == "" {
|
|
return false
|
|
}
|
|
|
|
// ">" matches everything when used alone
|
|
if pattern == ">" {
|
|
return namespace != ""
|
|
}
|
|
|
|
patternTokens := strings.Split(pattern, ".")
|
|
namespaceTokens := strings.Split(namespace, ".")
|
|
|
|
return matchTokens(patternTokens, namespaceTokens)
|
|
}
|
|
|
|
// matchTokens recursively matches pattern tokens against namespace tokens
|
|
func matchTokens(patternTokens, namespaceTokens []string) bool {
|
|
// If pattern is exhausted, namespace must also be exhausted
|
|
if len(patternTokens) == 0 {
|
|
return len(namespaceTokens) == 0
|
|
}
|
|
|
|
patternToken := patternTokens[0]
|
|
|
|
// ">" matches one or more remaining tokens (must be last pattern token)
|
|
if patternToken == ">" {
|
|
// ">" requires at least one token to match
|
|
return len(namespaceTokens) >= 1
|
|
}
|
|
|
|
// If namespace is exhausted but pattern has more tokens, no match
|
|
if len(namespaceTokens) == 0 {
|
|
return false
|
|
}
|
|
|
|
namespaceToken := namespaceTokens[0]
|
|
|
|
// "*" matches exactly one token
|
|
if patternToken == "*" {
|
|
return matchTokens(patternTokens[1:], namespaceTokens[1:])
|
|
}
|
|
|
|
// Exact match required
|
|
if patternToken == namespaceToken {
|
|
return matchTokens(patternTokens[1:], namespaceTokens[1:])
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// IsWildcardPattern returns true if the pattern contains wildcards (* or >).
|
|
// Wildcard patterns can match multiple namespaces and bypass namespace isolation.
|
|
func IsWildcardPattern(pattern string) bool {
|
|
return strings.Contains(pattern, "*") || strings.Contains(pattern, ">")
|
|
}
|
|
|
|
// SubscriptionFilter defines optional filters for event subscriptions.
|
|
// All configured filters are combined with AND logic - an event must match
|
|
// all specified criteria to be delivered to the subscriber.
|
|
//
|
|
// Filter Processing:
|
|
// - EventTypes: Event must have an EventType matching at least one in the list (OR within types)
|
|
// - ActorPattern: Event's ActorID must match the pattern (supports * and > wildcards)
|
|
//
|
|
// Filtering is applied client-side in the EventBus. For NATSEventBus, namespace-level
|
|
// filtering uses NATS subject patterns, while EventTypes and ActorPattern filtering
|
|
// happens after message receipt.
|
|
type SubscriptionFilter struct {
|
|
// EventTypes filters events by type. Empty slice means all event types.
|
|
// If specified, only events with an EventType in this list are delivered.
|
|
// Example: []string{"OrderPlaced", "OrderShipped"} receives only those event types.
|
|
EventTypes []string
|
|
|
|
// ActorPattern filters events by actor ID pattern. Empty string means all actors.
|
|
// Supports NATS-style wildcards:
|
|
// - "*" matches a single token (e.g., "order-*" matches "order-123", "order-456")
|
|
// - ">" matches one or more tokens (e.g., "order.>" matches "order.us.123", "order.eu.456")
|
|
// Example: "order-*" receives events only for actors starting with "order-"
|
|
ActorPattern string
|
|
}
|
|
|
|
// IsEmpty returns true if no filters are configured.
|
|
func (f *SubscriptionFilter) IsEmpty() bool {
|
|
return len(f.EventTypes) == 0 && f.ActorPattern == ""
|
|
}
|
|
|
|
// Matches returns true if the event matches all configured filters.
|
|
// An empty filter matches all events.
|
|
func (f *SubscriptionFilter) Matches(event *Event) bool {
|
|
if event == nil {
|
|
return false
|
|
}
|
|
|
|
// Check event type filter
|
|
if len(f.EventTypes) > 0 {
|
|
typeMatch := false
|
|
for _, et := range f.EventTypes {
|
|
if event.EventType == et {
|
|
typeMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !typeMatch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check actor pattern filter
|
|
if f.ActorPattern != "" {
|
|
if !MatchActorPattern(f.ActorPattern, event.ActorID) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MatchActorPattern checks if an actor ID matches a pattern.
|
|
// Uses the same matching logic as MatchNamespacePattern for consistency.
|
|
//
|
|
// Patterns:
|
|
// - "*" matches a single token (e.g., "order-*" matches "order-123")
|
|
// - ">" matches one or more tokens (e.g., "order.>" matches "order.us.east")
|
|
// - Exact strings match exactly (e.g., "order-123" matches only "order-123")
|
|
//
|
|
// Note: For simple prefix matching without dots (e.g., "order-*" matching "order-123"),
|
|
// this uses simplified matching where "*" matches any remaining characters in a token.
|
|
func MatchActorPattern(pattern, actorID string) bool {
|
|
// Empty pattern matches nothing
|
|
if pattern == "" {
|
|
return false
|
|
}
|
|
|
|
// Empty actor ID matches nothing except ">"
|
|
if actorID == "" {
|
|
return false
|
|
}
|
|
|
|
// If pattern contains dots, use token-based matching (same as namespace)
|
|
if strings.Contains(pattern, ".") || strings.Contains(actorID, ".") {
|
|
return MatchNamespacePattern(pattern, actorID)
|
|
}
|
|
|
|
// Simple matching for non-tokenized patterns
|
|
// ">" matches any non-empty actor ID
|
|
if pattern == ">" {
|
|
return true
|
|
}
|
|
|
|
// "*" matches any single-token actor ID (no dots)
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
|
|
// Check for suffix wildcard (e.g., "order-*")
|
|
if strings.HasSuffix(pattern, "*") {
|
|
prefix := strings.TrimSuffix(pattern, "*")
|
|
return strings.HasPrefix(actorID, prefix)
|
|
}
|
|
|
|
// Check for suffix multi-match (e.g., "order->")
|
|
if strings.HasSuffix(pattern, ">") {
|
|
prefix := strings.TrimSuffix(pattern, ">")
|
|
return strings.HasPrefix(actorID, prefix)
|
|
}
|
|
|
|
// Exact match
|
|
return pattern == actorID
|
|
}
|