onbot 0.0.1__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.
- onbot-0.0.1/LICENSE +21 -0
- onbot-0.0.1/PKG-INFO +132 -0
- onbot-0.0.1/README.md +114 -0
- onbot-0.0.1/onbot/__init__.py +12 -0
- onbot-0.0.1/onbot/__main__.py +6 -0
- onbot-0.0.1/onbot/app.py +173 -0
- onbot-0.0.1/onbot/auth/__init__.py +19 -0
- onbot-0.0.1/onbot/auth/token_provider.py +153 -0
- onbot-0.0.1/onbot/cli.py +80 -0
- onbot-0.0.1/onbot/clients/__init__.py +5 -0
- onbot-0.0.1/onbot/clients/authentik.py +123 -0
- onbot-0.0.1/onbot/clients/base.py +243 -0
- onbot-0.0.1/onbot/clients/mas_admin.py +65 -0
- onbot-0.0.1/onbot/clients/matrix.py +482 -0
- onbot-0.0.1/onbot/clients/synapse_admin.py +159 -0
- onbot-0.0.1/onbot/clients/versions.py +61 -0
- onbot-0.0.1/onbot/config.py +660 -0
- onbot-0.0.1/onbot/events.py +51 -0
- onbot-0.0.1/onbot/healthcheck.py +134 -0
- onbot-0.0.1/onbot/identity.py +44 -0
- onbot-0.0.1/onbot/lifecycle/__init__.py +2 -0
- onbot-0.0.1/onbot/lifecycle/accounts.py +309 -0
- onbot-0.0.1/onbot/logging.py +26 -0
- onbot-0.0.1/onbot/media.py +46 -0
- onbot-0.0.1/onbot/models.py +75 -0
- onbot-0.0.1/onbot/onboarding/__init__.py +5 -0
- onbot-0.0.1/onbot/onboarding/listener.py +103 -0
- onbot-0.0.1/onbot/onboarding/welcome.py +116 -0
- onbot-0.0.1/onbot/reconciler/__init__.py +2 -0
- onbot-0.0.1/onbot/reconciler/effectors.py +100 -0
- onbot-0.0.1/onbot/reconciler/engine.py +395 -0
- onbot-0.0.1/onbot/reconciler/membership.py +54 -0
- onbot-0.0.1/onbot/reconciler/power_levels.py +96 -0
- onbot-0.0.1/onbot/reconciler/rooms.py +135 -0
- onbot-0.0.1/onbot/reconciler/state.py +77 -0
- onbot-0.0.1/onbot/utils.py +61 -0
- onbot-0.0.1/pyproject.toml +126 -0
- onbot-0.0.1/tests/README.md +17 -0
- onbot-0.0.1/tests/__init__.py +0 -0
- onbot-0.0.1/tests/contract/__init__.py +0 -0
- onbot-0.0.1/tests/contract/test_authentik_client.py +56 -0
- onbot-0.0.1/tests/contract/test_base_client.py +107 -0
- onbot-0.0.1/tests/contract/test_matrix_client.py +428 -0
- onbot-0.0.1/tests/contract/test_synapse_admin_client.py +235 -0
- onbot-0.0.1/tests/integration/__init__.py +0 -0
- onbot-0.0.1/tests/integration/conftest.py +211 -0
- onbot-0.0.1/tests/integration/stack/authentik/blueprints/onbot.yaml +53 -0
- onbot-0.0.1/tests/integration/stack/docker-compose.yml +118 -0
- onbot-0.0.1/tests/integration/stack/mas/config.yaml +131 -0
- onbot-0.0.1/tests/integration/stack/postgres/10-init-dbs.sh +12 -0
- onbot-0.0.1/tests/integration/stack/synapse/homeserver.yaml +59 -0
- onbot-0.0.1/tests/integration/stack/synapse/log.config +15 -0
- onbot-0.0.1/tests/integration/stack_api.py +325 -0
- onbot-0.0.1/tests/integration/test_lifecycle.py +52 -0
- onbot-0.0.1/tests/integration/test_media.py +22 -0
- onbot-0.0.1/tests/integration/test_onboarding.py +39 -0
- onbot-0.0.1/tests/integration/test_provisioning.py +24 -0
- onbot-0.0.1/tests/integration/test_q1_experiment.py +45 -0
- onbot-0.0.1/tests/integration/test_reconcile.py +80 -0
- onbot-0.0.1/tests/unit/__init__.py +0 -0
- onbot-0.0.1/tests/unit/test_cli.py +43 -0
- onbot-0.0.1/tests/unit/test_config.py +64 -0
- onbot-0.0.1/tests/unit/test_config_docs.py +38 -0
- onbot-0.0.1/tests/unit/test_effectors.py +31 -0
- onbot-0.0.1/tests/unit/test_engine.py +273 -0
- onbot-0.0.1/tests/unit/test_healthcheck.py +81 -0
- onbot-0.0.1/tests/unit/test_identity.py +32 -0
- onbot-0.0.1/tests/unit/test_lifecycle.py +401 -0
- onbot-0.0.1/tests/unit/test_listener.py +165 -0
- onbot-0.0.1/tests/unit/test_main_module.py +9 -0
- onbot-0.0.1/tests/unit/test_media.py +39 -0
- onbot-0.0.1/tests/unit/test_membership.py +56 -0
- onbot-0.0.1/tests/unit/test_power_levels.py +58 -0
- onbot-0.0.1/tests/unit/test_rooms.py +105 -0
- onbot-0.0.1/tests/unit/test_skeleton.py +29 -0
- onbot-0.0.1/tests/unit/test_state.py +36 -0
- onbot-0.0.1/tests/unit/test_token_provider.py +110 -0
- onbot-0.0.1/tests/unit/test_utils.py +28 -0
- onbot-0.0.1/tests/unit/test_versions.py +35 -0
- onbot-0.0.1/tests/unit/test_welcome.py +128 -0
onbot-0.0.1/LICENSE
ADDED
|
@@ -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.
|
onbot-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: onbot
|
|
3
|
+
Version: 0.0.1
|
|
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
|
+
Onbot keeps a **Matrix (Synapse)** homeserver in sync with an
|
|
22
|
+
**[Authentik](https://goauthentik.io/)** identity provider and gives every new user a friendly
|
|
23
|
+
welcome into the right rooms.
|
|
24
|
+
|
|
25
|
+
Authentik is the source of truth. Onbot mirrors it into Matrix: each Authentik group becomes a room,
|
|
26
|
+
group membership becomes room membership, and roles become power levels. When a new user shows up,
|
|
27
|
+
they get a guided 1:1 welcome message.
|
|
28
|
+
|
|
29
|
+
Onbot is built for **Matrix 2.0**. It assumes a
|
|
30
|
+
[**Matrix Authentication Service (MAS)**](https://element-hq.github.io/matrix-authentication-service/)
|
|
31
|
+
deployment with Authentik as the upstream identity provider.
|
|
32
|
+
|
|
33
|
+
## What Onbot does and does not do
|
|
34
|
+
|
|
35
|
+
Onbot **does not create accounts**. MAS provisions a Matrix account the first time a user logs in
|
|
36
|
+
through Authentik. Onbot's job is projection: turn Authentik groups into rooms, group membership
|
|
37
|
+
into room membership, attributes into power levels, and drive the offboarding lifecycle when a user
|
|
38
|
+
is disabled.
|
|
39
|
+
|
|
40
|
+
## Quick start with Docker
|
|
41
|
+
|
|
42
|
+
The published image is [`dzdde/onbot`](https://hub.docker.com/r/dzdde/onbot) on Docker Hub. It runs
|
|
43
|
+
as a non-root user and needs one thing from you: a config file.
|
|
44
|
+
|
|
45
|
+
1. Create a `config.yml` (see [Minimal config](#minimal-config) below).
|
|
46
|
+
|
|
47
|
+
2. Run it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
docker run --rm \
|
|
51
|
+
-v "$PWD/config.yml:/config/config.yml:ro" \
|
|
52
|
+
dzdde/onbot:latest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The image defaults to reading `/config/config.yml` and running the long-lived `onbot run` service.
|
|
56
|
+
It also ships a built-in `HEALTHCHECK`.
|
|
57
|
+
|
|
58
|
+
### docker-compose
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
services:
|
|
62
|
+
onbot:
|
|
63
|
+
image: dzdde/onbot:latest
|
|
64
|
+
restart: unless-stopped
|
|
65
|
+
volumes:
|
|
66
|
+
- ./config.yml:/config/config.yml:ro
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
More deployment detail (env-only config, CLI commands, healthcheck) lives in
|
|
70
|
+
[docs/deployment.md](docs/deployment.md).
|
|
71
|
+
|
|
72
|
+
## Minimal config
|
|
73
|
+
|
|
74
|
+
Configuration is a single YAML file. Copy this, fill in the values, save it as `config.yml`:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
synapse_server:
|
|
78
|
+
server_name: company.org # your Matrix domain (the part after the ':')
|
|
79
|
+
server_url: https://internal.matrix # how the bot reaches Synapse (an internal URL is fine)
|
|
80
|
+
bot_user_id: "@welcome-bot:company.org"
|
|
81
|
+
bot_access_token: syt_REPLACE_ME # or an `oauth2:` block instead
|
|
82
|
+
|
|
83
|
+
authentik_server:
|
|
84
|
+
url: https://authentik.company.org/
|
|
85
|
+
api_key: REPLACE_ME # an Authentik API token
|
|
86
|
+
|
|
87
|
+
# Required to enforce offboarding under MAS (omit on non-MAS deployments):
|
|
88
|
+
mas_admin:
|
|
89
|
+
url: https://auth.company.org # the MAS base URL
|
|
90
|
+
client_id: REPLACE_ME # a MAS admin client (in policy.data.admin_clients)
|
|
91
|
+
client_secret: REPLACE_ME
|
|
92
|
+
|
|
93
|
+
sync_authentik_users_with_matrix_rooms:
|
|
94
|
+
authentik_username_mapping_attribute: username # MUST agree with MAS's localpart template
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Two settings above are easy to get wrong and worth calling out:
|
|
98
|
+
|
|
99
|
+
- **`authentik_username_mapping_attribute` must match the localpart template MAS uses.** Onbot
|
|
100
|
+
computes each user's MXID from this Authentik attribute. If it disagrees with MAS, the computed
|
|
101
|
+
MXIDs will not match the real accounts and nobody gets added to rooms.
|
|
102
|
+
- **`mas_admin` is required to actually offboard disabled users.** The Synapse admin API cannot
|
|
103
|
+
revoke a MAS-issued session, only MAS can. Without this block, offboarding silently does nothing
|
|
104
|
+
to live sessions.
|
|
105
|
+
|
|
106
|
+
Every setting can also be supplied via an environment variable (prefix `ONBOT_`, nest with `__`),
|
|
107
|
+
for example `ONBOT_SYNAPSE_SERVER__BOT_ACCESS_TOKEN=syt_…`.
|
|
108
|
+
|
|
109
|
+
> Never commit a real config. `config*.yml` is gitignored (only `config.example.yml` is tracked) and
|
|
110
|
+
> the image carries no secrets. Provide config at runtime.
|
|
111
|
+
|
|
112
|
+
For the full picture (bot credential options, the MAS auth topology, and every field), see the docs
|
|
113
|
+
below.
|
|
114
|
+
|
|
115
|
+
## Documentation
|
|
116
|
+
|
|
117
|
+
- [docs/configuration.md](docs/configuration.md) walks through every config block, the bot
|
|
118
|
+
credential choices, and how to generate the reference.
|
|
119
|
+
- [docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md) lists every field, its type, default,
|
|
120
|
+
description, and `ONBOT_*` env-var name (generated from the model).
|
|
121
|
+
- [docs/deployment.md](docs/deployment.md) covers running with Docker and compose, env-only config,
|
|
122
|
+
the CLI commands, and the healthcheck.
|
|
123
|
+
- [docs/architecture.md](docs/architecture.md) explains the Matrix client to MAS to Authentik auth
|
|
124
|
+
topology and links the architecture decision records.
|
|
125
|
+
- [docs/development.md](docs/development.md) is the setup, build, and release guide for contributors.
|
|
126
|
+
- [docs/testing.md](docs/testing.md) describes the unit, contract, and integration suites.
|
|
127
|
+
- [docs/troubleshooting.md](docs/troubleshooting.md) is a symptom to cause table for common issues.
|
|
128
|
+
- [docs/project/GOALS.md](docs/project/GOALS.md) captures project intent and [docs/project/BATTLE_PLAN.md](docs/project/BATTLE_PLAN.md) the build plan.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT, see [LICENSE](LICENSE).
|
onbot-0.0.1/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Onbot
|
|
2
|
+
|
|
3
|
+
Onbot keeps a **Matrix (Synapse)** homeserver in sync with an
|
|
4
|
+
**[Authentik](https://goauthentik.io/)** identity provider and gives every new user a friendly
|
|
5
|
+
welcome into the right rooms.
|
|
6
|
+
|
|
7
|
+
Authentik is the source of truth. Onbot mirrors it into Matrix: each Authentik group becomes a room,
|
|
8
|
+
group membership becomes room membership, and roles become power levels. When a new user shows up,
|
|
9
|
+
they get a guided 1:1 welcome message.
|
|
10
|
+
|
|
11
|
+
Onbot is built for **Matrix 2.0**. It assumes a
|
|
12
|
+
[**Matrix Authentication Service (MAS)**](https://element-hq.github.io/matrix-authentication-service/)
|
|
13
|
+
deployment with Authentik as the upstream identity provider.
|
|
14
|
+
|
|
15
|
+
## What Onbot does and does not do
|
|
16
|
+
|
|
17
|
+
Onbot **does not create accounts**. MAS provisions a Matrix account the first time a user logs in
|
|
18
|
+
through Authentik. Onbot's job is projection: turn Authentik groups into rooms, group membership
|
|
19
|
+
into room membership, attributes into power levels, and drive the offboarding lifecycle when a user
|
|
20
|
+
is disabled.
|
|
21
|
+
|
|
22
|
+
## Quick start with Docker
|
|
23
|
+
|
|
24
|
+
The published image is [`dzdde/onbot`](https://hub.docker.com/r/dzdde/onbot) on Docker Hub. It runs
|
|
25
|
+
as a non-root user and needs one thing from you: a config file.
|
|
26
|
+
|
|
27
|
+
1. Create a `config.yml` (see [Minimal config](#minimal-config) below).
|
|
28
|
+
|
|
29
|
+
2. Run it:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
docker run --rm \
|
|
33
|
+
-v "$PWD/config.yml:/config/config.yml:ro" \
|
|
34
|
+
dzdde/onbot:latest
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The image defaults to reading `/config/config.yml` and running the long-lived `onbot run` service.
|
|
38
|
+
It also ships a built-in `HEALTHCHECK`.
|
|
39
|
+
|
|
40
|
+
### docker-compose
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
services:
|
|
44
|
+
onbot:
|
|
45
|
+
image: dzdde/onbot:latest
|
|
46
|
+
restart: unless-stopped
|
|
47
|
+
volumes:
|
|
48
|
+
- ./config.yml:/config/config.yml:ro
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
More deployment detail (env-only config, CLI commands, healthcheck) lives in
|
|
52
|
+
[docs/deployment.md](docs/deployment.md).
|
|
53
|
+
|
|
54
|
+
## Minimal config
|
|
55
|
+
|
|
56
|
+
Configuration is a single YAML file. Copy this, fill in the values, save it as `config.yml`:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
synapse_server:
|
|
60
|
+
server_name: company.org # your Matrix domain (the part after the ':')
|
|
61
|
+
server_url: https://internal.matrix # how the bot reaches Synapse (an internal URL is fine)
|
|
62
|
+
bot_user_id: "@welcome-bot:company.org"
|
|
63
|
+
bot_access_token: syt_REPLACE_ME # or an `oauth2:` block instead
|
|
64
|
+
|
|
65
|
+
authentik_server:
|
|
66
|
+
url: https://authentik.company.org/
|
|
67
|
+
api_key: REPLACE_ME # an Authentik API token
|
|
68
|
+
|
|
69
|
+
# Required to enforce offboarding under MAS (omit on non-MAS deployments):
|
|
70
|
+
mas_admin:
|
|
71
|
+
url: https://auth.company.org # the MAS base URL
|
|
72
|
+
client_id: REPLACE_ME # a MAS admin client (in policy.data.admin_clients)
|
|
73
|
+
client_secret: REPLACE_ME
|
|
74
|
+
|
|
75
|
+
sync_authentik_users_with_matrix_rooms:
|
|
76
|
+
authentik_username_mapping_attribute: username # MUST agree with MAS's localpart template
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Two settings above are easy to get wrong and worth calling out:
|
|
80
|
+
|
|
81
|
+
- **`authentik_username_mapping_attribute` must match the localpart template MAS uses.** Onbot
|
|
82
|
+
computes each user's MXID from this Authentik attribute. If it disagrees with MAS, the computed
|
|
83
|
+
MXIDs will not match the real accounts and nobody gets added to rooms.
|
|
84
|
+
- **`mas_admin` is required to actually offboard disabled users.** The Synapse admin API cannot
|
|
85
|
+
revoke a MAS-issued session, only MAS can. Without this block, offboarding silently does nothing
|
|
86
|
+
to live sessions.
|
|
87
|
+
|
|
88
|
+
Every setting can also be supplied via an environment variable (prefix `ONBOT_`, nest with `__`),
|
|
89
|
+
for example `ONBOT_SYNAPSE_SERVER__BOT_ACCESS_TOKEN=syt_…`.
|
|
90
|
+
|
|
91
|
+
> Never commit a real config. `config*.yml` is gitignored (only `config.example.yml` is tracked) and
|
|
92
|
+
> the image carries no secrets. Provide config at runtime.
|
|
93
|
+
|
|
94
|
+
For the full picture (bot credential options, the MAS auth topology, and every field), see the docs
|
|
95
|
+
below.
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
- [docs/configuration.md](docs/configuration.md) walks through every config block, the bot
|
|
100
|
+
credential choices, and how to generate the reference.
|
|
101
|
+
- [docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md) lists every field, its type, default,
|
|
102
|
+
description, and `ONBOT_*` env-var name (generated from the model).
|
|
103
|
+
- [docs/deployment.md](docs/deployment.md) covers running with Docker and compose, env-only config,
|
|
104
|
+
the CLI commands, and the healthcheck.
|
|
105
|
+
- [docs/architecture.md](docs/architecture.md) explains the Matrix client to MAS to Authentik auth
|
|
106
|
+
topology and links the architecture decision records.
|
|
107
|
+
- [docs/development.md](docs/development.md) is the setup, build, and release guide for contributors.
|
|
108
|
+
- [docs/testing.md](docs/testing.md) describes the unit, contract, and integration suites.
|
|
109
|
+
- [docs/troubleshooting.md](docs/troubleshooting.md) is a symptom to cause table for common issues.
|
|
110
|
+
- [docs/project/GOALS.md](docs/project/GOALS.md) captures project intent and [docs/project/BATTLE_PLAN.md](docs/project/BATTLE_PLAN.md) the build plan.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("onbot")
|
|
11
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
onbot-0.0.1/onbot/app.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
effectors = CSApiEffectors(matrix)
|
|
136
|
+
engine = ReconcilerEngine(
|
|
137
|
+
config, authentik, admin, effectors=effectors, events=events, lifecycle=lifecycle
|
|
138
|
+
)
|
|
139
|
+
welcome = WelcomeService(matrix, config)
|
|
140
|
+
listener = OnboardingListener(matrix, welcome, config, events)
|
|
141
|
+
listener.start() # subscribe onboarding to the reconciler's user-provisioned signal (AD-4)
|
|
142
|
+
try:
|
|
143
|
+
yield App(engine=engine, listener=listener)
|
|
144
|
+
finally:
|
|
145
|
+
await effectors.aclose()
|
|
146
|
+
await authentik.aclose()
|
|
147
|
+
await admin.aclose()
|
|
148
|
+
await matrix.aclose()
|
|
149
|
+
if mas_admin is not None:
|
|
150
|
+
await mas_admin.aclose()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def run_service(config: OnbotConfig) -> None:
|
|
154
|
+
"""Run the reconcile loop and the onboarding listener concurrently until stopped."""
|
|
155
|
+
async with build_app(config) as app:
|
|
156
|
+
# The engine owns the signal handlers; when it stops, stop the listener too.
|
|
157
|
+
async def _reconcile() -> None:
|
|
158
|
+
try:
|
|
159
|
+
await app.engine.run()
|
|
160
|
+
finally:
|
|
161
|
+
app.listener.request_stop()
|
|
162
|
+
|
|
163
|
+
await asyncio.gather(_reconcile(), app.listener.run())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def run_reconcile_once(config: OnbotConfig) -> None:
|
|
167
|
+
"""Run a single reconcile pass and exit (``onbot reconcile-once``).
|
|
168
|
+
|
|
169
|
+
Onboarding still fires for users discovered this pass — the listener is subscribed to the bus —
|
|
170
|
+
but the long-running sync stream is not started.
|
|
171
|
+
"""
|
|
172
|
+
async with build_app(config) as app:
|
|
173
|
+
await app.engine.reconcile_once()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""MAS-aware authentication (AD-6).
|
|
2
|
+
|
|
3
|
+
Token providers (:mod:`onbot.auth.token_provider`): a static/compat token or OAuth2
|
|
4
|
+
client-credentials with transparent refresh, behind one :class:`TokenProvider` protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from onbot.auth.token_provider import (
|
|
8
|
+
OAuth2ClientCredentialsTokenProvider,
|
|
9
|
+
OAuth2TokenError,
|
|
10
|
+
StaticTokenProvider,
|
|
11
|
+
TokenProvider,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"OAuth2ClientCredentialsTokenProvider",
|
|
16
|
+
"OAuth2TokenError",
|
|
17
|
+
"StaticTokenProvider",
|
|
18
|
+
"TokenProvider",
|
|
19
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""MAS-aware access-token providers (AD-6, Phase 6).
|
|
2
|
+
|
|
3
|
+
The bot authenticates to the Synapse CS + admin APIs with a bearer token. Under the
|
|
4
|
+
MAS topology two ways of obtaining that token coexist, and the rest of the code must
|
|
5
|
+
not care which is in use:
|
|
6
|
+
|
|
7
|
+
* :class:`StaticTokenProvider` — a fixed token. This is the near-term path: a
|
|
8
|
+
*compatibility token* issued by ``mas-cli manage issue-compatibility-token`` (or a
|
|
9
|
+
legacy Synapse access token when not running MAS). Nothing expires; the token is
|
|
10
|
+
returned verbatim.
|
|
11
|
+
* :class:`OAuth2ClientCredentialsTokenProvider` — the forward-looking path: the bot is
|
|
12
|
+
a confidential OAuth2 client of MAS and mints short-lived access tokens via the
|
|
13
|
+
``client_credentials`` grant, transparently refreshing them before they expire
|
|
14
|
+
(using the ``refresh_token`` grant when MAS returns a refresh token, otherwise a
|
|
15
|
+
fresh ``client_credentials`` exchange).
|
|
16
|
+
|
|
17
|
+
Both satisfy the :class:`TokenProvider` protocol — a single ``async get_token()`` —
|
|
18
|
+
which :class:`~onbot.clients.base.BaseApiClient` calls per request, so a token can
|
|
19
|
+
rotate underneath a long-lived client without reconstructing it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from typing import Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from onbot.logging import get_logger
|
|
30
|
+
|
|
31
|
+
log = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
# Refresh a little before the server-stated expiry so an in-flight request never races
|
|
34
|
+
# the cutoff (clock skew + network latency margin).
|
|
35
|
+
_EXPIRY_MARGIN_SEC = 30.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class TokenProvider(Protocol):
|
|
40
|
+
"""Supplies a bearer token for the Authorization header; may refresh under the hood."""
|
|
41
|
+
|
|
42
|
+
async def get_token(self) -> str: ...
|
|
43
|
+
|
|
44
|
+
async def aclose(self) -> None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StaticTokenProvider:
|
|
48
|
+
"""A fixed bearer token (compat token or legacy Synapse token). Never expires."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, token: str) -> None:
|
|
51
|
+
if not token:
|
|
52
|
+
raise ValueError("StaticTokenProvider requires a non-empty token")
|
|
53
|
+
self._token = token
|
|
54
|
+
|
|
55
|
+
async def get_token(self) -> str:
|
|
56
|
+
return self._token
|
|
57
|
+
|
|
58
|
+
async def aclose(self) -> None: # symmetry with the OAuth2 provider; nothing to close
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OAuth2ClientCredentialsTokenProvider:
|
|
63
|
+
"""Mints + refreshes MAS access tokens via the OAuth2 ``client_credentials`` grant.
|
|
64
|
+
|
|
65
|
+
The first :meth:`get_token` performs a ``client_credentials`` exchange and caches the
|
|
66
|
+
access token together with its expiry. Subsequent calls return the cached token until
|
|
67
|
+
it is within :data:`_EXPIRY_MARGIN_SEC` of expiring, then refresh it — via the
|
|
68
|
+
``refresh_token`` grant when one was issued, else a fresh ``client_credentials``
|
|
69
|
+
exchange. Client authentication uses HTTP Basic per RFC 6749 §2.3.1.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
token_endpoint: str,
|
|
76
|
+
client_id: str,
|
|
77
|
+
client_secret: str,
|
|
78
|
+
scope: str | None = None,
|
|
79
|
+
client: httpx.AsyncClient | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._token_endpoint = token_endpoint
|
|
82
|
+
self._client_id = client_id
|
|
83
|
+
self._client_secret = client_secret
|
|
84
|
+
self._scope = scope
|
|
85
|
+
self._http = client or httpx.AsyncClient(timeout=30.0)
|
|
86
|
+
self._owns_http = client is None
|
|
87
|
+
self._access_token: str | None = None
|
|
88
|
+
self._refresh_token: str | None = None
|
|
89
|
+
self._expires_at: float = 0.0
|
|
90
|
+
|
|
91
|
+
async def get_token(self) -> str:
|
|
92
|
+
if self._access_token is not None and time.monotonic() < self._expires_at - _EXPIRY_MARGIN_SEC:
|
|
93
|
+
return self._access_token
|
|
94
|
+
return await self._refresh()
|
|
95
|
+
|
|
96
|
+
async def _refresh(self) -> str:
|
|
97
|
+
if self._refresh_token is not None:
|
|
98
|
+
data = {"grant_type": "refresh_token", "refresh_token": self._refresh_token}
|
|
99
|
+
else:
|
|
100
|
+
data = {"grant_type": "client_credentials"}
|
|
101
|
+
if self._scope:
|
|
102
|
+
data["scope"] = self._scope
|
|
103
|
+
payload = await self._token_request(data)
|
|
104
|
+
self._store(payload)
|
|
105
|
+
assert self._access_token is not None # _store guarantees it or raised
|
|
106
|
+
return self._access_token
|
|
107
|
+
|
|
108
|
+
async def _token_request(self, data: dict[str, str]) -> dict[str, object]:
|
|
109
|
+
response = await self._http.post(
|
|
110
|
+
self._token_endpoint,
|
|
111
|
+
data=data,
|
|
112
|
+
auth=(self._client_id, self._client_secret),
|
|
113
|
+
headers={"Accept": "application/json"},
|
|
114
|
+
)
|
|
115
|
+
if response.status_code >= 400:
|
|
116
|
+
# A failed refresh may mean the refresh token was revoked; drop it so the next
|
|
117
|
+
# attempt falls back to a fresh client_credentials exchange.
|
|
118
|
+
self._refresh_token = None
|
|
119
|
+
raise OAuth2TokenError(response.status_code, _safe_json(response))
|
|
120
|
+
result: dict[str, object] = response.json()
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
def _store(self, payload: dict[str, object]) -> None:
|
|
124
|
+
token = payload.get("access_token")
|
|
125
|
+
if not isinstance(token, str):
|
|
126
|
+
raise OAuth2TokenError(200, payload)
|
|
127
|
+
self._access_token = token
|
|
128
|
+
refresh = payload.get("refresh_token")
|
|
129
|
+
self._refresh_token = refresh if isinstance(refresh, str) else None
|
|
130
|
+
expires_in = payload.get("expires_in")
|
|
131
|
+
# Default to a conservative lifetime if the server omits expires_in.
|
|
132
|
+
seconds = float(expires_in) if isinstance(expires_in, int | float) else 300.0
|
|
133
|
+
self._expires_at = time.monotonic() + seconds
|
|
134
|
+
|
|
135
|
+
async def aclose(self) -> None:
|
|
136
|
+
if self._owns_http:
|
|
137
|
+
await self._http.aclose()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class OAuth2TokenError(Exception):
|
|
141
|
+
"""The MAS token endpoint rejected a token request."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, status_code: int, payload: object) -> None:
|
|
144
|
+
self.status_code = status_code
|
|
145
|
+
self.payload = payload
|
|
146
|
+
super().__init__(f"OAuth2 token request failed (HTTP {status_code}): {payload!r}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _safe_json(response: httpx.Response) -> object:
|
|
150
|
+
try:
|
|
151
|
+
return response.json()
|
|
152
|
+
except ValueError:
|
|
153
|
+
return response.text
|