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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Reconciler engine (AD-2): level-triggered, idempotent convergence.
|
|
2
|
+
|
|
3
|
+
Computes *desired* state (from Authentik) vs *actual* state (Synapse) and applies the diff. Runs on
|
|
4
|
+
a schedule **and** on demand (replacing the legacy ``while True: sleep`` tick loop), and shuts down
|
|
5
|
+
gracefully on SIGINT/SIGTERM. A single ``reconcile_once`` pass is fully re-runnable.
|
|
6
|
+
|
|
7
|
+
The pure decision logic lives in the sibling modules (``rooms``, ``membership``, ``power_levels``);
|
|
8
|
+
reads go through the Authentik + Synapse-admin clients; writes go through the Synapse-admin client
|
|
9
|
+
(membership/block) and the :class:`MatrixEffectors` seam (CS-API operations, Phase 4).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import signal
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from onbot.clients.authentik import ApiClientAuthentik
|
|
20
|
+
from onbot.clients.synapse_admin import ApiClientSynapseAdmin
|
|
21
|
+
from onbot.config import OnbotConfig, SyncMatrixRoomsBasedOnAuthentikGroups
|
|
22
|
+
from onbot.events import EventBus, Signal
|
|
23
|
+
from onbot.identity import build_canonical, compute_mxid
|
|
24
|
+
from onbot.lifecycle.accounts import AccountLifecycleManager
|
|
25
|
+
from onbot.logging import get_logger
|
|
26
|
+
from onbot.models import GroupRoomMap, MappedUser, MatrixRoom
|
|
27
|
+
from onbot.reconciler.effectors import DryRunEffectors, MatrixEffectors
|
|
28
|
+
from onbot.reconciler.membership import (
|
|
29
|
+
desired_room_members,
|
|
30
|
+
diff_room_membership,
|
|
31
|
+
diff_space_membership,
|
|
32
|
+
)
|
|
33
|
+
from onbot.reconciler.power_levels import (
|
|
34
|
+
PowerLevelGroup,
|
|
35
|
+
compute_desired_user_levels,
|
|
36
|
+
extract_power_level_groups,
|
|
37
|
+
merge_power_levels,
|
|
38
|
+
)
|
|
39
|
+
from onbot.reconciler.rooms import build_group_room_maps
|
|
40
|
+
from onbot.reconciler.state import (
|
|
41
|
+
GroupRoomState,
|
|
42
|
+
OnbotRoomType,
|
|
43
|
+
SpaceRoomState,
|
|
44
|
+
dump_room_state,
|
|
45
|
+
event_type_name,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
log = get_logger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConfigurationError(RuntimeError):
|
|
52
|
+
"""Raised when the configuration cannot be satisfied (e.g. required space missing)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReconcilerEngine:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
config: OnbotConfig,
|
|
59
|
+
authentik: ApiClientAuthentik,
|
|
60
|
+
admin: ApiClientSynapseAdmin,
|
|
61
|
+
effectors: MatrixEffectors | None = None,
|
|
62
|
+
events: EventBus | None = None,
|
|
63
|
+
lifecycle: AccountLifecycleManager | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.config = config
|
|
66
|
+
self.authentik = authentik
|
|
67
|
+
self.admin = admin
|
|
68
|
+
self.effectors: MatrixEffectors = effectors or DryRunEffectors()
|
|
69
|
+
self.events = events or EventBus()
|
|
70
|
+
self.lifecycle = lifecycle
|
|
71
|
+
self.server_name = config.synapse_server.server_name
|
|
72
|
+
self._stop = asyncio.Event()
|
|
73
|
+
self._trigger = asyncio.Event()
|
|
74
|
+
|
|
75
|
+
# --- runtime loop --------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def trigger(self) -> None:
|
|
78
|
+
"""Request an out-of-band reconcile (on-demand) before the next scheduled tick."""
|
|
79
|
+
self._trigger.set()
|
|
80
|
+
|
|
81
|
+
def request_stop(self) -> None:
|
|
82
|
+
self._stop.set()
|
|
83
|
+
self._trigger.set() # unblock the wait so we exit promptly
|
|
84
|
+
|
|
85
|
+
async def run(self) -> None:
|
|
86
|
+
"""Run scheduled + on-demand reconciles until stopped (SIGINT/SIGTERM)."""
|
|
87
|
+
self._install_signal_handlers()
|
|
88
|
+
log.info("reconciler started; tick=%ss", self.config.server_tick_rate_sec)
|
|
89
|
+
while not self._stop.is_set():
|
|
90
|
+
# Clear before the pass so a trigger raised *during* it is preserved for the next wait.
|
|
91
|
+
self._trigger.clear()
|
|
92
|
+
try:
|
|
93
|
+
await self.reconcile_once()
|
|
94
|
+
except Exception:
|
|
95
|
+
log.exception("reconcile pass failed; will retry next tick")
|
|
96
|
+
if self._stop.is_set():
|
|
97
|
+
break
|
|
98
|
+
await self._wait_for_next_tick()
|
|
99
|
+
log.info("reconciler stopped")
|
|
100
|
+
|
|
101
|
+
async def _wait_for_next_tick(self) -> None:
|
|
102
|
+
# Wake on an on-demand trigger, or fall through on the scheduled timeout.
|
|
103
|
+
with contextlib.suppress(TimeoutError):
|
|
104
|
+
await asyncio.wait_for(self._trigger.wait(), timeout=self.config.server_tick_rate_sec)
|
|
105
|
+
|
|
106
|
+
def _install_signal_handlers(self) -> None:
|
|
107
|
+
try:
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
110
|
+
loop.add_signal_handler(sig, self.request_stop)
|
|
111
|
+
except NotImplementedError, RuntimeError: # pragma: no cover - non-main thread / Windows
|
|
112
|
+
log.debug("signal handlers unavailable in this environment")
|
|
113
|
+
|
|
114
|
+
# --- one convergence pass ------------------------------------------------
|
|
115
|
+
|
|
116
|
+
async def reconcile_once(self) -> None:
|
|
117
|
+
log.info("reconcile: gathering desired (Authentik) and actual (Synapse) state")
|
|
118
|
+
matrix_users = await self.admin.list_users()
|
|
119
|
+
users = await self._gather_mapped_users(matrix_users)
|
|
120
|
+
space = await self._resolve_space()
|
|
121
|
+
group_maps = await self._gather_group_room_maps()
|
|
122
|
+
|
|
123
|
+
await self._converge_rooms(group_maps, space)
|
|
124
|
+
if space is not None:
|
|
125
|
+
await self._converge_space_membership(space, users)
|
|
126
|
+
await self._converge_room_membership_and_levels(group_maps, users)
|
|
127
|
+
await self._converge_lifecycle(matrix_users, {u.mxid for u in users})
|
|
128
|
+
log.info("reconcile: done (%d users, %d group rooms)", len(users), len(group_maps))
|
|
129
|
+
|
|
130
|
+
async def _gather_mapped_users(self, matrix_users: list[dict[str, Any]]) -> list[MappedUser]:
|
|
131
|
+
cfg = self.config.sync_authentik_users_with_matrix_rooms
|
|
132
|
+
if not cfg.enabled:
|
|
133
|
+
return []
|
|
134
|
+
configured_paths = cfg.sync_only_users_in_authentik_pathes
|
|
135
|
+
paths: list[str | None] = [*configured_paths] if configured_paths else [None]
|
|
136
|
+
by_mxid: dict[str, dict[str, Any]] = {}
|
|
137
|
+
seen_pks: set[str] = set()
|
|
138
|
+
for path in paths:
|
|
139
|
+
for user in await self.authentik.list_users(
|
|
140
|
+
filter_by_path=path,
|
|
141
|
+
filter_by_attribute=cfg.sync_only_users_with_authentik_attributes,
|
|
142
|
+
filter_groups_by_pk=cfg.sync_only_users_of_groups_with_id,
|
|
143
|
+
filter_is_active=True,
|
|
144
|
+
):
|
|
145
|
+
if user["username"] in self.config.authentik_user_ignore_list:
|
|
146
|
+
continue
|
|
147
|
+
if user["pk"] in seen_pks:
|
|
148
|
+
continue
|
|
149
|
+
seen_pks.add(user["pk"])
|
|
150
|
+
try:
|
|
151
|
+
mxid = compute_mxid(
|
|
152
|
+
user,
|
|
153
|
+
username_attribute=cfg.authentik_username_mapping_attribute,
|
|
154
|
+
server_name=self.server_name,
|
|
155
|
+
)
|
|
156
|
+
except KeyError:
|
|
157
|
+
log.warning("cannot map MXID for Authentik user %r; skipping", user.get("pk"))
|
|
158
|
+
continue
|
|
159
|
+
by_mxid[mxid] = user
|
|
160
|
+
|
|
161
|
+
mapped: list[MappedUser] = []
|
|
162
|
+
for matrix_user in matrix_users:
|
|
163
|
+
mxid = matrix_user["name"]
|
|
164
|
+
if mxid in self.config.matrix_user_ignore_list:
|
|
165
|
+
continue
|
|
166
|
+
authentik_user = by_mxid.get(mxid)
|
|
167
|
+
if authentik_user is not None:
|
|
168
|
+
mapped.append(MappedUser(authentik_obj=authentik_user, mxid=mxid, matrix_obj=matrix_user))
|
|
169
|
+
await self.events.emit(Signal.user_synced, mxid=mxid)
|
|
170
|
+
return mapped
|
|
171
|
+
|
|
172
|
+
async def _gather_group_room_maps(self) -> list[GroupRoomMap]:
|
|
173
|
+
settings = self.config.sync_matrix_rooms_based_on_authentik_groups
|
|
174
|
+
if not settings.enabled:
|
|
175
|
+
return []
|
|
176
|
+
groups = await self.authentik.list_groups(filter_by_attribute=settings.only_groups_with_attributes)
|
|
177
|
+
rooms = [MatrixRoom.from_admin_api(r) for r in await self.admin.list_non_space_rooms()]
|
|
178
|
+
return build_group_room_maps(groups, rooms, self.config, self.server_name)
|
|
179
|
+
|
|
180
|
+
async def _resolve_space(self) -> MatrixRoom | None:
|
|
181
|
+
cfg = self.config.create_matrix_rooms_in_a_matrix_space
|
|
182
|
+
if not cfg.enabled:
|
|
183
|
+
return None
|
|
184
|
+
target_alias = build_canonical(cfg.alias, self.server_name, "#")
|
|
185
|
+
for sp in await self.admin.list_spaces():
|
|
186
|
+
if sp.get("canonical_alias") == target_alias:
|
|
187
|
+
return MatrixRoom.from_admin_api(sp, is_space=True)
|
|
188
|
+
if not cfg.create_matrix_space_if_not_exists.enabled:
|
|
189
|
+
raise ConfigurationError(
|
|
190
|
+
f"space {target_alias!r} not found and auto-creation is disabled "
|
|
191
|
+
"(create_matrix_rooms_in_a_matrix_space.create_matrix_space_if_not_exists.enabled)"
|
|
192
|
+
)
|
|
193
|
+
create = cfg.create_matrix_space_if_not_exists
|
|
194
|
+
room_id = await self.effectors.create_space(
|
|
195
|
+
alias=cfg.alias, name=create.name, topic=create.topic, params=create.space_params
|
|
196
|
+
)
|
|
197
|
+
await self.effectors.put_room_state(
|
|
198
|
+
room_id,
|
|
199
|
+
event_type_name(self.server_name, OnbotRoomType.space),
|
|
200
|
+
dump_room_state(SpaceRoomState(authentik_server=self.config.authentik_server.url)),
|
|
201
|
+
)
|
|
202
|
+
return MatrixRoom(room_id=room_id, canonical_alias=target_alias, is_space=True)
|
|
203
|
+
|
|
204
|
+
async def _converge_rooms(self, group_maps: list[GroupRoomMap], space: MatrixRoom | None) -> None:
|
|
205
|
+
parent_space_id = space.room_id if space else None
|
|
206
|
+
for gm in group_maps:
|
|
207
|
+
if gm.room is None:
|
|
208
|
+
room_id = await self.effectors.create_group_room(gm.desired, parent_space_id)
|
|
209
|
+
await self.effectors.put_room_state(
|
|
210
|
+
room_id,
|
|
211
|
+
event_type_name(self.server_name, OnbotRoomType.group_room),
|
|
212
|
+
dump_room_state(
|
|
213
|
+
GroupRoomState(
|
|
214
|
+
group_id=gm.group_pk,
|
|
215
|
+
authentik_server=self.config.authentik_server.url,
|
|
216
|
+
)
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
gm.room = MatrixRoom(room_id=room_id, canonical_alias=gm.desired.canonical_alias)
|
|
220
|
+
elif await self.admin.room_is_blocked(gm.room.room_id):
|
|
221
|
+
# G2.3: a previously blocked room whose group reappeared gets unblocked.
|
|
222
|
+
log.info("unblocking room %s (group reappeared)", gm.room.room_id)
|
|
223
|
+
await self.admin.room_set_blocked(gm.room.room_id, blocked=False)
|
|
224
|
+
|
|
225
|
+
async def _converge_space_membership(self, space: MatrixRoom, users: list[MappedUser]) -> None:
|
|
226
|
+
members = await self.admin.list_room_members(space.room_id)
|
|
227
|
+
diff = diff_space_membership(users, members)
|
|
228
|
+
for mxid in diff.to_add:
|
|
229
|
+
await self.admin.add_user_to_room(space.room_id, mxid)
|
|
230
|
+
|
|
231
|
+
async def _converge_room_membership_and_levels(
|
|
232
|
+
self, group_maps: list[GroupRoomMap], users: list[MappedUser]
|
|
233
|
+
) -> None:
|
|
234
|
+
sync_cfg = self.config.sync_authentik_users_with_matrix_rooms
|
|
235
|
+
room_cfg = self.config.sync_matrix_rooms_based_on_authentik_groups
|
|
236
|
+
bot_id = self.config.synapse_server.bot_user_id
|
|
237
|
+
|
|
238
|
+
pl_groups = extract_power_level_groups(
|
|
239
|
+
await self.authentik.list_groups(
|
|
240
|
+
filter_has_non_empty_attributes=[room_cfg.authentik_group_attr_for_matrix_power_level]
|
|
241
|
+
),
|
|
242
|
+
room_cfg.authentik_group_attr_for_matrix_power_level,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
for gm in group_maps:
|
|
246
|
+
if gm.room is None: # freshly created in dry-run with synthetic id is still set; guard anyway
|
|
247
|
+
continue
|
|
248
|
+
room_id = gm.room.room_id
|
|
249
|
+
actual_members = await self.admin.list_room_members(room_id)
|
|
250
|
+
desired_mxids = desired_room_members(gm.group_pk, users)
|
|
251
|
+
|
|
252
|
+
mdiff = diff_room_membership(
|
|
253
|
+
desired_mxids,
|
|
254
|
+
actual_members,
|
|
255
|
+
kick_enabled=sync_cfg.kick_matrix_room_members_not_in_mapped_authentik_group_anymore,
|
|
256
|
+
protected_ids=[bot_id],
|
|
257
|
+
)
|
|
258
|
+
for mxid in mdiff.to_add:
|
|
259
|
+
await self.admin.add_user_to_room(room_id, mxid)
|
|
260
|
+
for mxid in mdiff.to_kick:
|
|
261
|
+
await self.effectors.kick_user(
|
|
262
|
+
room_id,
|
|
263
|
+
mxid,
|
|
264
|
+
"Removed: missing/revoked group membership in the central user directory.",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
await self._converge_power_levels(room_id, gm.group_pk, users, pl_groups, room_cfg)
|
|
268
|
+
await self._converge_room_attributes(gm)
|
|
269
|
+
|
|
270
|
+
async def _converge_power_levels(
|
|
271
|
+
self,
|
|
272
|
+
room_id: str,
|
|
273
|
+
group_pk: str,
|
|
274
|
+
users: list[MappedUser],
|
|
275
|
+
pl_groups: list[PowerLevelGroup],
|
|
276
|
+
room_cfg: SyncMatrixRoomsBasedOnAuthentikGroups,
|
|
277
|
+
) -> None:
|
|
278
|
+
members_in_room = [u for u in users if group_pk in u.group_pks]
|
|
279
|
+
managed = {u.mxid for u in members_in_room}
|
|
280
|
+
if not managed:
|
|
281
|
+
return
|
|
282
|
+
desired = compute_desired_user_levels(
|
|
283
|
+
members_in_room,
|
|
284
|
+
pl_groups,
|
|
285
|
+
make_superusers_admin=room_cfg.make_authentik_superusers_matrix_room_admin,
|
|
286
|
+
)
|
|
287
|
+
current = await self.effectors.get_room_power_levels(room_id)
|
|
288
|
+
current_users = dict(current.get("users", {}))
|
|
289
|
+
merged = merge_power_levels(current_users, desired, managed)
|
|
290
|
+
if merged != current_users:
|
|
291
|
+
new_levels = {**current, "users": merged}
|
|
292
|
+
await self.effectors.set_room_power_levels(room_id, new_levels)
|
|
293
|
+
|
|
294
|
+
async def _converge_room_attributes(self, gm: GroupRoomMap) -> None:
|
|
295
|
+
if not self.config.matrix_room_default_settings.keep_updating_matrix_attributes_from_authentik:
|
|
296
|
+
return
|
|
297
|
+
assert gm.room is not None
|
|
298
|
+
if gm.desired.name is not None and gm.room.name != gm.desired.name:
|
|
299
|
+
await self.effectors.set_room_name(gm.room.room_id, gm.desired.name)
|
|
300
|
+
if gm.desired.topic is not None and gm.room.topic != gm.desired.topic:
|
|
301
|
+
await self.effectors.set_room_topic(gm.room.room_id, gm.desired.topic)
|
|
302
|
+
|
|
303
|
+
# --- lifecycle (AD-5, G9.*): quarantined, invoked only from the reconcile result ---
|
|
304
|
+
|
|
305
|
+
async def _converge_lifecycle(self, matrix_users: list[dict[str, Any]], active_mxids: set[str]) -> None:
|
|
306
|
+
if self.lifecycle is None:
|
|
307
|
+
return
|
|
308
|
+
sync_cfg = self.config.sync_authentik_users_with_matrix_rooms
|
|
309
|
+
if not sync_cfg.deactivate_disabled_authentik_users_in_matrix.enabled:
|
|
310
|
+
return
|
|
311
|
+
orphaned = await self._gather_orphaned_mxids(matrix_users, active_mxids)
|
|
312
|
+
await self.lifecycle.reconcile_accounts(orphaned)
|
|
313
|
+
|
|
314
|
+
async def _gather_orphaned_mxids(
|
|
315
|
+
self, matrix_users: list[dict[str, Any]], active_mxids: set[str]
|
|
316
|
+
) -> set[str]:
|
|
317
|
+
"""MXIDs whose Authentik account is disabled but a Matrix account still exists (G9.1).
|
|
318
|
+
|
|
319
|
+
Scoped to *disabled* Authentik users we can positively map — never sweeping arbitrary Matrix
|
|
320
|
+
accounts that simply lack an Authentik counterpart (e.g. admin/service users), so the
|
|
321
|
+
destructive path can only ever touch accounts we provisioned from a now-disabled directory
|
|
322
|
+
entry. The bot user and the ignore lists (G12.1) are always excluded.
|
|
323
|
+
"""
|
|
324
|
+
cfg = self.config.sync_authentik_users_with_matrix_rooms
|
|
325
|
+
disabled_users = await self.authentik.list_users(
|
|
326
|
+
filter_by_attribute=cfg.sync_only_users_with_authentik_attributes,
|
|
327
|
+
filter_is_active=False,
|
|
328
|
+
)
|
|
329
|
+
matrix_mxids = {u["name"] for u in matrix_users}
|
|
330
|
+
bot_id = self.config.synapse_server.bot_user_id
|
|
331
|
+
orphaned: set[str] = set()
|
|
332
|
+
for user in disabled_users:
|
|
333
|
+
if user.get("username") in self.config.authentik_user_ignore_list:
|
|
334
|
+
continue
|
|
335
|
+
try:
|
|
336
|
+
mxid = compute_mxid(
|
|
337
|
+
user,
|
|
338
|
+
username_attribute=cfg.authentik_username_mapping_attribute,
|
|
339
|
+
server_name=self.server_name,
|
|
340
|
+
)
|
|
341
|
+
except KeyError:
|
|
342
|
+
continue
|
|
343
|
+
if mxid == bot_id or mxid in self.config.matrix_user_ignore_list:
|
|
344
|
+
continue
|
|
345
|
+
if mxid in active_mxids or mxid not in matrix_mxids:
|
|
346
|
+
continue
|
|
347
|
+
orphaned.add(mxid)
|
|
348
|
+
return orphaned
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Group membership → room membership (pure diff logic).
|
|
2
|
+
|
|
3
|
+
Ported from legacy ``Bot.sync_users_and_rooms`` / ``sync_users_and_space``. Fixes the BATTLE_PLAN §3
|
|
4
|
+
bug where membership was tested with substring ``in`` (``grp["pk"] in room...["pk"]``) instead of
|
|
5
|
+
set membership — here it is exact set containment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from onbot.models import MappedUser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class MembershipDiff:
|
|
18
|
+
to_add: list[str] = field(default_factory=list)
|
|
19
|
+
to_kick: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def desired_room_members(group_pk: str, mapped_users: Iterable[MappedUser]) -> set[str]:
|
|
23
|
+
"""MXIDs that should be in the room mapped to ``group_pk`` (members of that Authentik group)."""
|
|
24
|
+
return {u.mxid for u in mapped_users if group_pk in u.group_pks}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def diff_room_membership(
|
|
28
|
+
desired_mxids: set[str],
|
|
29
|
+
actual_member_ids: Iterable[str],
|
|
30
|
+
*,
|
|
31
|
+
kick_enabled: bool,
|
|
32
|
+
protected_ids: Iterable[str] = (),
|
|
33
|
+
) -> MembershipDiff:
|
|
34
|
+
"""Compute adds/kicks to converge actual room membership onto ``desired_mxids``.
|
|
35
|
+
|
|
36
|
+
``kick_enabled`` gates removals (G3.2 is toggleable). ``protected_ids`` (e.g. the bot itself) are
|
|
37
|
+
never kicked.
|
|
38
|
+
"""
|
|
39
|
+
actual = set(actual_member_ids)
|
|
40
|
+
protected = set(protected_ids)
|
|
41
|
+
to_add = sorted(desired_mxids - actual)
|
|
42
|
+
to_kick: list[str] = []
|
|
43
|
+
if kick_enabled:
|
|
44
|
+
to_kick = sorted((actual - desired_mxids) - protected)
|
|
45
|
+
return MembershipDiff(to_add=to_add, to_kick=to_kick)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def diff_space_membership(
|
|
49
|
+
mapped_users: Iterable[MappedUser], actual_member_ids: Iterable[str]
|
|
50
|
+
) -> MembershipDiff:
|
|
51
|
+
"""All synced users should be in the parent space (G1.3); we only ever add, never kick."""
|
|
52
|
+
desired = {u.mxid for u in mapped_users}
|
|
53
|
+
to_add = sorted(desired - set(actual_member_ids))
|
|
54
|
+
return MembershipDiff(to_add=to_add)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Per-room power-level computation (pure logic).
|
|
2
|
+
|
|
3
|
+
Ported from legacy ``power_level_manager.py`` and made side-effect-free. Two improvements over
|
|
4
|
+
legacy:
|
|
5
|
+
|
|
6
|
+
* **Withdrawal (G8.4):** legacy left elevated power levels sticky forever. Here we compute the full
|
|
7
|
+
*desired* level for every managed room member and :func:`merge_power_levels` removes the explicit
|
|
8
|
+
entry for any managed user who no longer qualifies — converging both up and down (AD-2).
|
|
9
|
+
* **Highest wins (G8.3):** when a user is in several power-level groups we take the max; superuser
|
|
10
|
+
room-admin (G8.2) overrides group levels.
|
|
11
|
+
|
|
12
|
+
Non-managed users (e.g. the bot, manually-promoted accounts) are never touched.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from onbot.models import MappedUser
|
|
22
|
+
from onbot.utils import get_nested_dict_val_by_path
|
|
23
|
+
|
|
24
|
+
ROOM_ADMIN_LEVEL = 100
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class PowerLevelGroup:
|
|
29
|
+
"""An Authentik group that confers a Matrix power level on its members."""
|
|
30
|
+
|
|
31
|
+
member_pks: set[str]
|
|
32
|
+
level: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_power_level_groups(
|
|
36
|
+
groups: Iterable[dict[str, Any]], attribute_path: str
|
|
37
|
+
) -> list[PowerLevelGroup]:
|
|
38
|
+
"""Build :class:`PowerLevelGroup` entries from Authentik groups carrying the power-level attr.
|
|
39
|
+
|
|
40
|
+
``attribute_path`` is the attribute name/path *within* the group's ``attributes`` object (the
|
|
41
|
+
config value of ``authentik_group_attr_for_matrix_power_level``).
|
|
42
|
+
"""
|
|
43
|
+
key_path = ("attributes." + attribute_path).split(".")
|
|
44
|
+
result: list[PowerLevelGroup] = []
|
|
45
|
+
for group in groups:
|
|
46
|
+
level = get_nested_dict_val_by_path(group, key_path, fallback_val=None)
|
|
47
|
+
if not isinstance(level, int) or isinstance(level, bool):
|
|
48
|
+
continue
|
|
49
|
+
result.append(PowerLevelGroup(member_pks=set(group.get("users", [])), level=level))
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compute_desired_user_levels(
|
|
54
|
+
members: Iterable[MappedUser],
|
|
55
|
+
power_level_groups: Iterable[PowerLevelGroup],
|
|
56
|
+
*,
|
|
57
|
+
make_superusers_admin: bool,
|
|
58
|
+
admin_level: int = ROOM_ADMIN_LEVEL,
|
|
59
|
+
) -> dict[str, int]:
|
|
60
|
+
"""Desired explicit power level per managed member who qualifies for one (> default).
|
|
61
|
+
|
|
62
|
+
Members with no group level and no superuser admin are omitted (they should sit at the room
|
|
63
|
+
default); :func:`merge_power_levels` turns that omission into a withdrawal.
|
|
64
|
+
"""
|
|
65
|
+
groups = list(power_level_groups)
|
|
66
|
+
desired: dict[str, int] = {}
|
|
67
|
+
for member in members:
|
|
68
|
+
level: int | None = None
|
|
69
|
+
member_pk = member.authentik_obj.get("pk")
|
|
70
|
+
for group in groups:
|
|
71
|
+
if member_pk in group.member_pks:
|
|
72
|
+
level = group.level if level is None else max(level, group.level)
|
|
73
|
+
if make_superusers_admin and member.is_superuser:
|
|
74
|
+
level = admin_level
|
|
75
|
+
if level is not None:
|
|
76
|
+
desired[member.mxid] = level
|
|
77
|
+
return desired
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def merge_power_levels(
|
|
81
|
+
current_users: dict[str, int],
|
|
82
|
+
desired: dict[str, int],
|
|
83
|
+
managed_mxids: set[str],
|
|
84
|
+
) -> dict[str, int]:
|
|
85
|
+
"""Merge desired levels into the current ``users`` map, withdrawing stale managed entries.
|
|
86
|
+
|
|
87
|
+
Only ``managed_mxids`` are added/updated/removed; everyone else is left untouched. Returns a new
|
|
88
|
+
dict (the caller decides whether it changed before writing).
|
|
89
|
+
"""
|
|
90
|
+
merged = dict(current_users)
|
|
91
|
+
for mxid in managed_mxids:
|
|
92
|
+
if mxid in desired:
|
|
93
|
+
merged[mxid] = desired[mxid]
|
|
94
|
+
else:
|
|
95
|
+
merged.pop(mxid, None)
|
|
96
|
+
return merged
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Group → room projection (pure logic).
|
|
2
|
+
|
|
3
|
+
Ported from the legacy ``Bot._get_authentik_groups_to_synapse_room_mappings`` and
|
|
4
|
+
``_get_matrix_room_attrs_from_authentik_group``, split into side-effect-free functions so the
|
|
5
|
+
mapping rules (the valuable business logic per BATTLE_PLAN §3) can be unit-tested without any API.
|
|
6
|
+
|
|
7
|
+
Bugs fixed vs legacy:
|
|
8
|
+
|
|
9
|
+
* ``matrix_room_create_params_from_authentik_attribute`` is now split into a key path before lookup
|
|
10
|
+
(legacy passed the raw dotted string to ``get_nested_dict_val_by_path``, so it never matched).
|
|
11
|
+
* the create-params attribute default is ``attributes.chatroom_params`` (legacy ``attribute.…`` typo).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from onbot.config import MatrixDynamicRoomSettings, OnbotConfig
|
|
20
|
+
from onbot.identity import build_canonical
|
|
21
|
+
from onbot.logging import get_logger
|
|
22
|
+
from onbot.models import GroupRoomMap, MatrixRoom, RoomCreateAttributes
|
|
23
|
+
from onbot.utils import get_nested_dict_val_by_path
|
|
24
|
+
|
|
25
|
+
log = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def filter_synced_groups(groups: list[dict[str, Any]], config: OnbotConfig) -> list[dict[str, Any]]:
|
|
29
|
+
"""Apply the selective group→room rules (G2.5): ignore list, name prefix, parentage.
|
|
30
|
+
|
|
31
|
+
The attribute filter (``only_groups_with_attributes``) is applied at the Authentik query level,
|
|
32
|
+
so it is not repeated here.
|
|
33
|
+
"""
|
|
34
|
+
settings = config.sync_matrix_rooms_based_on_authentik_groups
|
|
35
|
+
result = groups
|
|
36
|
+
if config.authentik_group_id_ignore_list:
|
|
37
|
+
ignore = set(config.authentik_group_id_ignore_list)
|
|
38
|
+
result = [g for g in result if g["pk"] not in ignore]
|
|
39
|
+
if settings.only_for_groupnames_starting_with:
|
|
40
|
+
prefix = settings.only_for_groupnames_starting_with
|
|
41
|
+
result = [g for g in result if g["name"].startswith(prefix)]
|
|
42
|
+
if settings.only_for_children_of_groups_with_uid:
|
|
43
|
+
parents = set(settings.only_for_children_of_groups_with_uid)
|
|
44
|
+
result = [g for g in result if g.get("parent") in parents]
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_room_settings(group_pk: str, config: OnbotConfig) -> MatrixDynamicRoomSettings:
|
|
49
|
+
"""Merge per-group overrides (G6.7) over the default room settings."""
|
|
50
|
+
defaults = config.matrix_room_default_settings
|
|
51
|
+
override = config.per_authentik_group_pk_matrix_room_settings.get(group_pk)
|
|
52
|
+
if override is None:
|
|
53
|
+
return defaults
|
|
54
|
+
return MatrixDynamicRoomSettings.model_validate(defaults.model_dump() | override.model_dump())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compute_room_attributes(
|
|
58
|
+
group: dict[str, Any], config: OnbotConfig, server_name: str
|
|
59
|
+
) -> RoomCreateAttributes:
|
|
60
|
+
"""Compute the desired Matrix room attributes for an Authentik group (G6.1-G6.6)."""
|
|
61
|
+
settings = resolve_room_settings(group["pk"], config)
|
|
62
|
+
|
|
63
|
+
alias_base = get_nested_dict_val_by_path(
|
|
64
|
+
group, settings.matrix_alias_from_authentik_attribute.split("."), fallback_val=None
|
|
65
|
+
)
|
|
66
|
+
if not alias_base:
|
|
67
|
+
alias_base = group["pk"]
|
|
68
|
+
alias = f"{settings.alias_prefix or ''}{alias_base}"
|
|
69
|
+
# Matrix room aliases keep things simple: drop dashes (legacy behaviour; preserves alias matching).
|
|
70
|
+
alias = alias.replace("-", "")
|
|
71
|
+
|
|
72
|
+
name = get_nested_dict_val_by_path(
|
|
73
|
+
group, settings.matrix_name_from_authentik_attribute.split("."), fallback_val=None
|
|
74
|
+
)
|
|
75
|
+
if settings.name_prefix and name and not name.startswith(settings.name_prefix):
|
|
76
|
+
name = f"{settings.name_prefix}{name}"
|
|
77
|
+
|
|
78
|
+
topic: str | None = None
|
|
79
|
+
if settings.matrix_topic_from_authentik_attribute:
|
|
80
|
+
topic = get_nested_dict_val_by_path(
|
|
81
|
+
group, settings.matrix_topic_from_authentik_attribute.split("."), fallback_val=None
|
|
82
|
+
)
|
|
83
|
+
if settings.topic_prefix:
|
|
84
|
+
topic = f"{settings.topic_prefix}{topic or ''}"
|
|
85
|
+
|
|
86
|
+
room_params = dict(settings.default_room_create_params or {})
|
|
87
|
+
if settings.matrix_room_create_params_from_authentik_attribute:
|
|
88
|
+
raw = get_nested_dict_val_by_path(
|
|
89
|
+
group,
|
|
90
|
+
settings.matrix_room_create_params_from_authentik_attribute.split("."),
|
|
91
|
+
fallback_val=None,
|
|
92
|
+
)
|
|
93
|
+
if raw:
|
|
94
|
+
try:
|
|
95
|
+
room_params = room_params | json.loads(raw)
|
|
96
|
+
except json.JSONDecodeError, TypeError:
|
|
97
|
+
log.warning("Group %s has invalid room-create-params JSON: %r", group["pk"], raw)
|
|
98
|
+
|
|
99
|
+
return RoomCreateAttributes(
|
|
100
|
+
alias=alias,
|
|
101
|
+
canonical_alias=build_canonical(alias, server_name, "#"),
|
|
102
|
+
name=name,
|
|
103
|
+
topic=topic,
|
|
104
|
+
room_params=room_params,
|
|
105
|
+
encrypted=settings.end2end_encryption_enabled,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_group_room_maps(
|
|
110
|
+
groups: list[dict[str, Any]],
|
|
111
|
+
actual_rooms: list[MatrixRoom],
|
|
112
|
+
config: OnbotConfig,
|
|
113
|
+
server_name: str,
|
|
114
|
+
) -> list[GroupRoomMap]:
|
|
115
|
+
"""Project filtered groups to desired rooms, pairing each with its actual room if present."""
|
|
116
|
+
rooms_by_alias = {r.canonical_alias: r for r in actual_rooms if r.canonical_alias}
|
|
117
|
+
maps: list[GroupRoomMap] = []
|
|
118
|
+
for group in filter_synced_groups(groups, config):
|
|
119
|
+
desired = compute_room_attributes(group, config, server_name)
|
|
120
|
+
maps.append(
|
|
121
|
+
GroupRoomMap(
|
|
122
|
+
authentik_group=group,
|
|
123
|
+
desired=desired,
|
|
124
|
+
room=rooms_by_alias.get(desired.canonical_alias),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return maps
|