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/events.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tiny async signal bus (AD-4).
|
|
2
|
+
|
|
3
|
+
Explicit, in-process coupling between the bounded domains: the reconciler emits signals (e.g. a
|
|
4
|
+
user was synced) and onboarding (Phase 4) subscribes. Kept deliberately minimal — no broker, no
|
|
5
|
+
persistence — but designed so a domain could later move to its own process behind the same API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from onbot.logging import get_logger
|
|
18
|
+
|
|
19
|
+
log = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Signal(StrEnum):
|
|
23
|
+
user_synced = "user_synced"
|
|
24
|
+
drift_detected = "drift_detected"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Event:
|
|
29
|
+
signal: Signal
|
|
30
|
+
payload: dict[str, Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
Handler = Callable[[Event], Awaitable[None]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EventBus:
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._handlers: dict[Signal, list[Handler]] = defaultdict(list)
|
|
39
|
+
|
|
40
|
+
def subscribe(self, signal: Signal, handler: Handler) -> None:
|
|
41
|
+
self._handlers[signal].append(handler)
|
|
42
|
+
|
|
43
|
+
async def emit(self, signal: Signal, **payload: Any) -> None:
|
|
44
|
+
handlers = self._handlers.get(signal, [])
|
|
45
|
+
if not handlers:
|
|
46
|
+
return
|
|
47
|
+
event = Event(signal=signal, payload=payload)
|
|
48
|
+
results = await asyncio.gather(*(h(event) for h in handlers), return_exceptions=True)
|
|
49
|
+
for result in results:
|
|
50
|
+
if isinstance(result, Exception):
|
|
51
|
+
log.exception("event handler for %s failed", signal, exc_info=result)
|
onbot/healthcheck.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Dependency health probe for ``onbot healthcheck`` (BATTLE_PLAN §5 Phase 8).
|
|
2
|
+
|
|
3
|
+
Probes the services the bot actually talks to — the Matrix CS API, the Synapse admin API, the
|
|
4
|
+
Authentik API, and (when configured) the MAS admin API — using the *real* configured credentials, so
|
|
5
|
+
the check verifies connectivity **and** authorization, not just that a port is open. Each probe is a
|
|
6
|
+
single lightweight authenticated request.
|
|
7
|
+
|
|
8
|
+
Exit code contract (suitable for a container ``HEALTHCHECK`` / orchestrator readiness probe):
|
|
9
|
+
|
|
10
|
+
* ``0`` — every required dependency answered successfully.
|
|
11
|
+
* ``1`` — at least one probe failed (unreachable, auth rejected, or unexpected response).
|
|
12
|
+
|
|
13
|
+
The bot user id from ``/whoami`` is compared against ``synapse_server.bot_user_id``; a mismatch is a
|
|
14
|
+
warning (the token authenticates as a different user than configured) but not a hard failure, since
|
|
15
|
+
the bot can still operate as whoever the token belongs to.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from onbot.app import build_matrix_token_provider
|
|
24
|
+
from onbot.auth.token_provider import OAuth2ClientCredentialsTokenProvider
|
|
25
|
+
from onbot.clients.authentik import ApiClientAuthentik
|
|
26
|
+
from onbot.clients.mas_admin import ApiClientMasAdmin, mxid_localpart
|
|
27
|
+
from onbot.clients.matrix import ApiClientMatrix
|
|
28
|
+
from onbot.clients.synapse_admin import ApiClientSynapseAdmin
|
|
29
|
+
from onbot.config import OnbotConfig
|
|
30
|
+
from onbot.logging import get_logger
|
|
31
|
+
|
|
32
|
+
log = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ProbeResult:
|
|
37
|
+
"""Outcome of a single dependency probe."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
ok: bool
|
|
41
|
+
detail: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _probe_matrix(matrix: ApiClientMatrix, expected_user_id: str) -> ProbeResult:
|
|
45
|
+
"""Reach the CS API and confirm the bot token authenticates (``/whoami``)."""
|
|
46
|
+
whoami = await matrix.get_json("v3/account/whoami")
|
|
47
|
+
user_id = whoami.get("user_id", "")
|
|
48
|
+
if user_id != expected_user_id:
|
|
49
|
+
return ProbeResult(
|
|
50
|
+
"matrix-cs",
|
|
51
|
+
True,
|
|
52
|
+
f"reachable, but token is @{user_id} (config expects {expected_user_id})",
|
|
53
|
+
)
|
|
54
|
+
return ProbeResult("matrix-cs", True, f"authenticated as {user_id}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _probe_synapse_admin(admin: ApiClientSynapseAdmin) -> ProbeResult:
|
|
58
|
+
"""Confirm the admin token is authorized against the Synapse admin API."""
|
|
59
|
+
# A minimal authenticated read; ``guests=false`` is required under MSC3861/MAS (synapse_admin.py).
|
|
60
|
+
await admin.get_json("v2/users", params={"limit": 1, "guests": "false"})
|
|
61
|
+
return ProbeResult("synapse-admin", True, "admin API authorized")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _probe_authentik(authentik: ApiClientAuthentik) -> ProbeResult:
|
|
65
|
+
"""Confirm the Authentik API token works."""
|
|
66
|
+
await authentik.get_json("core/users/", params={"page_size": 1})
|
|
67
|
+
return ProbeResult("authentik", True, "API token accepted")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _probe_mas_admin(mas: ApiClientMasAdmin, bot_user_id: str) -> ProbeResult:
|
|
71
|
+
"""Confirm the MAS admin client-credentials token works (lifecycle enforcement path, §7 Q1)."""
|
|
72
|
+
# ``by-username`` returns the user or 404 (both prove auth); the lookup itself never mutates.
|
|
73
|
+
await mas.get_user_id_by_username(mxid_localpart(bot_user_id))
|
|
74
|
+
return ProbeResult("mas-admin", True, "admin API authorized")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def run_healthcheck(config: OnbotConfig) -> int:
|
|
78
|
+
"""Probe every configured dependency, log a line per result, and return an exit code."""
|
|
79
|
+
token_provider = build_matrix_token_provider(config.synapse_server)
|
|
80
|
+
authentik = ApiClientAuthentik(url=config.authentik_server.url, api_key=config.authentik_server.api_key)
|
|
81
|
+
admin = ApiClientSynapseAdmin(
|
|
82
|
+
server_url=config.synapse_server.server_url,
|
|
83
|
+
token_provider=token_provider,
|
|
84
|
+
admin_api_path=config.synapse_server.admin_api_path,
|
|
85
|
+
)
|
|
86
|
+
matrix = ApiClientMatrix(
|
|
87
|
+
server_url=config.synapse_server.server_url,
|
|
88
|
+
token_provider=token_provider,
|
|
89
|
+
server_name=config.synapse_server.server_name,
|
|
90
|
+
)
|
|
91
|
+
mas: ApiClientMasAdmin | None = None
|
|
92
|
+
if config.mas_admin is not None:
|
|
93
|
+
mas = ApiClientMasAdmin(
|
|
94
|
+
mas_url=config.mas_admin.url,
|
|
95
|
+
token_provider=OAuth2ClientCredentialsTokenProvider(
|
|
96
|
+
token_endpoint=f"{config.mas_admin.url.rstrip('/')}/oauth2/token",
|
|
97
|
+
client_id=config.mas_admin.client_id,
|
|
98
|
+
client_secret=config.mas_admin.client_secret,
|
|
99
|
+
scope="urn:mas:admin",
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
probes = [
|
|
104
|
+
_probe_matrix(matrix, config.synapse_server.bot_user_id),
|
|
105
|
+
_probe_synapse_admin(admin),
|
|
106
|
+
_probe_authentik(authentik),
|
|
107
|
+
]
|
|
108
|
+
names = ["matrix-cs", "synapse-admin", "authentik"]
|
|
109
|
+
if mas is not None:
|
|
110
|
+
probes.append(_probe_mas_admin(mas, config.synapse_server.bot_user_id))
|
|
111
|
+
names.append("mas-admin")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
results = await asyncio.gather(*probes, return_exceptions=True)
|
|
115
|
+
finally:
|
|
116
|
+
await authentik.aclose()
|
|
117
|
+
await admin.aclose()
|
|
118
|
+
await matrix.aclose()
|
|
119
|
+
if mas is not None:
|
|
120
|
+
await mas.aclose()
|
|
121
|
+
|
|
122
|
+
failed = False
|
|
123
|
+
for name, result in zip(names, results, strict=True):
|
|
124
|
+
if isinstance(result, ProbeResult):
|
|
125
|
+
log.info("healthcheck %-14s OK — %s", result.name, result.detail)
|
|
126
|
+
else:
|
|
127
|
+
failed = True
|
|
128
|
+
log.error("healthcheck %-14s FAIL — %r", name, result)
|
|
129
|
+
|
|
130
|
+
if failed:
|
|
131
|
+
log.error("healthcheck: one or more dependencies are unhealthy")
|
|
132
|
+
return 1
|
|
133
|
+
log.info("healthcheck: all dependencies healthy")
|
|
134
|
+
return 0
|
onbot/identity.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""MXID / localpart mapping (AD-6 — the critical MAS integration contract).
|
|
2
|
+
|
|
3
|
+
Under MAS, accounts are auto-provisioned on first login and the MXID localpart is derived by MAS
|
|
4
|
+
from an upstream Authentik claim. The bot does **not** create accounts, but it must compute the
|
|
5
|
+
*same* MXID to match users to their Matrix accounts. The localpart source is configurable
|
|
6
|
+
(``sync_authentik_users_with_matrix_rooms.authentik_username_mapping_attribute``) and MUST agree
|
|
7
|
+
with the template MAS uses, otherwise users won't match (BATTLE_PLAN §7 Q2).
|
|
8
|
+
|
|
9
|
+
Shared by the reconciler (membership, power levels) and, later, onboarding (Phase 4).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from onbot.utils import get_nested_dict_val_by_path
|
|
17
|
+
|
|
18
|
+
SigilT = Literal["@", "#", "!"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_canonical(local_part: str, server_name: str, sigil: SigilT = "@") -> str:
|
|
22
|
+
"""Build a fully-qualified Matrix identifier: ``<sigil><local_part>:<server_name>``.
|
|
23
|
+
|
|
24
|
+
``@`` → user ID, ``#`` → room alias, ``!`` → room/space ID.
|
|
25
|
+
"""
|
|
26
|
+
return f"{sigil}{local_part}:{server_name}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_mxid(
|
|
30
|
+
authentik_user: dict[str, Any],
|
|
31
|
+
*,
|
|
32
|
+
username_attribute: str,
|
|
33
|
+
server_name: str,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Compute the deterministic MXID for an Authentik user (G1.2, AD-6).
|
|
36
|
+
|
|
37
|
+
``username_attribute`` is a dotted path into the Authentik user object (e.g. ``username`` or
|
|
38
|
+
``attributes.matrix_name``). Raises ``KeyError`` if the attribute is missing — a user we cannot
|
|
39
|
+
map deterministically must surface loudly, never be silently skipped.
|
|
40
|
+
"""
|
|
41
|
+
local_part = get_nested_dict_val_by_path(authentik_user, username_attribute.split("."))
|
|
42
|
+
if local_part is None or local_part == "":
|
|
43
|
+
raise KeyError(username_attribute)
|
|
44
|
+
return build_canonical(str(local_part), server_name, "@")
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Quarantined account lifecycle (AD-5, G9.*): the scariest code in the repo.
|
|
2
|
+
|
|
3
|
+
When an Authentik account is disabled (or removed), the matching Matrix account must eventually be
|
|
4
|
+
locked out and, optionally, erased. This is destructive and irreversible past a point, so the module
|
|
5
|
+
is built around three safety principles:
|
|
6
|
+
|
|
7
|
+
* **Cooldowns absorb accidents (G12.3).** Detection only *marks* a user; nothing destructive happens
|
|
8
|
+
until ``deactivate_after_n_sec`` has elapsed, and erasure waits a further ``delete_after_n_sec``.
|
|
9
|
+
A user who reappears in Authentik before erasure is silently re-enabled (G9.6).
|
|
10
|
+
* **Dry-run + audit by default (Q6, AD-5).** Destructive effector calls are gated on an explicit
|
|
11
|
+
``dry_run=False``; until an operator opts in, the manager only records bookkeeping timestamps and
|
|
12
|
+
logs an audit line describing what *would* happen.
|
|
13
|
+
* **Pure decision, isolated effects.** :func:`decide_account_action` is a side-effect-free state
|
|
14
|
+
machine (exhaustively unit-tested); all I/O lives behind the :class:`LifecycleEffectors` and
|
|
15
|
+
:class:`LifecycleLedgerStore` seams.
|
|
16
|
+
|
|
17
|
+
**Two-stage semantics (legacy-faithful, GOALS-aligned).** Stage one (``deactivate_after_n_sec``)
|
|
18
|
+
*logs the user out* — revoking every device/session (G9.2). Under the MAS topology (AD-6) this is the
|
|
19
|
+
effective lock-out: re-login flows through MAS→Authentik, which already blocks the disabled upstream
|
|
20
|
+
user. It is also reversible, which keeps the G9.6 re-enable window open. Stage two
|
|
21
|
+
(``delete_after_n_sec``) *erases* the account (G9.4) and optionally its uploaded media (G9.5).
|
|
22
|
+
|
|
23
|
+
Whether MAS *itself* propagates the upstream disable (revoking sessions / locking the account) is an
|
|
24
|
+
open question for the maintainer (BATTLE_PLAN §7 Q1); this module is the enforcement backstop either
|
|
25
|
+
way.
|
|
26
|
+
|
|
27
|
+
There is no database (AD-1): the per-user bookkeeping (mark/disable timestamps) is persisted as a
|
|
28
|
+
single blob in the bot's Matrix *account data*, keyed by MXID — decoupled from the onboarding DM.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from enum import StrEnum
|
|
36
|
+
from typing import Any, Protocol, runtime_checkable
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
|
|
40
|
+
from onbot.config import OnbotConfig
|
|
41
|
+
from onbot.logging import get_logger
|
|
42
|
+
from onbot.reconciler.state import SCHEMA_VERSION, event_type_name
|
|
43
|
+
|
|
44
|
+
log = get_logger(__name__)
|
|
45
|
+
# Dedicated audit channel so operators can route the irreversible-action trail separately.
|
|
46
|
+
audit = get_logger("onbot.lifecycle.audit")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lifecycle_account_data_type(server_name: str) -> str:
|
|
50
|
+
"""Account-data type holding the lifecycle ledger, e.g. ``org.company.onbot.lifecycle``."""
|
|
51
|
+
return event_type_name(server_name, "lifecycle")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LifecycleAction(StrEnum):
|
|
55
|
+
none = "none" # active user, no bookkeeping to do
|
|
56
|
+
reenable = "reenable" # previously marked/disabled but active again → clear (G9.6)
|
|
57
|
+
mark = "mark" # newly detected orphan → start the cooldown clock (no destructive action)
|
|
58
|
+
logout = "logout" # cooldown elapsed → revoke sessions (G9.2)
|
|
59
|
+
erase = "erase" # further cooldown elapsed → deactivate/erase account (G9.4/G9.5)
|
|
60
|
+
wait = "wait" # in a cooldown window → do nothing yet
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decide_account_action(
|
|
64
|
+
*,
|
|
65
|
+
is_orphaned: bool,
|
|
66
|
+
marked_ts: float | None,
|
|
67
|
+
disabled_ts: float | None,
|
|
68
|
+
now: float,
|
|
69
|
+
deactivate_after_n_sec: float,
|
|
70
|
+
delete_after_n_sec: float | None,
|
|
71
|
+
) -> LifecycleAction:
|
|
72
|
+
"""Pure state machine: given a user's orphan status and timestamps, decide the next action.
|
|
73
|
+
|
|
74
|
+
``marked_ts`` is when the user was first seen orphaned; ``disabled_ts`` is when they were logged
|
|
75
|
+
out. ``delete_after_n_sec=None`` disables erasure entirely (a logged-out user simply stays so).
|
|
76
|
+
"""
|
|
77
|
+
has_state = marked_ts is not None or disabled_ts is not None
|
|
78
|
+
if not is_orphaned:
|
|
79
|
+
return LifecycleAction.reenable if has_state else LifecycleAction.none
|
|
80
|
+
if marked_ts is None:
|
|
81
|
+
return LifecycleAction.mark
|
|
82
|
+
if disabled_ts is None:
|
|
83
|
+
if now - marked_ts >= deactivate_after_n_sec:
|
|
84
|
+
return LifecycleAction.logout
|
|
85
|
+
return LifecycleAction.wait
|
|
86
|
+
if delete_after_n_sec is not None and now - disabled_ts >= delete_after_n_sec:
|
|
87
|
+
return LifecycleAction.erase
|
|
88
|
+
return LifecycleAction.wait
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LifecycleEntry(BaseModel):
|
|
92
|
+
"""Per-user bookkeeping for the lifecycle state machine."""
|
|
93
|
+
|
|
94
|
+
marked_for_disabling_timestamp: float | None = None
|
|
95
|
+
disabled_user_timestamp: float | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LifecycleLedger(BaseModel):
|
|
99
|
+
"""Versioned blob of every tracked user's lifecycle state (stored in bot account data)."""
|
|
100
|
+
|
|
101
|
+
schema_version: int = SCHEMA_VERSION
|
|
102
|
+
entries: dict[str, LifecycleEntry] = Field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@runtime_checkable
|
|
106
|
+
class LifecycleEffectors(Protocol):
|
|
107
|
+
"""The destructive Matrix/Synapse operations, isolated behind a seam (separate boundary, AD-5)."""
|
|
108
|
+
|
|
109
|
+
async def logout(self, mxid: str) -> None:
|
|
110
|
+
"""Revoke every session for ``mxid`` (G9.2). Reversible (see :meth:`reenable`)."""
|
|
111
|
+
|
|
112
|
+
async def reenable(self, mxid: str) -> None:
|
|
113
|
+
"""Reverse a non-destructive logout when the user returns (G9.6). May be a no-op."""
|
|
114
|
+
|
|
115
|
+
async def erase(self, mxid: str, *, delete_media: bool) -> None:
|
|
116
|
+
"""Deactivate/erase the account (G9.4) and optionally its uploaded media (G9.5)."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AdminApiLifecycleEffectors:
|
|
120
|
+
"""Concrete effectors over the Synapse Admin API.
|
|
121
|
+
|
|
122
|
+
NOTE (BATTLE_PLAN §7 Q1, verified by the Phase 7b harness): under MAS these do **not** revoke a
|
|
123
|
+
live MAS-issued session — deleting devices/deactivating in Synapse leaves the MAS token valid.
|
|
124
|
+
Use :class:`MasLifecycleEffectors` for real enforcement under the MAS topology (ADR-0006). These
|
|
125
|
+
remain correct for non-MAS Synapse deployments.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, admin: Any) -> None:
|
|
129
|
+
self.admin = admin
|
|
130
|
+
|
|
131
|
+
async def logout(self, mxid: str) -> None:
|
|
132
|
+
await self.admin.logout_account(mxid)
|
|
133
|
+
|
|
134
|
+
async def reenable(self, mxid: str) -> None:
|
|
135
|
+
# Synapse logout deletes devices; there is nothing to restore — the user logs in afresh.
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
async def erase(self, mxid: str, *, delete_media: bool) -> None:
|
|
139
|
+
if delete_media:
|
|
140
|
+
await self.admin.delete_user_media(mxid)
|
|
141
|
+
await self.admin.deactivate_account(mxid, erase=delete_media)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MasLifecycleEffectors:
|
|
145
|
+
"""Effectors that enforce lockout through the **MAS admin API** (ADR-0006, §7 Q1).
|
|
146
|
+
|
|
147
|
+
This is the path that actually works under MAS: ``logout`` locks the MAS user (revoking live
|
|
148
|
+
sessions, reversibly), ``reenable`` unlocks them when their upstream account returns (G9.6), and
|
|
149
|
+
``erase`` deactivates the account irreversibly. Media deletion still goes through the Synapse
|
|
150
|
+
admin API (MAS does not own media).
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, mas_admin: Any, synapse_admin: Any | None = None) -> None:
|
|
154
|
+
self.mas = mas_admin
|
|
155
|
+
self.synapse_admin = synapse_admin
|
|
156
|
+
|
|
157
|
+
async def _resolve(self, mxid: str) -> str | None:
|
|
158
|
+
from onbot.clients.mas_admin import mxid_localpart
|
|
159
|
+
|
|
160
|
+
uid: str | None = await self.mas.get_user_id_by_username(mxid_localpart(mxid))
|
|
161
|
+
if uid is None:
|
|
162
|
+
log.warning("no MAS user for %s; cannot enforce lifecycle action", mxid)
|
|
163
|
+
return uid
|
|
164
|
+
|
|
165
|
+
async def logout(self, mxid: str) -> None:
|
|
166
|
+
uid = await self._resolve(mxid)
|
|
167
|
+
if uid is not None:
|
|
168
|
+
await self.mas.lock_user(uid)
|
|
169
|
+
|
|
170
|
+
async def reenable(self, mxid: str) -> None:
|
|
171
|
+
uid = await self._resolve(mxid)
|
|
172
|
+
if uid is not None:
|
|
173
|
+
await self.mas.unlock_user(uid)
|
|
174
|
+
|
|
175
|
+
async def erase(self, mxid: str, *, delete_media: bool) -> None:
|
|
176
|
+
uid = await self._resolve(mxid)
|
|
177
|
+
if uid is not None:
|
|
178
|
+
await self.mas.deactivate_user(uid)
|
|
179
|
+
if delete_media and self.synapse_admin is not None:
|
|
180
|
+
await self.synapse_admin.delete_user_media(mxid)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@runtime_checkable
|
|
184
|
+
class LifecycleLedgerStore(Protocol):
|
|
185
|
+
async def load(self) -> LifecycleLedger: ...
|
|
186
|
+
|
|
187
|
+
async def save(self, ledger: LifecycleLedger) -> None: ...
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class MatrixAccountDataLedgerStore:
|
|
191
|
+
"""Persists the ledger as a single account-data blob on the bot user (no database, AD-1)."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, client: Any, bot_id: str, server_name: str) -> None:
|
|
194
|
+
self.client = client
|
|
195
|
+
self.bot_id = bot_id
|
|
196
|
+
self.data_type = lifecycle_account_data_type(server_name)
|
|
197
|
+
|
|
198
|
+
async def load(self) -> LifecycleLedger:
|
|
199
|
+
raw = await self.client.get_account_data(self.bot_id, self.data_type)
|
|
200
|
+
if not raw:
|
|
201
|
+
return LifecycleLedger()
|
|
202
|
+
return LifecycleLedger.model_validate(raw)
|
|
203
|
+
|
|
204
|
+
async def save(self, ledger: LifecycleLedger) -> None:
|
|
205
|
+
await self.client.set_account_data(self.bot_id, self.data_type, ledger.model_dump(mode="json"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class LifecycleOutcome(BaseModel):
|
|
209
|
+
"""What the manager decided for one user this pass (returned for tests/observability)."""
|
|
210
|
+
|
|
211
|
+
mxid: str
|
|
212
|
+
action: LifecycleAction
|
|
213
|
+
dry_run: bool
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AccountLifecycleManager:
|
|
217
|
+
"""Orchestrates the lifecycle pass: load ledger → decide per user → act → persist.
|
|
218
|
+
|
|
219
|
+
Invoked only by the reconciler with the set of *orphaned* MXIDs (Matrix accounts whose Authentik
|
|
220
|
+
user is disabled/gone). Any MXID already in the ledger that is no longer orphaned is re-enabled.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
config: OnbotConfig,
|
|
226
|
+
store: LifecycleLedgerStore,
|
|
227
|
+
effectors: LifecycleEffectors,
|
|
228
|
+
*,
|
|
229
|
+
clock: Callable[[], float] = time.time,
|
|
230
|
+
) -> None:
|
|
231
|
+
self.cfg = config.sync_authentik_users_with_matrix_rooms.deactivate_disabled_authentik_users_in_matrix
|
|
232
|
+
self.store = store
|
|
233
|
+
self.effectors = effectors
|
|
234
|
+
self.clock = clock
|
|
235
|
+
|
|
236
|
+
async def reconcile_accounts(self, orphaned_mxids: set[str]) -> list[LifecycleOutcome]:
|
|
237
|
+
if not self.cfg.enabled:
|
|
238
|
+
return []
|
|
239
|
+
ledger = await self.store.load()
|
|
240
|
+
now = self.clock()
|
|
241
|
+
outcomes: list[LifecycleOutcome] = []
|
|
242
|
+
changed = False
|
|
243
|
+
|
|
244
|
+
# Decide for every orphan and for anyone we are already tracking (so they can be re-enabled).
|
|
245
|
+
for mxid in sorted(orphaned_mxids | set(ledger.entries)):
|
|
246
|
+
entry = ledger.entries.get(mxid)
|
|
247
|
+
action = decide_account_action(
|
|
248
|
+
is_orphaned=mxid in orphaned_mxids,
|
|
249
|
+
marked_ts=entry.marked_for_disabling_timestamp if entry else None,
|
|
250
|
+
disabled_ts=entry.disabled_user_timestamp if entry else None,
|
|
251
|
+
now=now,
|
|
252
|
+
deactivate_after_n_sec=self.cfg.deactivate_after_n_sec,
|
|
253
|
+
delete_after_n_sec=self.cfg.delete_after_n_sec,
|
|
254
|
+
)
|
|
255
|
+
if action is LifecycleAction.none or action is LifecycleAction.wait:
|
|
256
|
+
continue
|
|
257
|
+
changed |= await self._apply(ledger, mxid, action, now)
|
|
258
|
+
outcomes.append(LifecycleOutcome(mxid=mxid, action=action, dry_run=self.cfg.dry_run))
|
|
259
|
+
|
|
260
|
+
if changed:
|
|
261
|
+
await self.store.save(ledger)
|
|
262
|
+
return outcomes
|
|
263
|
+
|
|
264
|
+
async def _apply(self, ledger: LifecycleLedger, mxid: str, action: LifecycleAction, now: float) -> bool:
|
|
265
|
+
"""Carry out one decision; return whether the ledger changed. Destructive ops respect dry-run."""
|
|
266
|
+
if action is LifecycleAction.reenable:
|
|
267
|
+
# Restorative (only ever grants access back), so it runs even under dry-run — it undoes a
|
|
268
|
+
# prior real logout/lock. A no-op when nothing was locked.
|
|
269
|
+
await self.effectors.reenable(mxid)
|
|
270
|
+
ledger.entries.pop(mxid, None)
|
|
271
|
+
audit.info("re-enabled %s (Authentik account active again); cleared lifecycle state", mxid)
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
if action is LifecycleAction.mark:
|
|
275
|
+
# Bookkeeping only — harmless to persist even in dry-run, and it starts the cooldown clock.
|
|
276
|
+
ledger.entries[mxid] = LifecycleEntry(marked_for_disabling_timestamp=now)
|
|
277
|
+
audit.info(
|
|
278
|
+
"marked %s for lifecycle action (Authentik account disabled); logout in %ss",
|
|
279
|
+
mxid,
|
|
280
|
+
self.cfg.deactivate_after_n_sec,
|
|
281
|
+
)
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
if action is LifecycleAction.logout:
|
|
285
|
+
if self.cfg.dry_run:
|
|
286
|
+
audit.warning("DRY-RUN: would log out %s (revoke all sessions)", mxid)
|
|
287
|
+
return False
|
|
288
|
+
await self.effectors.logout(mxid)
|
|
289
|
+
entry = ledger.entries.setdefault(mxid, LifecycleEntry())
|
|
290
|
+
entry.disabled_user_timestamp = now
|
|
291
|
+
audit.warning(
|
|
292
|
+
"logged out %s (revoked all sessions); erase in %ss", mxid, self.cfg.delete_after_n_sec
|
|
293
|
+
)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
# action is LifecycleAction.erase
|
|
297
|
+
if self.cfg.dry_run:
|
|
298
|
+
audit.warning(
|
|
299
|
+
"DRY-RUN: would erase %s (deactivate account, delete_media=%s)",
|
|
300
|
+
mxid,
|
|
301
|
+
self.cfg.include_user_media_on_delete,
|
|
302
|
+
)
|
|
303
|
+
return False
|
|
304
|
+
await self.effectors.erase(mxid, delete_media=self.cfg.include_user_media_on_delete)
|
|
305
|
+
ledger.entries.pop(mxid, None)
|
|
306
|
+
audit.warning(
|
|
307
|
+
"erased %s (account deactivated, media=%s)", mxid, self.cfg.include_user_media_on_delete
|
|
308
|
+
)
|
|
309
|
+
return True
|
onbot/logging.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Structured logging setup.
|
|
2
|
+
|
|
3
|
+
A thin, dependency-light wrapper over the stdlib ``logging`` module. Phase 2 keeps this
|
|
4
|
+
minimal; a structured-logging backend can be slotted in later without changing call sites.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
_DEFAULT_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def configure_logging(level: str | int | None = None) -> None:
|
|
16
|
+
"""Configure root logging once, honouring ``ONBOT_LOG_LEVEL`` when ``level`` is unset."""
|
|
17
|
+
if level is None:
|
|
18
|
+
level = os.environ.get("ONBOT_LOG_LEVEL", "INFO")
|
|
19
|
+
if isinstance(level, str):
|
|
20
|
+
level = logging.getLevelName(level.upper())
|
|
21
|
+
logging.basicConfig(level=level, format=_DEFAULT_FORMAT)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_logger(name: str) -> logging.Logger:
|
|
25
|
+
"""Return a module-scoped logger; prefer ``get_logger(__name__)`` at call sites."""
|
|
26
|
+
return logging.getLogger(name)
|
onbot/media.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Remote-URL → Matrix media upload with deduplication (G10.1, G10.2, Phase 6).
|
|
2
|
+
|
|
3
|
+
Avatars (bot, rooms, the space) are configured as plain HTTP(S) URLs. To use them in Matrix they
|
|
4
|
+
must be fetched and uploaded to the homeserver's media repo, which returns an ``mxc://`` URI. This
|
|
5
|
+
helper does that and **deduplicates by source URL within a run**, so the same avatar URL is fetched
|
|
6
|
+
and uploaded at most once (the legacy bot re-uploaded on every tick). Uploads go through the
|
|
7
|
+
authenticated media endpoint on :class:`~onbot.clients.matrix.ApiClientMatrix` (MSC3916).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from onbot.clients.matrix import ApiClientMatrix
|
|
15
|
+
from onbot.logging import get_logger
|
|
16
|
+
|
|
17
|
+
log = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
_DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MediaUploader:
|
|
23
|
+
def __init__(self, client: ApiClientMatrix, *, http_client: httpx.AsyncClient | None = None) -> None:
|
|
24
|
+
self.client = client
|
|
25
|
+
self._http = http_client or httpx.AsyncClient(timeout=30.0, follow_redirects=True)
|
|
26
|
+
self._owns_http = http_client is None
|
|
27
|
+
# Source URL -> mxc URI, so a repeated URL uploads only once (G10.2).
|
|
28
|
+
self._cache: dict[str, str] = {}
|
|
29
|
+
|
|
30
|
+
async def upload_from_url(self, url: str) -> str:
|
|
31
|
+
"""Fetch ``url`` and upload it, returning the ``mxc://`` URI (cached per source URL)."""
|
|
32
|
+
cached = self._cache.get(url)
|
|
33
|
+
if cached is not None:
|
|
34
|
+
return cached
|
|
35
|
+
response = await self._http.get(url)
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
raw_type = response.headers.get("content-type", _DEFAULT_CONTENT_TYPE).split(";")[0].strip()
|
|
38
|
+
content_type = raw_type or _DEFAULT_CONTENT_TYPE
|
|
39
|
+
mxc = await self.client.upload_media(response.content, content_type=content_type)
|
|
40
|
+
self._cache[url] = mxc
|
|
41
|
+
log.info("uploaded avatar %s -> %s", url, mxc)
|
|
42
|
+
return mxc
|
|
43
|
+
|
|
44
|
+
async def aclose(self) -> None:
|
|
45
|
+
if self._owns_http:
|
|
46
|
+
await self._http.aclose()
|