Files
aether/pattern.go
Hugo Nijhuis ef73fb6bfd
All checks were successful
CI / build (pull_request) Successful in 19s
CI / build (push) Successful in 39s
Add namespace event filtering (SubscribeWithFilter)
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>
2026-01-10 23:45:57 +01:00

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
}