onbot 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
onbot/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Onbot — keep a Matrix homeserver in sync with Authentik and onboard new users.
2
+
3
+ Clean-slate rebuild targeting modern Matrix (MAS / next-gen auth, authenticated media,
4
+ sliding sync). See ``BATTLE_PLAN.md`` for the architecture and ``GOALS.md`` for intent.
5
+ """
6
+
7
+ __version__ = "0.1.0.dev0"
onbot/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m onbot``."""
2
+
3
+ from onbot.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
onbot/app.py ADDED
@@ -0,0 +1,171 @@
1
+ """Composition root: build clients, wire the reconciler + onboarding, manage lifecycle.
2
+
3
+ Keeps construction in one place (AD-4) so the CLI stays thin. The reconciler converges
4
+ Authentik→Matrix state; onboarding reacts (event-driven) to the reconciler's "user provisioned"
5
+ signal and to Matrix join events. They share the async API clients and an in-process event bus.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import AsyncIterator
12
+ from contextlib import asynccontextmanager
13
+ from dataclasses import dataclass
14
+
15
+ from onbot.auth.token_provider import (
16
+ OAuth2ClientCredentialsTokenProvider,
17
+ StaticTokenProvider,
18
+ TokenProvider,
19
+ )
20
+ from onbot.clients.authentik import ApiClientAuthentik
21
+ from onbot.clients.mas_admin import ApiClientMasAdmin
22
+ from onbot.clients.matrix import ApiClientMatrix, CSApiEffectors
23
+ from onbot.clients.synapse_admin import ApiClientSynapseAdmin
24
+ from onbot.config import OnbotConfig, SynapseServer
25
+ from onbot.events import EventBus
26
+ from onbot.lifecycle.accounts import (
27
+ AccountLifecycleManager,
28
+ AdminApiLifecycleEffectors,
29
+ LifecycleEffectors,
30
+ MasLifecycleEffectors,
31
+ MatrixAccountDataLedgerStore,
32
+ )
33
+ from onbot.logging import get_logger
34
+ from onbot.media import MediaUploader
35
+ from onbot.onboarding.listener import OnboardingListener
36
+ from onbot.onboarding.welcome import WelcomeService
37
+ from onbot.reconciler.engine import ReconcilerEngine
38
+
39
+ log = get_logger(__name__)
40
+
41
+
42
+ def build_matrix_token_provider(synapse: SynapseServer) -> TokenProvider:
43
+ """Pick the bot's auth strategy (AD-6): OAuth2 client-credentials if configured, else a static
44
+ compatibility/legacy token. Raises if neither is provided."""
45
+ if synapse.oauth2 is not None:
46
+ return OAuth2ClientCredentialsTokenProvider(
47
+ token_endpoint=synapse.oauth2.token_endpoint,
48
+ client_id=synapse.oauth2.client_id,
49
+ client_secret=synapse.oauth2.client_secret,
50
+ scope=synapse.oauth2.scope,
51
+ )
52
+ if synapse.bot_access_token:
53
+ return StaticTokenProvider(synapse.bot_access_token)
54
+ raise ValueError("synapse_server needs either bot_access_token or an oauth2 block")
55
+
56
+
57
+ async def _apply_bot_avatar(matrix: ApiClientMatrix, config: OnbotConfig) -> None:
58
+ """Set the bot's own avatar from the configured URL on startup (G6.8), best-effort."""
59
+ url = config.synapse_server.bot_avatar_url
60
+ if not url:
61
+ return
62
+ uploader = MediaUploader(matrix)
63
+ try:
64
+ mxc = await uploader.upload_from_url(url)
65
+ await matrix.set_user_avatar(config.synapse_server.bot_user_id, mxc)
66
+ log.info("set bot avatar from %s", url)
67
+ except Exception:
68
+ log.exception("failed to set bot avatar from %s", url)
69
+ finally:
70
+ await uploader.aclose()
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class App:
75
+ """Wired application: the reconciler engine and the onboarding listener over a shared bus."""
76
+
77
+ engine: ReconcilerEngine
78
+ listener: OnboardingListener
79
+
80
+
81
+ @asynccontextmanager
82
+ async def build_app(config: OnbotConfig) -> AsyncIterator[App]:
83
+ """Construct the reconciler + onboarding with their clients, closing them on exit."""
84
+ authentik = ApiClientAuthentik(
85
+ url=config.authentik_server.url,
86
+ api_key=config.authentik_server.api_key,
87
+ )
88
+ # One MAS-aware token provider shared by the admin + CS clients (same bot identity, AD-6).
89
+ token_provider = build_matrix_token_provider(config.synapse_server)
90
+ admin = ApiClientSynapseAdmin(
91
+ server_url=config.synapse_server.server_url,
92
+ token_provider=token_provider,
93
+ admin_api_path=config.synapse_server.admin_api_path,
94
+ )
95
+ matrix = ApiClientMatrix(
96
+ server_url=config.synapse_server.server_url,
97
+ token_provider=token_provider,
98
+ server_name=config.synapse_server.server_name,
99
+ )
100
+ # Negotiate CS-API capabilities up front (sliding sync / authenticated media); best-effort so a
101
+ # transient failure does not block startup — the listener re-checks and falls back if needed.
102
+ try:
103
+ await matrix.negotiate_versions()
104
+ except Exception:
105
+ log.exception("CS-API version negotiation failed; continuing with defaults")
106
+ # Register the bot's device so welcome DM sends work under MAS (compat-token devices are
107
+ # otherwise absent from Synapse's devices table; ADR-0006/0009).
108
+ await matrix.ensure_device_registered()
109
+ await _apply_bot_avatar(matrix, config)
110
+ events = EventBus()
111
+ # Lifecycle enforcement: under MAS only the MAS admin API can revoke a live session (§7 Q1), so
112
+ # prefer it when configured; otherwise fall back to the Synapse-admin effectors.
113
+ mas_admin: ApiClientMasAdmin | None = None
114
+ lifecycle_effectors: LifecycleEffectors
115
+ if config.mas_admin is not None:
116
+ mas_admin = ApiClientMasAdmin(
117
+ mas_url=config.mas_admin.url,
118
+ token_provider=OAuth2ClientCredentialsTokenProvider(
119
+ token_endpoint=f"{config.mas_admin.url.rstrip('/')}/oauth2/token",
120
+ client_id=config.mas_admin.client_id,
121
+ client_secret=config.mas_admin.client_secret,
122
+ scope="urn:mas:admin",
123
+ ),
124
+ )
125
+ lifecycle_effectors = MasLifecycleEffectors(mas_admin, synapse_admin=admin)
126
+ else:
127
+ lifecycle_effectors = AdminApiLifecycleEffectors(admin)
128
+ lifecycle = AccountLifecycleManager(
129
+ config,
130
+ store=MatrixAccountDataLedgerStore(
131
+ matrix, config.synapse_server.bot_user_id, config.synapse_server.server_name
132
+ ),
133
+ effectors=lifecycle_effectors,
134
+ )
135
+ engine = ReconcilerEngine(
136
+ config, authentik, admin, effectors=CSApiEffectors(matrix), events=events, lifecycle=lifecycle
137
+ )
138
+ welcome = WelcomeService(matrix, config)
139
+ listener = OnboardingListener(matrix, welcome, config, events)
140
+ listener.start() # subscribe onboarding to the reconciler's user-provisioned signal (AD-4)
141
+ try:
142
+ yield App(engine=engine, listener=listener)
143
+ finally:
144
+ await authentik.aclose()
145
+ await admin.aclose()
146
+ await matrix.aclose()
147
+ if mas_admin is not None:
148
+ await mas_admin.aclose()
149
+
150
+
151
+ async def run_service(config: OnbotConfig) -> None:
152
+ """Run the reconcile loop and the onboarding listener concurrently until stopped."""
153
+ async with build_app(config) as app:
154
+ # The engine owns the signal handlers; when it stops, stop the listener too.
155
+ async def _reconcile() -> None:
156
+ try:
157
+ await app.engine.run()
158
+ finally:
159
+ app.listener.request_stop()
160
+
161
+ await asyncio.gather(_reconcile(), app.listener.run())
162
+
163
+
164
+ async def run_reconcile_once(config: OnbotConfig) -> None:
165
+ """Run a single reconcile pass and exit (``onbot reconcile-once``).
166
+
167
+ Onboarding still fires for users discovered this pass — the listener is subscribed to the bus —
168
+ but the long-running sync stream is not started.
169
+ """
170
+ async with build_app(config) as app:
171
+ await app.engine.reconcile_once()
onbot/auth/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """MAS-aware authentication (AD-6).
2
+
3
+ Token providers (:mod:`onbot.auth.token_provider`): a static/compat token or OAuth2
4
+ client-credentials with transparent refresh, behind one :class:`TokenProvider` protocol.
5
+ """
6
+
7
+ from onbot.auth.token_provider import (
8
+ OAuth2ClientCredentialsTokenProvider,
9
+ OAuth2TokenError,
10
+ StaticTokenProvider,
11
+ TokenProvider,
12
+ )
13
+
14
+ __all__ = [
15
+ "OAuth2ClientCredentialsTokenProvider",
16
+ "OAuth2TokenError",
17
+ "StaticTokenProvider",
18
+ "TokenProvider",
19
+ ]
@@ -0,0 +1,153 @@
1
+ """MAS-aware access-token providers (AD-6, Phase 6).
2
+
3
+ The bot authenticates to the Synapse CS + admin APIs with a bearer token. Under the
4
+ MAS topology two ways of obtaining that token coexist, and the rest of the code must
5
+ not care which is in use:
6
+
7
+ * :class:`StaticTokenProvider` — a fixed token. This is the near-term path: a
8
+ *compatibility token* issued by ``mas-cli manage issue-compatibility-token`` (or a
9
+ legacy Synapse access token when not running MAS). Nothing expires; the token is
10
+ returned verbatim.
11
+ * :class:`OAuth2ClientCredentialsTokenProvider` — the forward-looking path: the bot is
12
+ a confidential OAuth2 client of MAS and mints short-lived access tokens via the
13
+ ``client_credentials`` grant, transparently refreshing them before they expire
14
+ (using the ``refresh_token`` grant when MAS returns a refresh token, otherwise a
15
+ fresh ``client_credentials`` exchange).
16
+
17
+ Both satisfy the :class:`TokenProvider` protocol — a single ``async get_token()`` —
18
+ which :class:`~onbot.clients.base.BaseApiClient` calls per request, so a token can
19
+ rotate underneath a long-lived client without reconstructing it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import time
25
+ from typing import Protocol, runtime_checkable
26
+
27
+ import httpx
28
+
29
+ from onbot.logging import get_logger
30
+
31
+ log = get_logger(__name__)
32
+
33
+ # Refresh a little before the server-stated expiry so an in-flight request never races
34
+ # the cutoff (clock skew + network latency margin).
35
+ _EXPIRY_MARGIN_SEC = 30.0
36
+
37
+
38
+ @runtime_checkable
39
+ class TokenProvider(Protocol):
40
+ """Supplies a bearer token for the Authorization header; may refresh under the hood."""
41
+
42
+ async def get_token(self) -> str: ...
43
+
44
+ async def aclose(self) -> None: ...
45
+
46
+
47
+ class StaticTokenProvider:
48
+ """A fixed bearer token (compat token or legacy Synapse token). Never expires."""
49
+
50
+ def __init__(self, token: str) -> None:
51
+ if not token:
52
+ raise ValueError("StaticTokenProvider requires a non-empty token")
53
+ self._token = token
54
+
55
+ async def get_token(self) -> str:
56
+ return self._token
57
+
58
+ async def aclose(self) -> None: # symmetry with the OAuth2 provider; nothing to close
59
+ return None
60
+
61
+
62
+ class OAuth2ClientCredentialsTokenProvider:
63
+ """Mints + refreshes MAS access tokens via the OAuth2 ``client_credentials`` grant.
64
+
65
+ The first :meth:`get_token` performs a ``client_credentials`` exchange and caches the
66
+ access token together with its expiry. Subsequent calls return the cached token until
67
+ it is within :data:`_EXPIRY_MARGIN_SEC` of expiring, then refresh it — via the
68
+ ``refresh_token`` grant when one was issued, else a fresh ``client_credentials``
69
+ exchange. Client authentication uses HTTP Basic per RFC 6749 §2.3.1.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ *,
75
+ token_endpoint: str,
76
+ client_id: str,
77
+ client_secret: str,
78
+ scope: str | None = None,
79
+ client: httpx.AsyncClient | None = None,
80
+ ) -> None:
81
+ self._token_endpoint = token_endpoint
82
+ self._client_id = client_id
83
+ self._client_secret = client_secret
84
+ self._scope = scope
85
+ self._http = client or httpx.AsyncClient(timeout=30.0)
86
+ self._owns_http = client is None
87
+ self._access_token: str | None = None
88
+ self._refresh_token: str | None = None
89
+ self._expires_at: float = 0.0
90
+
91
+ async def get_token(self) -> str:
92
+ if self._access_token is not None and time.monotonic() < self._expires_at - _EXPIRY_MARGIN_SEC:
93
+ return self._access_token
94
+ return await self._refresh()
95
+
96
+ async def _refresh(self) -> str:
97
+ if self._refresh_token is not None:
98
+ data = {"grant_type": "refresh_token", "refresh_token": self._refresh_token}
99
+ else:
100
+ data = {"grant_type": "client_credentials"}
101
+ if self._scope:
102
+ data["scope"] = self._scope
103
+ payload = await self._token_request(data)
104
+ self._store(payload)
105
+ assert self._access_token is not None # _store guarantees it or raised
106
+ return self._access_token
107
+
108
+ async def _token_request(self, data: dict[str, str]) -> dict[str, object]:
109
+ response = await self._http.post(
110
+ self._token_endpoint,
111
+ data=data,
112
+ auth=(self._client_id, self._client_secret),
113
+ headers={"Accept": "application/json"},
114
+ )
115
+ if response.status_code >= 400:
116
+ # A failed refresh may mean the refresh token was revoked; drop it so the next
117
+ # attempt falls back to a fresh client_credentials exchange.
118
+ self._refresh_token = None
119
+ raise OAuth2TokenError(response.status_code, _safe_json(response))
120
+ result: dict[str, object] = response.json()
121
+ return result
122
+
123
+ def _store(self, payload: dict[str, object]) -> None:
124
+ token = payload.get("access_token")
125
+ if not isinstance(token, str):
126
+ raise OAuth2TokenError(200, payload)
127
+ self._access_token = token
128
+ refresh = payload.get("refresh_token")
129
+ self._refresh_token = refresh if isinstance(refresh, str) else None
130
+ expires_in = payload.get("expires_in")
131
+ # Default to a conservative lifetime if the server omits expires_in.
132
+ seconds = float(expires_in) if isinstance(expires_in, int | float) else 300.0
133
+ self._expires_at = time.monotonic() + seconds
134
+
135
+ async def aclose(self) -> None:
136
+ if self._owns_http:
137
+ await self._http.aclose()
138
+
139
+
140
+ class OAuth2TokenError(Exception):
141
+ """The MAS token endpoint rejected a token request."""
142
+
143
+ def __init__(self, status_code: int, payload: object) -> None:
144
+ self.status_code = status_code
145
+ self.payload = payload
146
+ super().__init__(f"OAuth2 token request failed (HTTP {status_code}): {payload!r}")
147
+
148
+
149
+ def _safe_json(response: httpx.Response) -> object:
150
+ try:
151
+ return response.json()
152
+ except ValueError:
153
+ return response.text
onbot/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line entry point: ``python -m onbot`` / ``onbot``.
2
+
3
+ Sub-commands mirror the operational surface from ``BATTLE_PLAN.md`` §4:
4
+
5
+ * ``run`` — long-running service: scheduled reconcile + (Phase 4) onboarding
6
+ * ``reconcile-once`` — run a single idempotent reconcile and exit
7
+ * ``generate-config`` — emit a documented example config (G11.2)
8
+ * ``healthcheck`` — probe dependencies for container/orchestrator health (Phase 8)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ from collections.abc import Sequence
16
+
17
+ from onbot import __version__
18
+ from onbot.config import generate_example_config, get_config_file_path, load_config
19
+ from onbot.logging import configure_logging, get_logger
20
+
21
+ log = get_logger(__name__)
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="onbot", description="Authentik → Matrix sync & onboarding bot.")
26
+ parser.add_argument("--version", action="version", version=f"onbot {__version__}")
27
+ parser.add_argument("--log-level", default=None, help="Override log level (e.g. DEBUG, INFO).")
28
+
29
+ sub = parser.add_subparsers(dest="command", required=True)
30
+ sub.add_parser("run", help="Run the bot service (reconcile loop + onboarding).")
31
+ sub.add_parser("reconcile-once", help="Run a single reconcile pass and exit.")
32
+ gen = sub.add_parser("generate-config", help="Write a documented example configuration file.")
33
+ gen.add_argument("-o", "--output", default=None, help="Write to this path instead of stdout.")
34
+ sub.add_parser("healthcheck", help="Check connectivity to required services.")
35
+ return parser
36
+
37
+
38
+ def _cmd_generate_config(output: str | None) -> int:
39
+ text = generate_example_config()
40
+ if output:
41
+ with open(output, "w", encoding="utf-8") as fh:
42
+ fh.write(text)
43
+ log.info("wrote example config to %s", output)
44
+ else:
45
+ print(text, end="")
46
+ return 0
47
+
48
+
49
+ def main(argv: Sequence[str] | None = None) -> int:
50
+ args = build_parser().parse_args(argv)
51
+ configure_logging(args.log_level)
52
+ log.info("onbot %s — command %r", __version__, args.command)
53
+
54
+ if args.command == "generate-config":
55
+ return _cmd_generate_config(args.output)
56
+
57
+ # Commands that need live configuration + the async runtime.
58
+ if get_config_file_path() is None:
59
+ log.warning("no config file found; relying on ONBOT_* environment variables")
60
+ config = load_config()
61
+
62
+ if args.command == "healthcheck":
63
+ from onbot.healthcheck import run_healthcheck
64
+
65
+ return asyncio.run(run_healthcheck(config))
66
+
67
+ from onbot import app # local import keeps `generate-config` usable without a config file
68
+
69
+ if args.command == "run":
70
+ asyncio.run(app.run_service(config))
71
+ return 0
72
+ if args.command == "reconcile-once":
73
+ asyncio.run(app.run_reconcile_once(config))
74
+ return 0
75
+
76
+ raise SystemExit(f"unknown command {args.command!r}") # pragma: no cover
77
+
78
+
79
+ if __name__ == "__main__": # pragma: no cover
80
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Async HTTP clients on a shared pooled base (AD-7): Authentik, Synapse-Admin, Matrix CS.
2
+
3
+ Base client + Authentik/Admin clients land in Phase 3; the Matrix CS client (+ Simplified Sliding
4
+ Sync stream and the concrete reconciler effectors) lands in Phase 4.
5
+ """
@@ -0,0 +1,123 @@
1
+ """Authentik API client (async, paginated).
2
+
3
+ Ported from legacy ``onbot/api_client_authentik.py`` onto the async :class:`BaseApiClient`.
4
+ Fixes carried over from BATTLE_PLAN §3: full pagination (legacy read only page 1), no stray
5
+ ``print`` debugging, and typed errors via the base client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Sequence
12
+ from typing import Any
13
+
14
+ from onbot.clients.base import BaseApiClient
15
+ from onbot.logging import get_logger
16
+ from onbot.utils import dict_has_nested_attr
17
+
18
+ log = get_logger(__name__)
19
+
20
+ _DEFAULT_PAGE_SIZE = 100
21
+
22
+
23
+ def _next_page_params(page: Any, current: dict[str, Any]) -> dict[str, Any] | None:
24
+ """Authentik paginates with ``pagination.next`` holding the next page number (0 = end)."""
25
+ pagination = (page or {}).get("pagination", {})
26
+ next_page = pagination.get("next")
27
+ if not next_page:
28
+ return None
29
+ return {**current, "page": next_page}
30
+
31
+
32
+ class ApiClientAuthentik(BaseApiClient):
33
+ def __init__(self, url: str, api_key: str, **kwargs: Any) -> None:
34
+ super().__init__(base_url=f"{url.rstrip('/')}/api/v3", auth_token=api_key, **kwargs)
35
+
36
+ async def list_users(
37
+ self,
38
+ *,
39
+ filter_groups_by_name: str | Sequence[str] | None = None,
40
+ filter_groups_by_pk: str | Sequence[str] | None = None,
41
+ filter_by_path: str | None = None,
42
+ filter_by_attribute: str | dict[str, Any] | None = None,
43
+ filter_is_superuser: bool | None = None,
44
+ filter_is_active: bool | None = True,
45
+ ) -> list[dict[str, Any]]:
46
+ """List users (https://<authentik>/api/v3/#get-/core/users/), following all pages."""
47
+ if isinstance(filter_by_attribute, dict):
48
+ filter_by_attribute = json.dumps(filter_by_attribute)
49
+ params = {
50
+ "groups_by_name": filter_groups_by_name,
51
+ "groups_by_pk": filter_groups_by_pk,
52
+ "attributes": filter_by_attribute,
53
+ "is_superuser": filter_is_superuser,
54
+ "is_active": filter_is_active,
55
+ "path": filter_by_path,
56
+ "page_size": _DEFAULT_PAGE_SIZE,
57
+ }
58
+ return await self.paginate_collect(
59
+ "core/users/",
60
+ params=params,
61
+ extract_items=lambda page: page["results"],
62
+ next_params=_next_page_params,
63
+ )
64
+
65
+ async def list_groups(
66
+ self,
67
+ *,
68
+ filter_members_by_username: str | Sequence[str] | None = None,
69
+ filter_members_by_pk: str | Sequence[str] | None = None,
70
+ filter_by_attribute: dict[str, Any] | None = None,
71
+ filter_is_superuser: bool | None = None,
72
+ filter_has_attributes: Sequence[str] | None = None,
73
+ filter_has_non_empty_attributes: Sequence[str] | None = None,
74
+ include_inactive_users_obj: bool = False,
75
+ ) -> list[dict[str, Any]]:
76
+ """List groups (https://<authentik>/api/v3/#get-/core/groups/), following all pages.
77
+
78
+ ``filter_has_attributes`` / ``filter_has_non_empty_attributes`` are client-side filters on
79
+ dotted attribute paths (Authentik's query API can't express them). Inactive users are
80
+ stripped from each group's ``users_obj`` unless ``include_inactive_users_obj``.
81
+ """
82
+ params = {
83
+ "members_by_username": filter_members_by_username,
84
+ "members_by_pk": filter_members_by_pk,
85
+ "attributes": (json.dumps(filter_by_attribute) if filter_by_attribute else None),
86
+ "is_superuser": filter_is_superuser,
87
+ "page_size": _DEFAULT_PAGE_SIZE,
88
+ }
89
+ groups: list[dict[str, Any]] = await self.paginate_collect(
90
+ "core/groups/",
91
+ params=params,
92
+ extract_items=lambda page: page["results"],
93
+ next_params=_next_page_params,
94
+ )
95
+
96
+ if not include_inactive_users_obj:
97
+ for group in groups:
98
+ self._remove_inactive_users_from_group(group)
99
+
100
+ if filter_has_attributes:
101
+ groups = [
102
+ g
103
+ for g in groups
104
+ if any(
105
+ dict_has_nested_attr(g.get("attributes", {}), attr.split("."))
106
+ for attr in filter_has_attributes
107
+ )
108
+ ]
109
+ if filter_has_non_empty_attributes:
110
+ groups = [
111
+ g
112
+ for g in groups
113
+ if any(
114
+ dict_has_nested_attr(g.get("attributes", {}), attr.split("."), must_have_val=True)
115
+ for attr in filter_has_non_empty_attributes
116
+ )
117
+ ]
118
+ return groups
119
+
120
+ @staticmethod
121
+ def _remove_inactive_users_from_group(group: dict[str, Any]) -> None:
122
+ if "users_obj" in group:
123
+ group["users_obj"] = [u for u in group["users_obj"] if u.get("is_active") is True]