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 +7 -0
- onbot/__main__.py +6 -0
- onbot/app.py +171 -0
- onbot/auth/__init__.py +19 -0
- onbot/auth/token_provider.py +153 -0
- onbot/cli.py +80 -0
- onbot/clients/__init__.py +5 -0
- onbot/clients/authentik.py +123 -0
- onbot/clients/base.py +243 -0
- onbot/clients/mas_admin.py +65 -0
- onbot/clients/matrix.py +457 -0
- onbot/clients/synapse_admin.py +159 -0
- onbot/clients/versions.py +61 -0
- onbot/config.py +653 -0
- onbot/events.py +51 -0
- onbot/healthcheck.py +134 -0
- onbot/identity.py +44 -0
- onbot/lifecycle/__init__.py +2 -0
- onbot/lifecycle/accounts.py +309 -0
- onbot/logging.py +26 -0
- onbot/media.py +46 -0
- onbot/models.py +74 -0
- onbot/onboarding/__init__.py +5 -0
- onbot/onboarding/listener.py +103 -0
- onbot/onboarding/welcome.py +116 -0
- onbot/reconciler/__init__.py +2 -0
- onbot/reconciler/effectors.py +78 -0
- onbot/reconciler/engine.py +348 -0
- onbot/reconciler/membership.py +54 -0
- onbot/reconciler/power_levels.py +96 -0
- onbot/reconciler/rooms.py +127 -0
- onbot/reconciler/state.py +77 -0
- onbot/utils.py +61 -0
- onbot-0.1.0.dev0.dist-info/METADATA +241 -0
- onbot-0.1.0.dev0.dist-info/RECORD +38 -0
- onbot-0.1.0.dev0.dist-info/WHEEL +4 -0
- onbot-0.1.0.dev0.dist-info/entry_points.txt +5 -0
- onbot-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
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
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,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]
|