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.
Files changed (80) hide show
  1. onbot-0.0.1/LICENSE +21 -0
  2. onbot-0.0.1/PKG-INFO +132 -0
  3. onbot-0.0.1/README.md +114 -0
  4. onbot-0.0.1/onbot/__init__.py +12 -0
  5. onbot-0.0.1/onbot/__main__.py +6 -0
  6. onbot-0.0.1/onbot/app.py +173 -0
  7. onbot-0.0.1/onbot/auth/__init__.py +19 -0
  8. onbot-0.0.1/onbot/auth/token_provider.py +153 -0
  9. onbot-0.0.1/onbot/cli.py +80 -0
  10. onbot-0.0.1/onbot/clients/__init__.py +5 -0
  11. onbot-0.0.1/onbot/clients/authentik.py +123 -0
  12. onbot-0.0.1/onbot/clients/base.py +243 -0
  13. onbot-0.0.1/onbot/clients/mas_admin.py +65 -0
  14. onbot-0.0.1/onbot/clients/matrix.py +482 -0
  15. onbot-0.0.1/onbot/clients/synapse_admin.py +159 -0
  16. onbot-0.0.1/onbot/clients/versions.py +61 -0
  17. onbot-0.0.1/onbot/config.py +660 -0
  18. onbot-0.0.1/onbot/events.py +51 -0
  19. onbot-0.0.1/onbot/healthcheck.py +134 -0
  20. onbot-0.0.1/onbot/identity.py +44 -0
  21. onbot-0.0.1/onbot/lifecycle/__init__.py +2 -0
  22. onbot-0.0.1/onbot/lifecycle/accounts.py +309 -0
  23. onbot-0.0.1/onbot/logging.py +26 -0
  24. onbot-0.0.1/onbot/media.py +46 -0
  25. onbot-0.0.1/onbot/models.py +75 -0
  26. onbot-0.0.1/onbot/onboarding/__init__.py +5 -0
  27. onbot-0.0.1/onbot/onboarding/listener.py +103 -0
  28. onbot-0.0.1/onbot/onboarding/welcome.py +116 -0
  29. onbot-0.0.1/onbot/reconciler/__init__.py +2 -0
  30. onbot-0.0.1/onbot/reconciler/effectors.py +100 -0
  31. onbot-0.0.1/onbot/reconciler/engine.py +395 -0
  32. onbot-0.0.1/onbot/reconciler/membership.py +54 -0
  33. onbot-0.0.1/onbot/reconciler/power_levels.py +96 -0
  34. onbot-0.0.1/onbot/reconciler/rooms.py +135 -0
  35. onbot-0.0.1/onbot/reconciler/state.py +77 -0
  36. onbot-0.0.1/onbot/utils.py +61 -0
  37. onbot-0.0.1/pyproject.toml +126 -0
  38. onbot-0.0.1/tests/README.md +17 -0
  39. onbot-0.0.1/tests/__init__.py +0 -0
  40. onbot-0.0.1/tests/contract/__init__.py +0 -0
  41. onbot-0.0.1/tests/contract/test_authentik_client.py +56 -0
  42. onbot-0.0.1/tests/contract/test_base_client.py +107 -0
  43. onbot-0.0.1/tests/contract/test_matrix_client.py +428 -0
  44. onbot-0.0.1/tests/contract/test_synapse_admin_client.py +235 -0
  45. onbot-0.0.1/tests/integration/__init__.py +0 -0
  46. onbot-0.0.1/tests/integration/conftest.py +211 -0
  47. onbot-0.0.1/tests/integration/stack/authentik/blueprints/onbot.yaml +53 -0
  48. onbot-0.0.1/tests/integration/stack/docker-compose.yml +118 -0
  49. onbot-0.0.1/tests/integration/stack/mas/config.yaml +131 -0
  50. onbot-0.0.1/tests/integration/stack/postgres/10-init-dbs.sh +12 -0
  51. onbot-0.0.1/tests/integration/stack/synapse/homeserver.yaml +59 -0
  52. onbot-0.0.1/tests/integration/stack/synapse/log.config +15 -0
  53. onbot-0.0.1/tests/integration/stack_api.py +325 -0
  54. onbot-0.0.1/tests/integration/test_lifecycle.py +52 -0
  55. onbot-0.0.1/tests/integration/test_media.py +22 -0
  56. onbot-0.0.1/tests/integration/test_onboarding.py +39 -0
  57. onbot-0.0.1/tests/integration/test_provisioning.py +24 -0
  58. onbot-0.0.1/tests/integration/test_q1_experiment.py +45 -0
  59. onbot-0.0.1/tests/integration/test_reconcile.py +80 -0
  60. onbot-0.0.1/tests/unit/__init__.py +0 -0
  61. onbot-0.0.1/tests/unit/test_cli.py +43 -0
  62. onbot-0.0.1/tests/unit/test_config.py +64 -0
  63. onbot-0.0.1/tests/unit/test_config_docs.py +38 -0
  64. onbot-0.0.1/tests/unit/test_effectors.py +31 -0
  65. onbot-0.0.1/tests/unit/test_engine.py +273 -0
  66. onbot-0.0.1/tests/unit/test_healthcheck.py +81 -0
  67. onbot-0.0.1/tests/unit/test_identity.py +32 -0
  68. onbot-0.0.1/tests/unit/test_lifecycle.py +401 -0
  69. onbot-0.0.1/tests/unit/test_listener.py +165 -0
  70. onbot-0.0.1/tests/unit/test_main_module.py +9 -0
  71. onbot-0.0.1/tests/unit/test_media.py +39 -0
  72. onbot-0.0.1/tests/unit/test_membership.py +56 -0
  73. onbot-0.0.1/tests/unit/test_power_levels.py +58 -0
  74. onbot-0.0.1/tests/unit/test_rooms.py +105 -0
  75. onbot-0.0.1/tests/unit/test_skeleton.py +29 -0
  76. onbot-0.0.1/tests/unit/test_state.py +36 -0
  77. onbot-0.0.1/tests/unit/test_token_provider.py +110 -0
  78. onbot-0.0.1/tests/unit/test_utils.py +28 -0
  79. onbot-0.0.1/tests/unit/test_versions.py +35 -0
  80. 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"
@@ -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,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