diff --git a/VERSION b/VERSION index 22042876..fc5d63b1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.22T18.35.16.272Z.0cc53c87.berickson.20260122.pmss.rulesets.argument +0.1.0+2026.01.26T17.51.31.713Z.b83cda8e.berickson.20260126.blacklist.by.domain diff --git a/docs/concepts/events.md b/docs/concepts/events.md index e2f49301..d4b1d12d 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -129,3 +129,34 @@ authentication, safety checks, and analytics while keeping the event format itself lightweight. Clients only need to agree on the JSON structure of events, while the server handles durability and routing responsibilities on their behalf. + +## Configuring domain-based event blacklisting + +Incoming events can be blacklisted through PMSS rules so that specific domains +either continue streaming, are told to retry later, or drop events entirely. +The `blacklist_event_action` setting controls the action and defaults to +`TRANSMIT`. Define rules under the `incoming_events` namespace and include a +`domain` attribute to scope the behavior per organization. When the action is +`MAINTAIN`, the `blacklist_time_limit` setting controls whether the client +should wait a short time or stop sending forever. + +```pmss +incoming_events { + blacklist_event_action: TRANSMIT; +} + +incoming_events[domain="example.org"] { + blacklist_event_action: DROP; +} + +incoming_events[domain="pilot.example.edu"] { + blacklist_event_action: MAINTAIN; + blacklist_time_limit: DAYS; +} +``` + +When a client connects, the server extracts a candidate domain from the event +payload and uses it to resolve the `blacklist_event_action` setting. If +a rule returns `DROP`, the client is instructed to stop sending events. +`MAINTAIN` asks the client to retain events and retry after a delay (as defined +by `blacklist_time_limit`), while `TRANSMIT` streams events normally. diff --git a/docs/reference/system_settings.md b/docs/reference/system_settings.md index 6333264d..354a2784 100644 --- a/docs/reference/system_settings.md +++ b/docs/reference/system_settings.md @@ -137,6 +137,13 @@ runtime. | `event_auth.hash_identify` | Enables hash-based identity hints (e.g., `/page#user=alice`) for one-off experiments. | disabled | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | | `event_auth.testcase_auth` | Allows automated tests to tag events with deterministic user IDs. | disabled | [`learning_observer/learning_observer/auth/events.py`](../../learning_observer/learning_observer/auth/events.py) | +### Incoming events blacklist (`incoming_events` namespace) + +| YAML path | Description | Default | Used in | +| --- | --- | --- | --- | +| `incoming_events.blacklist_event_action` | Action to take for incoming events (`TRANSMIT`, `MAINTAIN`, or `DROP`) when blacklist rules match. | `TRANSMIT` | [`learning_observer/learning_observer/blacklist.py`](../../learning_observer/learning_observer/blacklist.py) | +| `incoming_events.blacklist_time_limit` | Time limit to return when `blacklist_event_action` is `MAINTAIN` (`PERMANENT`, `MINUTES`, or `DAYS`). | `MINUTES` | [`learning_observer/learning_observer/blacklist.py`](../../learning_observer/learning_observer/blacklist.py) | + ## Modules Modules can define their own PMSS namespaces under `modules.`. diff --git a/learning_observer/VERSION b/learning_observer/VERSION index 22042876..fc5d63b1 100644 --- a/learning_observer/VERSION +++ b/learning_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.01.22T18.35.16.272Z.0cc53c87.berickson.20260122.pmss.rulesets.argument +0.1.0+2026.01.26T17.51.31.713Z.b83cda8e.berickson.20260126.blacklist.by.domain diff --git a/learning_observer/learning_observer/blacklist.py b/learning_observer/learning_observer/blacklist.py index cc9857d6..ab084b22 100644 --- a/learning_observer/learning_observer/blacklist.py +++ b/learning_observer/learning_observer/blacklist.py @@ -1,3 +1,8 @@ +import pmss + +import learning_observer.settings +import learning_observer.util + # TODO: # ACTIONS = enum.StrEnum("ACTIONS", names=('TRANSMIT', 'MAINTAIN', 'DROP')) # @@ -16,6 +21,56 @@ class TIME_LIMITS: [setattr(TIME_LIMITS, limit, limit) for limit in time_limits] +def _parse_blacklist_time_limit(value): + if isinstance(value, str): + normalized = value.strip().upper() + if normalized in time_limits: + return normalized + try: + numeric_value = float(normalized) + except ValueError as exc: + raise ValueError(f"Value '{value}' is not a valid blacklist time limit.") from exc + if numeric_value.is_integer(): + return int(numeric_value) + return numeric_value + if isinstance(value, bool): + raise ValueError(f"Value '{value}' is not a valid blacklist time limit.") + if isinstance(value, (int, float)): + return int(value) if isinstance(value, float) and value.is_integer() else value + raise ValueError(f"Value '{value}' is not a valid blacklist time limit.") + + +# Register blacklist settings +pmss.parser('blacklist_event_action', parent='string', choices=action_modes, transform=None) +pmss.register_field( + name='blacklist_event_action', + type='blacklist_event_action', + description='How to treat incoming events for blacklist checks.', + default=ACTIONS.TRANSMIT +) +pmss.parser('blacklist_time_limit', transform=_parse_blacklist_time_limit) +pmss.register_field( + name='blacklist_time_limit', + type='blacklist_time_limit', + description='Time limits for MAINTAINing blacklist responses. One of PERMANENT / MINUTES / DAYS or a number of milliseconds.', + default=TIME_LIMITS.MINUTES +) + + +def _extract_event_domain(event): + '''Find the domain of a user via the event + HACK we do not include the user's email throughout the incoming + event pipeline. We might do that in the future. For now, we look + in the places where an email *might* be to extract the domain. + ''' + if not isinstance(event, dict): + return None + email = None + if 'chrome_identity' in event: + email = event['chrome_identity'].get('email') + return learning_observer.util.get_domain_from_email(email) + + def get_blacklist_status(event): '''Return the blacklist status of a given event Returns should look like: @@ -26,16 +81,23 @@ def get_blacklist_status(event): message }` ''' - # TODO add in the logic to check our blacklist - if False: + event_domain = _extract_event_domain(event) + attributes = {'domain': event_domain} if event_domain else {} + action_type = learning_observer.settings.pmss_settings.blacklist_event_action(types=['incoming_events'], attributes=attributes) + + if action_type == ACTIONS.MAINTAIN: # tell client to keep messages around + time_limit = learning_observer.settings.pmss_settings.blacklist_time_limit( + types=['incoming_events'], + attributes=attributes + ) return { 'status': 'blocklist', 'action': ACTIONS.MAINTAIN, - 'time_limit': TIME_LIMITS.MINUTES, # Note this could (and usually would) be a number, but we have a few defaults like 'MINUTES' defined + 'time_limit': time_limit, 'message': 'You are blocked for now' } - if False: + if action_type == ACTIONS.DROP: # tell client to drop messages return { 'status': 'blocklist',