imbi-api 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. imbi_api/__init__.py +15 -0
  2. imbi_api/app.py +83 -0
  3. imbi_api/auth/__init__.py +1 -0
  4. imbi_api/auth/local_auth.py +58 -0
  5. imbi_api/auth/login_providers.py +255 -0
  6. imbi_api/auth/models.py +86 -0
  7. imbi_api/auth/oauth.py +353 -0
  8. imbi_api/auth/password.py +58 -0
  9. imbi_api/auth/permissions.py +596 -0
  10. imbi_api/auth/seed.py +704 -0
  11. imbi_api/auth/sessions.py +106 -0
  12. imbi_api/auth/tokens.py +187 -0
  13. imbi_api/domain/__init__.py +1 -0
  14. imbi_api/domain/models.py +1191 -0
  15. imbi_api/domain/scoring.py +141 -0
  16. imbi_api/email/__init__.py +171 -0
  17. imbi_api/email/client.py +274 -0
  18. imbi_api/email/dependencies.py +34 -0
  19. imbi_api/email/models.py +131 -0
  20. imbi_api/email/templates/base.html +203 -0
  21. imbi_api/email/templates/base.txt +13 -0
  22. imbi_api/email/templates/password_reset.html +49 -0
  23. imbi_api/email/templates/password_reset.txt +24 -0
  24. imbi_api/email/templates/welcome.html +42 -0
  25. imbi_api/email/templates/welcome.txt +24 -0
  26. imbi_api/email/templates.py +192 -0
  27. imbi_api/endpoints/__init__.py +59 -0
  28. imbi_api/endpoints/_helpers.py +241 -0
  29. imbi_api/endpoints/admin.py +48 -0
  30. imbi_api/endpoints/admin_plugins.py +483 -0
  31. imbi_api/endpoints/api_keys.py +406 -0
  32. imbi_api/endpoints/auth.py +1311 -0
  33. imbi_api/endpoints/auth_providers.py +668 -0
  34. imbi_api/endpoints/blueprints.py +347 -0
  35. imbi_api/endpoints/client_credentials.py +396 -0
  36. imbi_api/endpoints/document_templates.py +590 -0
  37. imbi_api/endpoints/documents.py +678 -0
  38. imbi_api/endpoints/environments.py +537 -0
  39. imbi_api/endpoints/events.py +264 -0
  40. imbi_api/endpoints/identity_plugins.py +79 -0
  41. imbi_api/endpoints/link_definitions.py +488 -0
  42. imbi_api/endpoints/local_auth.py +77 -0
  43. imbi_api/endpoints/mfa.py +456 -0
  44. imbi_api/endpoints/operations_log.py +642 -0
  45. imbi_api/endpoints/organizations.py +523 -0
  46. imbi_api/endpoints/plugin_edges.py +369 -0
  47. imbi_api/endpoints/plugin_entities.py +345 -0
  48. imbi_api/endpoints/project_configuration.py +408 -0
  49. imbi_api/endpoints/project_deployments.py +1113 -0
  50. imbi_api/endpoints/project_logs.py +525 -0
  51. imbi_api/endpoints/project_plugins.py +201 -0
  52. imbi_api/endpoints/project_type_plugins.py +167 -0
  53. imbi_api/endpoints/project_types.py +458 -0
  54. imbi_api/endpoints/projects.py +1632 -0
  55. imbi_api/endpoints/releases.py +1070 -0
  56. imbi_api/endpoints/roles.py +567 -0
  57. imbi_api/endpoints/sa_api_keys.py +387 -0
  58. imbi_api/endpoints/scoring.py +574 -0
  59. imbi_api/endpoints/scoring_policies.py +345 -0
  60. imbi_api/endpoints/service_accounts.py +449 -0
  61. imbi_api/endpoints/service_plugins.py +869 -0
  62. imbi_api/endpoints/status.py +21 -0
  63. imbi_api/endpoints/tags.py +334 -0
  64. imbi_api/endpoints/teams.py +621 -0
  65. imbi_api/endpoints/third_party_services.py +1333 -0
  66. imbi_api/endpoints/uploads.py +363 -0
  67. imbi_api/endpoints/user_activity.py +1047 -0
  68. imbi_api/endpoints/users.py +918 -0
  69. imbi_api/endpoints/webhooks.py +891 -0
  70. imbi_api/entrypoint.py +228 -0
  71. imbi_api/graph_sql.py +26 -0
  72. imbi_api/identity/__init__.py +1 -0
  73. imbi_api/identity/endpoints.py +272 -0
  74. imbi_api/identity/errors.py +28 -0
  75. imbi_api/identity/flows.py +434 -0
  76. imbi_api/identity/host_integration.py +168 -0
  77. imbi_api/identity/models.py +79 -0
  78. imbi_api/identity/repository.py +428 -0
  79. imbi_api/identity/resolution.py +203 -0
  80. imbi_api/identity/state.py +127 -0
  81. imbi_api/identity/sweeper.py +133 -0
  82. imbi_api/lifespans.py +163 -0
  83. imbi_api/llm/__init__.py +0 -0
  84. imbi_api/llm/dependencies.py +20 -0
  85. imbi_api/middleware/__init__.py +5 -0
  86. imbi_api/middleware/rate_limit.py +53 -0
  87. imbi_api/models.py +104 -0
  88. imbi_api/openapi.py +437 -0
  89. imbi_api/patch.py +107 -0
  90. imbi_api/plugins/__init__.py +40 -0
  91. imbi_api/plugins/assignments.py +128 -0
  92. imbi_api/plugins/credentials.py +245 -0
  93. imbi_api/plugins/installer.py +87 -0
  94. imbi_api/plugins/lifecycle.py +141 -0
  95. imbi_api/plugins/reload.py +80 -0
  96. imbi_api/plugins/resolution.py +233 -0
  97. imbi_api/plugins/schemas.py +80 -0
  98. imbi_api/py.typed +0 -0
  99. imbi_api/relationships.py +23 -0
  100. imbi_api/scoring/__init__.py +44 -0
  101. imbi_api/scoring/queue.py +281 -0
  102. imbi_api/settings.py +302 -0
  103. imbi_api/storage/__init__.py +13 -0
  104. imbi_api/storage/client.py +187 -0
  105. imbi_api/storage/dependencies.py +21 -0
  106. imbi_api/storage/thumbnails.py +90 -0
  107. imbi_api/storage/validation.py +107 -0
  108. imbi_api-2.0.0.dist-info/METADATA +188 -0
  109. imbi_api-2.0.0.dist-info/RECORD +112 -0
  110. imbi_api-2.0.0.dist-info/WHEEL +4 -0
  111. imbi_api-2.0.0.dist-info/entry_points.txt +2 -0
  112. imbi_api-2.0.0.dist-info/licenses/LICENSE +28 -0
imbi_api/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Imbi
3
+ ====
4
+
5
+ Imbi is a DevOps Service Management Platform designed to provide an efficient
6
+ way to manage a large environment that contains many services and applications.
7
+
8
+ """
9
+
10
+ from importlib import metadata
11
+
12
+ try:
13
+ version = metadata.version('imbi-api')
14
+ except metadata.PackageNotFoundError:
15
+ version = '0.0.0'
imbi_api/app.py ADDED
@@ -0,0 +1,83 @@
1
+ import logging
2
+
3
+ import fastapi
4
+ from fastapi import responses
5
+ from fastapi.middleware import cors
6
+ from imbi_common import graph, lifespan, valkey
7
+ from imbi_common.plugins.errors import PluginCredentialsMissing
8
+ from uvicorn.middleware import proxy_headers
9
+
10
+ from imbi_api import endpoints, lifespans, openapi, settings, version
11
+ from imbi_api.middleware import rate_limit
12
+
13
+ LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ def create_app() -> fastapi.FastAPI:
17
+ app = fastapi.FastAPI(
18
+ title='Imbi',
19
+ lifespan=lifespan.Lifespan(
20
+ lifespans.clickhouse_hook,
21
+ graph.graph_lifespan,
22
+ lifespans.email_hook,
23
+ lifespans.storage_hook,
24
+ lifespans.anthropic_hook,
25
+ valkey.valkey_lifespan,
26
+ lifespans.score_worker_hook,
27
+ lifespans.identity_refresh_hook,
28
+ ),
29
+ version=version,
30
+ redoc_url=None,
31
+ docs_url=None,
32
+ license_info={
33
+ 'name': 'BSD 3-Clause',
34
+ 'url': 'https://github.com/AWeber-Imbi/imbi-api/blob/main/LICENSE',
35
+ },
36
+ )
37
+
38
+ server_config = settings.ServerConfig()
39
+ app.add_middleware(
40
+ cors.CORSMiddleware,
41
+ allow_origins=server_config.cors_allowed_origins,
42
+ allow_credentials=True,
43
+ allow_methods=['*'],
44
+ allow_headers=['authorization'],
45
+ )
46
+
47
+ # Honor X-Forwarded-For from trusted proxies so rate limiting keys
48
+ # on the real client IP rather than the proxy address. Skipped
49
+ # when forwarded_allow_ips is empty (dev/no-proxy deployments).
50
+ if server_config.forwarded_allow_ips:
51
+ app.add_middleware(
52
+ proxy_headers.ProxyHeadersMiddleware,
53
+ trusted_hosts=server_config.forwarded_allow_ips,
54
+ )
55
+
56
+ # Translate plugin credential failures (raised either at credential
57
+ # lookup or from inside a plugin handler at runtime) into 503 so
58
+ # callers don't see opaque 500s when an integration isn't configured.
59
+ @app.exception_handler(PluginCredentialsMissing)
60
+ async def _plugin_credentials_missing( # pyright: ignore[reportUnusedFunction]
61
+ _request: fastapi.Request,
62
+ exc: PluginCredentialsMissing,
63
+ ) -> responses.JSONResponse:
64
+ LOGGER.warning('Plugin credentials missing: %s', exc)
65
+ return responses.JSONResponse(
66
+ status_code=503,
67
+ content={'detail': str(exc)},
68
+ )
69
+
70
+ # Phase 5: Setup rate limiting middleware
71
+ rate_limit.setup_rate_limiting(app)
72
+
73
+ app.add_route('/docs', openapi.stoplights_html, include_in_schema=False)
74
+ for router in endpoints.prefixed_routers:
75
+ app.include_router(router, prefix=server_config.api_prefix)
76
+ for router in endpoints.unprefixed_routers:
77
+ app.include_router(router)
78
+
79
+ # Set custom OpenAPI schema generator with blueprint-enhanced models
80
+ # FastAPI pattern: override openapi method to customize schema
81
+ app.openapi = openapi.create_custom_openapi(app) # type: ignore[method-assign]
82
+
83
+ return app
@@ -0,0 +1 @@
1
+ """Authentication and authorization module."""
@@ -0,0 +1,58 @@
1
+ """Local password authentication configuration repository.
2
+
3
+ Reads/writes the singleton ``LocalAuthConfig`` node in the graph.
4
+ A small in-memory TTL cache keeps the ``/auth/providers`` hot path
5
+ off the graph. Writes invalidate the cache.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import logging
12
+ import time
13
+
14
+ from imbi_common import graph
15
+
16
+ from imbi_api.domain import models
17
+
18
+ LOGGER = logging.getLogger(__name__)
19
+
20
+ _CACHE_TTL_SECONDS = 30.0
21
+ _CACHE_KEY = 'global'
22
+
23
+ _config_cache: dict[str, tuple[models.LocalAuthConfig, float]] = {}
24
+
25
+
26
+ def _invalidate_cache() -> None:
27
+ """Drop the cached config entry."""
28
+ _config_cache.clear()
29
+
30
+
31
+ async def get_config(db: graph.Graph) -> models.LocalAuthConfig:
32
+ """Return the local-auth config, defaulting to enabled.
33
+
34
+ If no row exists yet a default ``LocalAuthConfig(enabled=True)`` is
35
+ returned without persisting it (lazy-default semantics).
36
+ """
37
+ cached = _config_cache.get(_CACHE_KEY)
38
+ now = time.time()
39
+ if cached is not None and (now - cached[1]) < _CACHE_TTL_SECONDS:
40
+ return cached[0]
41
+
42
+ rows = await db.match(models.LocalAuthConfig, {'key': 'global'})
43
+ config = rows[0] if rows else models.LocalAuthConfig(enabled=True)
44
+ _config_cache[_CACHE_KEY] = (config, now)
45
+ return config
46
+
47
+
48
+ async def set_enabled(
49
+ db: graph.Graph, enabled: bool
50
+ ) -> models.LocalAuthConfig:
51
+ """Persist the singleton config and invalidate the cache."""
52
+ config = models.LocalAuthConfig(
53
+ enabled=enabled,
54
+ updated_at=datetime.datetime.now(datetime.UTC),
55
+ )
56
+ await db.merge(config, ['key'])
57
+ _invalidate_cache()
58
+ return config
@@ -0,0 +1,255 @@
1
+ """Login-provider repository.
2
+
3
+ Reads ``ServiceApplication`` rows whose ``usage`` is ``'login'`` or
4
+ ``'both'``, joined to their parent ``ThirdPartyService`` for the OAuth
5
+ endpoints. Cross-org by design — login providers are an instance-level
6
+ concern even though each row is owned by a single organization.
7
+
8
+ A 30s in-memory TTL cache keeps the OAuth hot path off the graph.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import time
15
+ import typing
16
+
17
+ import pydantic
18
+ from imbi_common import graph
19
+
20
+ from imbi_api import settings
21
+
22
+ LOGGER = logging.getLogger(__name__)
23
+
24
+ _CACHE_TTL_SECONDS = 30.0
25
+
26
+ _provider_cache: dict[str, tuple[LoginApp, float]] = {}
27
+ _list_cache: dict[bool, tuple[list[LoginApp], float]] = {}
28
+
29
+
30
+ class LoginApp(pydantic.BaseModel):
31
+ """Flat view of a login-eligible row.
32
+
33
+ Combines the application or identity-plugin row with the OAuth
34
+ endpoints from the parent ``ThirdPartyService`` so callers don't
35
+ need to traverse the graph again.
36
+
37
+ Two sources feed this list:
38
+
39
+ * ``source='service_app'`` — legacy ``ServiceApplication`` rows
40
+ whose ``usage`` is ``'login'`` or ``'both'``. ``oauth_app_type``
41
+ is one of the hardcoded literals (``google``, ``github``,
42
+ ``oidc``).
43
+ * ``source='identity_plugin'`` — ``Plugin`` nodes whose manifest is
44
+ ``login_capable=true`` *and* whose row has ``used_as_login=true``.
45
+ ``oauth_app_type='identity_plugin'`` and ``plugin_id`` carries the
46
+ Plugin nano-ID so the auth router can route start/callback
47
+ through ``imbi_api.identity.flows``.
48
+ """
49
+
50
+ model_config = pydantic.ConfigDict(extra='ignore')
51
+
52
+ slug: str
53
+ name: str
54
+ oauth_app_type: typing.Literal[
55
+ 'google', 'github', 'oidc', 'identity_plugin'
56
+ ]
57
+ source: typing.Literal['service_app', 'identity_plugin'] = 'service_app'
58
+ plugin_id: str | None = None
59
+ plugin_slug: str | None = None
60
+ client_id: str | None = None
61
+ client_secret_encrypted: str | None = None
62
+ issuer_url: str | None = None
63
+ allowed_domains: list[str] = pydantic.Field(default_factory=list)
64
+ scopes: list[str] = pydantic.Field(default_factory=list)
65
+ status: str = 'active'
66
+ authorization_endpoint: str | None = None
67
+ token_endpoint: str | None = None
68
+ revoke_endpoint: str | None = None
69
+ callback_url: str = ''
70
+
71
+
72
+ def invalidate_cache(slug: str | None = None) -> None:
73
+ """Drop cached entries.
74
+
75
+ ``slug=None`` clears everything; otherwise only the slug + lists.
76
+ """
77
+ if slug is None:
78
+ _provider_cache.clear()
79
+ else:
80
+ _provider_cache.pop(slug, None)
81
+ _list_cache.clear()
82
+
83
+
84
+ _LIST_QUERY: typing.LiteralString = """
85
+ MATCH (a:ServiceApplication)-[:REGISTERED_IN]->(s:ThirdPartyService)
86
+ WHERE a.usage IN ['login', 'both']
87
+ RETURN a{{.*}} AS app, s{{.*}} AS service
88
+ ORDER BY a.slug
89
+ """
90
+
91
+ _IDENTITY_LOGIN_QUERY: typing.LiteralString = """
92
+ MATCH (p:Plugin)
93
+ WHERE p.login_capable = true AND p.used_as_login = true
94
+ RETURN p{{.*}} AS plugin
95
+ ORDER BY p.plugin_slug
96
+ """
97
+
98
+
99
+ def _row_to_login_app(
100
+ app: dict[str, typing.Any],
101
+ svc: dict[str, typing.Any] | None,
102
+ ) -> LoginApp:
103
+ """Materialize a ``LoginApp`` from raw graph dicts."""
104
+ raw_scopes = app.get('scopes')
105
+ if isinstance(raw_scopes, str):
106
+ import json as _json
107
+
108
+ try:
109
+ scopes = _json.loads(raw_scopes)
110
+ except (ValueError, TypeError):
111
+ scopes = []
112
+ else:
113
+ scopes = list(raw_scopes) if raw_scopes else []
114
+ raw_domains = app.get('allowed_domains')
115
+ if isinstance(raw_domains, str):
116
+ import json as _json
117
+
118
+ try:
119
+ domains = _json.loads(raw_domains)
120
+ except (ValueError, TypeError):
121
+ domains = []
122
+ else:
123
+ domains = list(raw_domains) if raw_domains else []
124
+ return LoginApp(
125
+ slug=app['slug'],
126
+ name=app.get('name', app['slug']),
127
+ oauth_app_type=app['oauth_app_type'],
128
+ client_id=app.get('client_id'),
129
+ client_secret_encrypted=app.get('client_secret'),
130
+ issuer_url=app.get('issuer_url'),
131
+ allowed_domains=domains,
132
+ scopes=scopes,
133
+ status=app.get('status', 'active'),
134
+ authorization_endpoint=(
135
+ svc.get('authorization_endpoint') if svc else None
136
+ ),
137
+ token_endpoint=svc.get('token_endpoint') if svc else None,
138
+ revoke_endpoint=svc.get('revoke_endpoint') if svc else None,
139
+ callback_url=settings.oauth_callback_url(app['slug']),
140
+ )
141
+
142
+
143
+ def _plugin_row_to_login_app(plugin: dict[str, typing.Any]) -> LoginApp:
144
+ """Materialize a ``LoginApp`` from a Plugin node row.
145
+
146
+ Identity-plugin rows have no client_id/client_secret on the Plugin
147
+ node — those live on the parent ``ServiceApplication`` and are
148
+ looked up by the identity flow at start/callback time. The
149
+ ``LoginApp`` returned here only carries enough metadata for the UI
150
+ to render the row and for the auth router to dispatch.
151
+ """
152
+ return LoginApp(
153
+ slug=plugin['plugin_slug'],
154
+ name=plugin.get('label') or plugin['plugin_slug'],
155
+ oauth_app_type='identity_plugin',
156
+ source='identity_plugin',
157
+ plugin_id=plugin['id'],
158
+ plugin_slug=plugin['plugin_slug'],
159
+ status='active',
160
+ callback_url=settings.oauth_callback_url(plugin['plugin_slug']),
161
+ )
162
+
163
+
164
+ async def list_login_apps(
165
+ db: graph.Graph,
166
+ *,
167
+ enabled_only: bool = False,
168
+ ) -> list[LoginApp]:
169
+ """Return every login-eligible row, merged across both sources.
170
+
171
+ Cached per ``enabled_only`` for ``_CACHE_TTL_SECONDS``. The merge
172
+ rule is "identity-plugin row wins on slug collision" — operators
173
+ are expected to disable the legacy ``ServiceApplication.usage``
174
+ flag once the identity-plugin equivalent is in use, but if both
175
+ are flagged, the identity row is the canonical source.
176
+ """
177
+ cached = _list_cache.get(enabled_only)
178
+ now = time.time()
179
+ if cached is not None and (now - cached[1]) < _CACHE_TTL_SECONDS:
180
+ return list(cached[0])
181
+
182
+ records = await db.execute(_LIST_QUERY, {}, ['app', 'service'])
183
+ apps: dict[str, LoginApp] = {}
184
+ for record in records:
185
+ app = graph.parse_agtype(record['app'])
186
+ svc = graph.parse_agtype(record.get('service'))
187
+ if not app.get('oauth_app_type'):
188
+ continue
189
+ login_app = _row_to_login_app(app, svc)
190
+ if enabled_only and login_app.status != 'active':
191
+ continue
192
+ apps[login_app.slug] = login_app
193
+
194
+ plugin_records = await db.execute(_IDENTITY_LOGIN_QUERY, {}, ['plugin'])
195
+ for record in plugin_records:
196
+ plugin = graph.parse_agtype(record['plugin'])
197
+ if not plugin.get('plugin_slug') or not plugin.get('id'):
198
+ continue
199
+ login_app = _plugin_row_to_login_app(plugin)
200
+ # Identity-plugin row wins on slug collision (operators are
201
+ # expected to disable the legacy service-app flag once they
202
+ # promote the identity plugin to a login provider).
203
+ apps[login_app.slug] = login_app
204
+
205
+ merged = list(apps.values())
206
+ _list_cache[enabled_only] = (list(merged), now)
207
+ return merged
208
+
209
+
210
+ _GET_QUERY: typing.LiteralString = """
211
+ MATCH (a:ServiceApplication {{slug: {slug}}})
212
+ -[:REGISTERED_IN]->(s:ThirdPartyService)
213
+ WHERE a.usage IN ['login', 'both']
214
+ RETURN a{{.*}} AS app, s{{.*}} AS service
215
+ """
216
+
217
+ _IDENTITY_LOGIN_GET_QUERY: typing.LiteralString = """
218
+ MATCH (p:Plugin {{plugin_slug: {slug}}})
219
+ WHERE p.login_capable = true AND p.used_as_login = true
220
+ RETURN p{{.*}} AS plugin
221
+ LIMIT 1
222
+ """
223
+
224
+
225
+ async def get_login_app(db: graph.Graph, slug: str) -> LoginApp | None:
226
+ """Look up a single login app by slug.
227
+
228
+ Checks the identity-plugin source first so it wins on collision
229
+ (matching :func:`list_login_apps`).
230
+ """
231
+ cached = _provider_cache.get(slug)
232
+ now = time.time()
233
+ if cached is not None and (now - cached[1]) < _CACHE_TTL_SECONDS:
234
+ return cached[0]
235
+
236
+ plugin_records = await db.execute(
237
+ _IDENTITY_LOGIN_GET_QUERY, {'slug': slug}, ['plugin']
238
+ )
239
+ if plugin_records:
240
+ plugin = graph.parse_agtype(plugin_records[0]['plugin'])
241
+ if plugin.get('plugin_slug') and plugin.get('id'):
242
+ login_app = _plugin_row_to_login_app(plugin)
243
+ _provider_cache[slug] = (login_app, now)
244
+ return login_app
245
+
246
+ records = await db.execute(_GET_QUERY, {'slug': slug}, ['app', 'service'])
247
+ if not records:
248
+ return None
249
+ app = graph.parse_agtype(records[0]['app'])
250
+ svc = graph.parse_agtype(records[0].get('service'))
251
+ if not app.get('oauth_app_type'):
252
+ return None
253
+ login_app = _row_to_login_app(app, svc)
254
+ _provider_cache[slug] = (login_app, now)
255
+ return login_app
@@ -0,0 +1,86 @@
1
+ """Request and response models for authentication endpoints."""
2
+
3
+ import typing
4
+
5
+ import pydantic
6
+
7
+
8
+ class LoginRequest(pydantic.BaseModel):
9
+ """Login request with email and password."""
10
+
11
+ email: pydantic.EmailStr
12
+ password: str
13
+
14
+
15
+ class TokenResponse(pydantic.BaseModel):
16
+ """JWT token response."""
17
+
18
+ access_token: str
19
+ refresh_token: str
20
+ token_type: str = 'bearer'
21
+ expires_in: int
22
+
23
+
24
+ class TokenRefreshRequest(pydantic.BaseModel):
25
+ """Request to refresh an access token."""
26
+
27
+ refresh_token: str
28
+
29
+
30
+ class AuthProvider(pydantic.BaseModel):
31
+ """Authentication provider configuration for UI."""
32
+
33
+ id: str # 'google', 'github', 'oidc', 'local'
34
+ type: typing.Literal['oauth', 'password']
35
+ name: str # Display name
36
+ enabled: bool
37
+ auth_url: str | None = None # URL to initiate auth (for OAuth)
38
+ icon: str | None = None # Icon identifier for UI
39
+
40
+
41
+ class AuthProvidersResponse(pydantic.BaseModel):
42
+ """List of available authentication providers."""
43
+
44
+ providers: list[AuthProvider]
45
+ default_redirect: str = '/dashboard'
46
+
47
+
48
+ class OAuthStateData(pydantic.BaseModel):
49
+ """Data stored in OAuth state parameter for CSRF protection.
50
+
51
+ The identity-plugin flow extends this with optional fields:
52
+
53
+ * ``intent`` discriminates ``'login'`` from ``'identity'``. Login
54
+ flows still create the local user; identity flows persist an
55
+ :class:`imbi_common.models.IdentityConnection` for the actor.
56
+ * ``plugin_id`` names the target identity plugin (or ``None`` for
57
+ the legacy hardcoded login providers).
58
+ * ``code_verifier`` carries the PKCE verifier through the redirect
59
+ so we don't need server-side state for in-flight flows.
60
+ * ``return_to`` is where the UI lands after a successful exchange.
61
+ * ``actor_user_id`` lets a logged-in user begin an identity flow
62
+ without re-authenticating.
63
+ * ``device_code`` is set for OAuth 2.0 device-code flows (e.g.
64
+ AWS IAM IC). The IdP issues the code at ``StartDeviceAuthorization``
65
+ time and there is no redirect callback to echo it back, so the
66
+ host signs it into the state JWT and pulls it back out on the
67
+ poll endpoint to call ``CreateToken``.
68
+ """
69
+
70
+ provider: str
71
+ nonce: str # Random nonce for CSRF
72
+ redirect_uri: str # Where to redirect after auth
73
+ timestamp: int # Unix timestamp for expiry
74
+ intent: typing.Literal['login', 'identity'] = 'login'
75
+ plugin_id: str | None = None
76
+ code_verifier: str | None = None
77
+ return_to: str | None = None
78
+ actor_user_id: str | None = None
79
+ device_code: str | None = None
80
+
81
+
82
+ class OAuthCallbackError(pydantic.BaseModel):
83
+ """OAuth callback error response."""
84
+
85
+ error: str
86
+ error_description: str | None = None