onbot 0.1.0.dev0__tar.gz

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.
Files changed (80) hide show
  1. onbot-0.1.0.dev0/LICENSE +21 -0
  2. onbot-0.1.0.dev0/PKG-INFO +241 -0
  3. onbot-0.1.0.dev0/README.md +223 -0
  4. onbot-0.1.0.dev0/onbot/__init__.py +7 -0
  5. onbot-0.1.0.dev0/onbot/__main__.py +6 -0
  6. onbot-0.1.0.dev0/onbot/app.py +171 -0
  7. onbot-0.1.0.dev0/onbot/auth/__init__.py +19 -0
  8. onbot-0.1.0.dev0/onbot/auth/token_provider.py +153 -0
  9. onbot-0.1.0.dev0/onbot/cli.py +80 -0
  10. onbot-0.1.0.dev0/onbot/clients/__init__.py +5 -0
  11. onbot-0.1.0.dev0/onbot/clients/authentik.py +123 -0
  12. onbot-0.1.0.dev0/onbot/clients/base.py +243 -0
  13. onbot-0.1.0.dev0/onbot/clients/mas_admin.py +65 -0
  14. onbot-0.1.0.dev0/onbot/clients/matrix.py +457 -0
  15. onbot-0.1.0.dev0/onbot/clients/synapse_admin.py +159 -0
  16. onbot-0.1.0.dev0/onbot/clients/versions.py +61 -0
  17. onbot-0.1.0.dev0/onbot/config.py +653 -0
  18. onbot-0.1.0.dev0/onbot/events.py +51 -0
  19. onbot-0.1.0.dev0/onbot/healthcheck.py +134 -0
  20. onbot-0.1.0.dev0/onbot/identity.py +44 -0
  21. onbot-0.1.0.dev0/onbot/lifecycle/__init__.py +2 -0
  22. onbot-0.1.0.dev0/onbot/lifecycle/accounts.py +309 -0
  23. onbot-0.1.0.dev0/onbot/logging.py +26 -0
  24. onbot-0.1.0.dev0/onbot/media.py +46 -0
  25. onbot-0.1.0.dev0/onbot/models.py +74 -0
  26. onbot-0.1.0.dev0/onbot/onboarding/__init__.py +5 -0
  27. onbot-0.1.0.dev0/onbot/onboarding/listener.py +103 -0
  28. onbot-0.1.0.dev0/onbot/onboarding/welcome.py +116 -0
  29. onbot-0.1.0.dev0/onbot/reconciler/__init__.py +2 -0
  30. onbot-0.1.0.dev0/onbot/reconciler/effectors.py +78 -0
  31. onbot-0.1.0.dev0/onbot/reconciler/engine.py +348 -0
  32. onbot-0.1.0.dev0/onbot/reconciler/membership.py +54 -0
  33. onbot-0.1.0.dev0/onbot/reconciler/power_levels.py +96 -0
  34. onbot-0.1.0.dev0/onbot/reconciler/rooms.py +127 -0
  35. onbot-0.1.0.dev0/onbot/reconciler/state.py +77 -0
  36. onbot-0.1.0.dev0/onbot/utils.py +61 -0
  37. onbot-0.1.0.dev0/pyproject.toml +121 -0
  38. onbot-0.1.0.dev0/tests/README.md +17 -0
  39. onbot-0.1.0.dev0/tests/__init__.py +0 -0
  40. onbot-0.1.0.dev0/tests/contract/__init__.py +0 -0
  41. onbot-0.1.0.dev0/tests/contract/test_authentik_client.py +56 -0
  42. onbot-0.1.0.dev0/tests/contract/test_base_client.py +107 -0
  43. onbot-0.1.0.dev0/tests/contract/test_matrix_client.py +428 -0
  44. onbot-0.1.0.dev0/tests/contract/test_synapse_admin_client.py +235 -0
  45. onbot-0.1.0.dev0/tests/integration/__init__.py +0 -0
  46. onbot-0.1.0.dev0/tests/integration/conftest.py +211 -0
  47. onbot-0.1.0.dev0/tests/integration/stack/authentik/blueprints/onbot.yaml +53 -0
  48. onbot-0.1.0.dev0/tests/integration/stack/docker-compose.yml +118 -0
  49. onbot-0.1.0.dev0/tests/integration/stack/mas/config.yaml +131 -0
  50. onbot-0.1.0.dev0/tests/integration/stack/postgres/10-init-dbs.sh +12 -0
  51. onbot-0.1.0.dev0/tests/integration/stack/synapse/homeserver.yaml +59 -0
  52. onbot-0.1.0.dev0/tests/integration/stack/synapse/log.config +15 -0
  53. onbot-0.1.0.dev0/tests/integration/stack_api.py +325 -0
  54. onbot-0.1.0.dev0/tests/integration/test_lifecycle.py +52 -0
  55. onbot-0.1.0.dev0/tests/integration/test_media.py +22 -0
  56. onbot-0.1.0.dev0/tests/integration/test_onboarding.py +39 -0
  57. onbot-0.1.0.dev0/tests/integration/test_provisioning.py +24 -0
  58. onbot-0.1.0.dev0/tests/integration/test_q1_experiment.py +45 -0
  59. onbot-0.1.0.dev0/tests/integration/test_reconcile.py +80 -0
  60. onbot-0.1.0.dev0/tests/unit/__init__.py +0 -0
  61. onbot-0.1.0.dev0/tests/unit/test_cli.py +43 -0
  62. onbot-0.1.0.dev0/tests/unit/test_config.py +64 -0
  63. onbot-0.1.0.dev0/tests/unit/test_config_docs.py +38 -0
  64. onbot-0.1.0.dev0/tests/unit/test_effectors.py +31 -0
  65. onbot-0.1.0.dev0/tests/unit/test_engine.py +202 -0
  66. onbot-0.1.0.dev0/tests/unit/test_healthcheck.py +81 -0
  67. onbot-0.1.0.dev0/tests/unit/test_identity.py +32 -0
  68. onbot-0.1.0.dev0/tests/unit/test_lifecycle.py +401 -0
  69. onbot-0.1.0.dev0/tests/unit/test_listener.py +165 -0
  70. onbot-0.1.0.dev0/tests/unit/test_main_module.py +9 -0
  71. onbot-0.1.0.dev0/tests/unit/test_media.py +39 -0
  72. onbot-0.1.0.dev0/tests/unit/test_membership.py +56 -0
  73. onbot-0.1.0.dev0/tests/unit/test_power_levels.py +58 -0
  74. onbot-0.1.0.dev0/tests/unit/test_rooms.py +87 -0
  75. onbot-0.1.0.dev0/tests/unit/test_skeleton.py +29 -0
  76. onbot-0.1.0.dev0/tests/unit/test_state.py +36 -0
  77. onbot-0.1.0.dev0/tests/unit/test_token_provider.py +110 -0
  78. onbot-0.1.0.dev0/tests/unit/test_utils.py +28 -0
  79. onbot-0.1.0.dev0/tests/unit/test_versions.py +35 -0
  80. onbot-0.1.0.dev0/tests/unit/test_welcome.py +128 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Deutsche Zentrum für Diabetesforschung e.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.1
2
+ Name: onbot
3
+ Version: 0.1.0.dev0
4
+ Summary: Keep a Matrix (Synapse) homeserver in sync with Authentik and onboard new users.
5
+ Keywords: matrix,synapse,authentik,mas,bot,onboarding
6
+ Author: DZD
7
+ License: MIT
8
+ Classifier: Programming Language :: Python :: 3.14
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Project-URL: Homepage, https://github.com/DZD-eV-Diabetes-Research/matrix-synapse-authentik-onbaording-bot
11
+ Requires-Python: >=3.14
12
+ Requires-Dist: httpx>=0.27
13
+ Requires-Dist: tenacity>=9.0
14
+ Requires-Dist: pydantic>=2.7
15
+ Requires-Dist: pydantic-settings>=2.3
16
+ Requires-Dist: pyyaml>=6.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Onbot
20
+
21
+ A bot that keeps a **Matrix (Synapse)** homeserver continuously in sync with an
22
+ **[Authentik](https://goauthentik.io/)** identity provider and onboards every new user into the
23
+ right rooms with a friendly welcome. Authentik is the source of truth; Matrix mirrors it
24
+ (group → room, group membership → room membership, power levels), and new users get guided in with a
25
+ 1:1 welcome DM.
26
+
27
+ Onbot targets **Matrix 2.0**: it assumes a [**Matrix Authentication Service
28
+ (MAS)**](https://element-hq.github.io/matrix-authentication-service/) deployment with Authentik as
29
+ an *upstream identity provider*, uses authenticated media, and drives the Client-Server and admin
30
+ APIs over a single async HTTP client (no `matrix-nio`).
31
+
32
+ See [`GOALS.md`](GOALS.md) for intent, [`BATTLE_PLAN.md`](BATTLE_PLAN.md) for the build plan, and
33
+ [`docs/adr/`](docs/adr/) for the architecture decisions.
34
+
35
+ > ⚠️ **Release blocker (maintainer):** the Phase 1 security items are **not done** — leaked
36
+ > credentials in git history still need rotating and the history scrubbing
37
+ > ([`BATTLE_PLAN.md`](BATTLE_PLAN.md) §5 Phase 1). **Do not publish an image or tag a release until
38
+ > those are complete.** The packaging below is ready; the security hand-off is the gate.
39
+
40
+ ---
41
+
42
+ ## How it works — the MAS auth topology
43
+
44
+ The auth chain is **Matrix client → MAS → Authentik** (ADR-[0006](docs/adr/0006-auth-topology-mas-authentik.md)):
45
+
46
+ ```
47
+ logs in via upstream IdP
48
+ Matrix client ───────────────▶ MAS ◀─────────────────────── Authentik
49
+ ▲ │ (provisions Matrix accounts (source of truth:
50
+ │ │ on first login) users & groups)
51
+ │ welcome DM, │
52
+ │ room membership ┌────┴─────┐
53
+ └──────────────────────│ Onbot │── reads users/groups ──▶ Authentik API
54
+ └────┬─────┘
55
+ └── Synapse Admin API + CS API ──▶ Synapse ◀─ MAS
56
+ ```
57
+
58
+ Consequences that shape how you configure Onbot:
59
+
60
+ - **Onbot does not create accounts.** MAS auto-provisions a Matrix account the first time a user
61
+ logs in through Authentik. Onbot's job is **projection**: turn Authentik groups into rooms, group
62
+ membership into room membership, and group/role attributes into power levels — plus the
63
+ quarantined offboarding lifecycle.
64
+ - **The MXID localpart contract is critical.** Onbot computes a user's MXID
65
+ (`@<localpart>:server_name`) from an Authentik attribute, and it **must match the localpart
66
+ template MAS uses** when it provisions accounts from the same Authentik claim. Set
67
+ [`sync_authentik_users_with_matrix_rooms.authentik_username_mapping_attribute`](docs/CONFIG_REFERENCE.md)
68
+ to agree with MAS — get it wrong and Onbot's computed MXIDs won't match the real accounts, so
69
+ nobody gets added to rooms. (Verified by the integration suite's localpart-contract test.)
70
+ - **Lifecycle enforcement requires MAS.** When Authentik disables a user, MAS blocks *new* logins
71
+ but **existing Matrix sessions keep working**, and the *Synapse* admin API cannot revoke a
72
+ MAS-issued session — only MAS can (ADR-[0005](docs/adr/0005-quarantine-lifecycle.md), §7 Q1, proven
73
+ empirically). So to actually offboard a disabled user you must configure the
74
+ [`mas_admin`](docs/CONFIG_REFERENCE.md) block. Without it, offboarding is a **no-op against live
75
+ sessions** (it silently fails to revoke).
76
+
77
+ ---
78
+
79
+ ## Configuration
80
+
81
+ Configuration is a single YAML file validated by a [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
82
+ model ([`onbot/config.py`](onbot/config.py)). Every setting can also be supplied (or overridden) via
83
+ an environment variable: prefix `ONBOT_`, nest with `__`. E.g.
84
+ `ONBOT_SYNAPSE_SERVER__BOT_ACCESS_TOKEN=syt_…`.
85
+
86
+ - **Full reference:** [`docs/CONFIG_REFERENCE.md`](docs/CONFIG_REFERENCE.md) — every field, its type,
87
+ default, description and `ONBOT_*` env-var name.
88
+ - **Annotated template:** [`config.example.yml`](config.example.yml) — a commented, fillable YAML
89
+ template. Copy it to `config.yml` and fill in the required values.
90
+
91
+ Both are **generated from the model** (with [psyplus](https://pypi.org/project/psyplus/)) and kept in
92
+ sync by CI — regenerate after editing `onbot/config.py`:
93
+
94
+ ```bash
95
+ pdm run gen-config-docs # rewrite docs/CONFIG_REFERENCE.md + config.example.yml
96
+ pdm run check-config-docs # fail if they drift from the model (runs in CI)
97
+ ```
98
+
99
+ > 🔐 **Never commit a real config.** `config*.yml` is gitignored (only `config.example.yml` is
100
+ > tracked) and the Docker image carries no secrets — provide config at runtime.
101
+
102
+ ### Bot credentials (pick one)
103
+
104
+ Onbot authenticates to Synapse as a bot user. Under MAS, choose one of:
105
+
106
+ | Option | Field | When |
107
+ |---|---|---|
108
+ | **Compatibility token** | `synapse_server.bot_access_token` | Near-term. Issue with `mas-cli manage issue-compatibility-token`. Provide the bare token. |
109
+ | **OAuth2 client-credentials** | `synapse_server.oauth2` | Forward-looking. The bot is a confidential MAS client and refreshes tokens automatically. |
110
+
111
+ Provide **exactly one**. The same identity drives both the Synapse Admin API and the Client-Server
112
+ API.
113
+
114
+ ### Minimal `config.yml`
115
+
116
+ ```yaml
117
+ synapse_server:
118
+ server_name: company.org # your Matrix domain (the part after the ':')
119
+ server_url: https://internal.matrix # how the bot reaches Synapse (internal URL is fine)
120
+ bot_user_id: "@welcome-bot:company.org"
121
+ bot_access_token: syt_REPLACE_ME # or use an `oauth2:` block instead
122
+
123
+ authentik_server:
124
+ url: https://authentik.company.org/
125
+ api_key: REPLACE_ME # Authentik API token
126
+
127
+ # Required to enforce offboarding under MAS (omit on non-MAS deployments):
128
+ mas_admin:
129
+ url: https://auth.company.org # the MAS base URL
130
+ client_id: REPLACE_ME # a MAS admin client (in policy.data.admin_clients)
131
+ client_secret: REPLACE_ME
132
+
133
+ sync_authentik_users_with_matrix_rooms:
134
+ authentik_username_mapping_attribute: username # MUST agree with MAS's localpart template
135
+ ```
136
+
137
+ See [`config.example.yml`](config.example.yml) for everything else (room mapping rules, power
138
+ levels, welcome messages, the dry-run lifecycle defaults, ignore lists, …).
139
+
140
+ ---
141
+
142
+ ## Run with Docker
143
+
144
+ The published image runs as a non-root user and ships only runtime dependencies (no crypto stack —
145
+ the bot operates outside encrypted rooms, ADR-[0009](docs/adr/0009-e2ee-stance.md)).
146
+
147
+ ```bash
148
+ docker run --rm \
149
+ -v "$PWD/config.yml:/config/config.yml:ro" \
150
+ ghcr.io/dzd-ev-diabetes-research/matrix-synapse-authentik-onbaording-bot:latest
151
+ ```
152
+
153
+ The image defaults to `ONBOT_CONFIG_FILE_PATH=/config/config.yml` and the `run` command. It has a
154
+ built-in `HEALTHCHECK` that calls `onbot healthcheck` (see below).
155
+
156
+ ### docker-compose
157
+
158
+ ```yaml
159
+ services:
160
+ onbot:
161
+ image: ghcr.io/dzd-ev-diabetes-research/matrix-synapse-authentik-onbaording-bot:latest
162
+ restart: unless-stopped
163
+ volumes:
164
+ - ./config.yml:/config/config.yml:ro
165
+ # Or skip the file and supply everything via env (no secrets on disk):
166
+ # environment:
167
+ # ONBOT_SYNAPSE_SERVER__SERVER_NAME: company.org
168
+ # ONBOT_SYNAPSE_SERVER__BOT_ACCESS_TOKEN: ${ONBOT_BOT_TOKEN}
169
+ ```
170
+
171
+ ### CLI commands
172
+
173
+ ```
174
+ onbot run # long-running service: reconcile loop + event-driven onboarding (default)
175
+ onbot reconcile-once # one idempotent reconcile pass, then exit
176
+ onbot generate-config # print a minimal config template (use config.example.yml for the rich one)
177
+ onbot healthcheck # probe Synapse/Authentik/MAS with the real credentials; exit 0 healthy, 1 not
178
+ ```
179
+
180
+ `onbot healthcheck` is what the container's `HEALTHCHECK` runs: it issues one authenticated request
181
+ to the Matrix CS API (`/whoami`), the Synapse admin API, the Authentik API, and — when `mas_admin`
182
+ is configured — the MAS admin API, and exits non-zero if any is unreachable or rejects the
183
+ credentials.
184
+
185
+ ---
186
+
187
+ ## Develop
188
+
189
+ Requires **Python 3.14** and **[PDM](https://pdm-project.org/)**.
190
+
191
+ ```bash
192
+ pdm install # create the venv and install deps (incl. dev + docs)
193
+ pdm run pre-commit install # enable lint + secret-scan hooks
194
+ ```
195
+
196
+ ### Run & test
197
+
198
+ ```bash
199
+ pdm run onbot --help
200
+ pdm run onbot reconcile-once
201
+
202
+ pdm run pytest -m "not integration" # fast unit + contract suite
203
+ pdm run pytest # full suite incl. live Synapse+MAS+Authentik (needs Docker)
204
+ pdm run ruff check . # lint
205
+ pdm run ruff format --check . # formatting
206
+ pdm run mypy onbot # type check
207
+ ```
208
+
209
+ The `run_*.sh` helper scripts at the repo root wrap the same PDM commands.
210
+
211
+ ### Build the image locally
212
+
213
+ ```bash
214
+ docker build -t onbot:dev .
215
+ docker run --rm onbot:dev --help
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Troubleshooting
221
+
222
+ | Symptom | Likely cause |
223
+ |---|---|
224
+ | Users never get added to rooms | The MXID localpart contract is broken — `authentik_username_mapping_attribute` doesn't match MAS's localpart template, so computed MXIDs don't exist. |
225
+ | Disabled users keep their Matrix access | `mas_admin` is not configured (the Synapse admin API can't revoke a MAS session), or lifecycle `dry_run` is still `true` (the default). |
226
+ | Nothing destructive ever happens | Expected by default — the lifecycle is quarantined (`dry_run: true`); it only logs to the `onbot.lifecycle.audit` channel until you opt in. |
227
+ | Welcome DM send fails with a 500 | The bot device isn't registered yet — Onbot registers it on startup (`ensure_device_registered`); check startup logs. |
228
+ | `healthcheck` reports a dependency FAIL | Read the per-dependency log line; it distinguishes unreachable from auth-rejected. The `matrix-cs` line also flags a token/`bot_user_id` mismatch. |
229
+ | Sliding sync unavailable | The homeserver doesn't advertise MSC4186; Onbot falls back to the reconciler signal path automatically. |
230
+
231
+ ---
232
+
233
+ ## Releasing
234
+
235
+ Versioned images are published to GHCR by the [release workflow](.github/workflows/release.yml) when
236
+ a `v*` tag is pushed. See [`CHANGELOG.md`](CHANGELOG.md) and the workflow's header comment for the
237
+ tag/version flow. **(Blocked on the Phase 1 security hand-off — see the note at the top.)**
238
+
239
+ ## License
240
+
241
+ MIT — see [`LICENSE`](LICENSE).
@@ -0,0 +1,223 @@
1
+ # Onbot
2
+
3
+ A bot that keeps a **Matrix (Synapse)** homeserver continuously in sync with an
4
+ **[Authentik](https://goauthentik.io/)** identity provider and onboards every new user into the
5
+ right rooms with a friendly welcome. Authentik is the source of truth; Matrix mirrors it
6
+ (group → room, group membership → room membership, power levels), and new users get guided in with a
7
+ 1:1 welcome DM.
8
+
9
+ Onbot targets **Matrix 2.0**: it assumes a [**Matrix Authentication Service
10
+ (MAS)**](https://element-hq.github.io/matrix-authentication-service/) deployment with Authentik as
11
+ an *upstream identity provider*, uses authenticated media, and drives the Client-Server and admin
12
+ APIs over a single async HTTP client (no `matrix-nio`).
13
+
14
+ See [`GOALS.md`](GOALS.md) for intent, [`BATTLE_PLAN.md`](BATTLE_PLAN.md) for the build plan, and
15
+ [`docs/adr/`](docs/adr/) for the architecture decisions.
16
+
17
+ > ⚠️ **Release blocker (maintainer):** the Phase 1 security items are **not done** — leaked
18
+ > credentials in git history still need rotating and the history scrubbing
19
+ > ([`BATTLE_PLAN.md`](BATTLE_PLAN.md) §5 Phase 1). **Do not publish an image or tag a release until
20
+ > those are complete.** The packaging below is ready; the security hand-off is the gate.
21
+
22
+ ---
23
+
24
+ ## How it works — the MAS auth topology
25
+
26
+ The auth chain is **Matrix client → MAS → Authentik** (ADR-[0006](docs/adr/0006-auth-topology-mas-authentik.md)):
27
+
28
+ ```
29
+ logs in via upstream IdP
30
+ Matrix client ───────────────▶ MAS ◀─────────────────────── Authentik
31
+ ▲ │ (provisions Matrix accounts (source of truth:
32
+ │ │ on first login) users & groups)
33
+ │ welcome DM, │
34
+ │ room membership ┌────┴─────┐
35
+ └──────────────────────│ Onbot │── reads users/groups ──▶ Authentik API
36
+ └────┬─────┘
37
+ └── Synapse Admin API + CS API ──▶ Synapse ◀─ MAS
38
+ ```
39
+
40
+ Consequences that shape how you configure Onbot:
41
+
42
+ - **Onbot does not create accounts.** MAS auto-provisions a Matrix account the first time a user
43
+ logs in through Authentik. Onbot's job is **projection**: turn Authentik groups into rooms, group
44
+ membership into room membership, and group/role attributes into power levels — plus the
45
+ quarantined offboarding lifecycle.
46
+ - **The MXID localpart contract is critical.** Onbot computes a user's MXID
47
+ (`@<localpart>:server_name`) from an Authentik attribute, and it **must match the localpart
48
+ template MAS uses** when it provisions accounts from the same Authentik claim. Set
49
+ [`sync_authentik_users_with_matrix_rooms.authentik_username_mapping_attribute`](docs/CONFIG_REFERENCE.md)
50
+ to agree with MAS — get it wrong and Onbot's computed MXIDs won't match the real accounts, so
51
+ nobody gets added to rooms. (Verified by the integration suite's localpart-contract test.)
52
+ - **Lifecycle enforcement requires MAS.** When Authentik disables a user, MAS blocks *new* logins
53
+ but **existing Matrix sessions keep working**, and the *Synapse* admin API cannot revoke a
54
+ MAS-issued session — only MAS can (ADR-[0005](docs/adr/0005-quarantine-lifecycle.md), §7 Q1, proven
55
+ empirically). So to actually offboard a disabled user you must configure the
56
+ [`mas_admin`](docs/CONFIG_REFERENCE.md) block. Without it, offboarding is a **no-op against live
57
+ sessions** (it silently fails to revoke).
58
+
59
+ ---
60
+
61
+ ## Configuration
62
+
63
+ Configuration is a single YAML file validated by a [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
64
+ model ([`onbot/config.py`](onbot/config.py)). Every setting can also be supplied (or overridden) via
65
+ an environment variable: prefix `ONBOT_`, nest with `__`. E.g.
66
+ `ONBOT_SYNAPSE_SERVER__BOT_ACCESS_TOKEN=syt_…`.
67
+
68
+ - **Full reference:** [`docs/CONFIG_REFERENCE.md`](docs/CONFIG_REFERENCE.md) — every field, its type,
69
+ default, description and `ONBOT_*` env-var name.
70
+ - **Annotated template:** [`config.example.yml`](config.example.yml) — a commented, fillable YAML
71
+ template. Copy it to `config.yml` and fill in the required values.
72
+
73
+ Both are **generated from the model** (with [psyplus](https://pypi.org/project/psyplus/)) and kept in
74
+ sync by CI — regenerate after editing `onbot/config.py`:
75
+
76
+ ```bash
77
+ pdm run gen-config-docs # rewrite docs/CONFIG_REFERENCE.md + config.example.yml
78
+ pdm run check-config-docs # fail if they drift from the model (runs in CI)
79
+ ```
80
+
81
+ > 🔐 **Never commit a real config.** `config*.yml` is gitignored (only `config.example.yml` is
82
+ > tracked) and the Docker image carries no secrets — provide config at runtime.
83
+
84
+ ### Bot credentials (pick one)
85
+
86
+ Onbot authenticates to Synapse as a bot user. Under MAS, choose one of:
87
+
88
+ | Option | Field | When |
89
+ |---|---|---|
90
+ | **Compatibility token** | `synapse_server.bot_access_token` | Near-term. Issue with `mas-cli manage issue-compatibility-token`. Provide the bare token. |
91
+ | **OAuth2 client-credentials** | `synapse_server.oauth2` | Forward-looking. The bot is a confidential MAS client and refreshes tokens automatically. |
92
+
93
+ Provide **exactly one**. The same identity drives both the Synapse Admin API and the Client-Server
94
+ API.
95
+
96
+ ### Minimal `config.yml`
97
+
98
+ ```yaml
99
+ synapse_server:
100
+ server_name: company.org # your Matrix domain (the part after the ':')
101
+ server_url: https://internal.matrix # how the bot reaches Synapse (internal URL is fine)
102
+ bot_user_id: "@welcome-bot:company.org"
103
+ bot_access_token: syt_REPLACE_ME # or use an `oauth2:` block instead
104
+
105
+ authentik_server:
106
+ url: https://authentik.company.org/
107
+ api_key: REPLACE_ME # Authentik API token
108
+
109
+ # Required to enforce offboarding under MAS (omit on non-MAS deployments):
110
+ mas_admin:
111
+ url: https://auth.company.org # the MAS base URL
112
+ client_id: REPLACE_ME # a MAS admin client (in policy.data.admin_clients)
113
+ client_secret: REPLACE_ME
114
+
115
+ sync_authentik_users_with_matrix_rooms:
116
+ authentik_username_mapping_attribute: username # MUST agree with MAS's localpart template
117
+ ```
118
+
119
+ See [`config.example.yml`](config.example.yml) for everything else (room mapping rules, power
120
+ levels, welcome messages, the dry-run lifecycle defaults, ignore lists, …).
121
+
122
+ ---
123
+
124
+ ## Run with Docker
125
+
126
+ The published image runs as a non-root user and ships only runtime dependencies (no crypto stack —
127
+ the bot operates outside encrypted rooms, ADR-[0009](docs/adr/0009-e2ee-stance.md)).
128
+
129
+ ```bash
130
+ docker run --rm \
131
+ -v "$PWD/config.yml:/config/config.yml:ro" \
132
+ ghcr.io/dzd-ev-diabetes-research/matrix-synapse-authentik-onbaording-bot:latest
133
+ ```
134
+
135
+ The image defaults to `ONBOT_CONFIG_FILE_PATH=/config/config.yml` and the `run` command. It has a
136
+ built-in `HEALTHCHECK` that calls `onbot healthcheck` (see below).
137
+
138
+ ### docker-compose
139
+
140
+ ```yaml
141
+ services:
142
+ onbot:
143
+ image: ghcr.io/dzd-ev-diabetes-research/matrix-synapse-authentik-onbaording-bot:latest
144
+ restart: unless-stopped
145
+ volumes:
146
+ - ./config.yml:/config/config.yml:ro
147
+ # Or skip the file and supply everything via env (no secrets on disk):
148
+ # environment:
149
+ # ONBOT_SYNAPSE_SERVER__SERVER_NAME: company.org
150
+ # ONBOT_SYNAPSE_SERVER__BOT_ACCESS_TOKEN: ${ONBOT_BOT_TOKEN}
151
+ ```
152
+
153
+ ### CLI commands
154
+
155
+ ```
156
+ onbot run # long-running service: reconcile loop + event-driven onboarding (default)
157
+ onbot reconcile-once # one idempotent reconcile pass, then exit
158
+ onbot generate-config # print a minimal config template (use config.example.yml for the rich one)
159
+ onbot healthcheck # probe Synapse/Authentik/MAS with the real credentials; exit 0 healthy, 1 not
160
+ ```
161
+
162
+ `onbot healthcheck` is what the container's `HEALTHCHECK` runs: it issues one authenticated request
163
+ to the Matrix CS API (`/whoami`), the Synapse admin API, the Authentik API, and — when `mas_admin`
164
+ is configured — the MAS admin API, and exits non-zero if any is unreachable or rejects the
165
+ credentials.
166
+
167
+ ---
168
+
169
+ ## Develop
170
+
171
+ Requires **Python 3.14** and **[PDM](https://pdm-project.org/)**.
172
+
173
+ ```bash
174
+ pdm install # create the venv and install deps (incl. dev + docs)
175
+ pdm run pre-commit install # enable lint + secret-scan hooks
176
+ ```
177
+
178
+ ### Run & test
179
+
180
+ ```bash
181
+ pdm run onbot --help
182
+ pdm run onbot reconcile-once
183
+
184
+ pdm run pytest -m "not integration" # fast unit + contract suite
185
+ pdm run pytest # full suite incl. live Synapse+MAS+Authentik (needs Docker)
186
+ pdm run ruff check . # lint
187
+ pdm run ruff format --check . # formatting
188
+ pdm run mypy onbot # type check
189
+ ```
190
+
191
+ The `run_*.sh` helper scripts at the repo root wrap the same PDM commands.
192
+
193
+ ### Build the image locally
194
+
195
+ ```bash
196
+ docker build -t onbot:dev .
197
+ docker run --rm onbot:dev --help
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Troubleshooting
203
+
204
+ | Symptom | Likely cause |
205
+ |---|---|
206
+ | Users never get added to rooms | The MXID localpart contract is broken — `authentik_username_mapping_attribute` doesn't match MAS's localpart template, so computed MXIDs don't exist. |
207
+ | Disabled users keep their Matrix access | `mas_admin` is not configured (the Synapse admin API can't revoke a MAS session), or lifecycle `dry_run` is still `true` (the default). |
208
+ | Nothing destructive ever happens | Expected by default — the lifecycle is quarantined (`dry_run: true`); it only logs to the `onbot.lifecycle.audit` channel until you opt in. |
209
+ | Welcome DM send fails with a 500 | The bot device isn't registered yet — Onbot registers it on startup (`ensure_device_registered`); check startup logs. |
210
+ | `healthcheck` reports a dependency FAIL | Read the per-dependency log line; it distinguishes unreachable from auth-rejected. The `matrix-cs` line also flags a token/`bot_user_id` mismatch. |
211
+ | Sliding sync unavailable | The homeserver doesn't advertise MSC4186; Onbot falls back to the reconciler signal path automatically. |
212
+
213
+ ---
214
+
215
+ ## Releasing
216
+
217
+ Versioned images are published to GHCR by the [release workflow](.github/workflows/release.yml) when
218
+ a `v*` tag is pushed. See [`CHANGELOG.md`](CHANGELOG.md) and the workflow's header comment for the
219
+ tag/version flow. **(Blocked on the Phase 1 security hand-off — see the note at the top.)**
220
+
221
+ ## License
222
+
223
+ MIT — see [`LICENSE`](LICENSE).
@@ -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"
@@ -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())
@@ -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()