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.
- imbi_api/__init__.py +15 -0
- imbi_api/app.py +83 -0
- imbi_api/auth/__init__.py +1 -0
- imbi_api/auth/local_auth.py +58 -0
- imbi_api/auth/login_providers.py +255 -0
- imbi_api/auth/models.py +86 -0
- imbi_api/auth/oauth.py +353 -0
- imbi_api/auth/password.py +58 -0
- imbi_api/auth/permissions.py +596 -0
- imbi_api/auth/seed.py +704 -0
- imbi_api/auth/sessions.py +106 -0
- imbi_api/auth/tokens.py +187 -0
- imbi_api/domain/__init__.py +1 -0
- imbi_api/domain/models.py +1191 -0
- imbi_api/domain/scoring.py +141 -0
- imbi_api/email/__init__.py +171 -0
- imbi_api/email/client.py +274 -0
- imbi_api/email/dependencies.py +34 -0
- imbi_api/email/models.py +131 -0
- imbi_api/email/templates/base.html +203 -0
- imbi_api/email/templates/base.txt +13 -0
- imbi_api/email/templates/password_reset.html +49 -0
- imbi_api/email/templates/password_reset.txt +24 -0
- imbi_api/email/templates/welcome.html +42 -0
- imbi_api/email/templates/welcome.txt +24 -0
- imbi_api/email/templates.py +192 -0
- imbi_api/endpoints/__init__.py +59 -0
- imbi_api/endpoints/_helpers.py +241 -0
- imbi_api/endpoints/admin.py +48 -0
- imbi_api/endpoints/admin_plugins.py +483 -0
- imbi_api/endpoints/api_keys.py +406 -0
- imbi_api/endpoints/auth.py +1311 -0
- imbi_api/endpoints/auth_providers.py +668 -0
- imbi_api/endpoints/blueprints.py +347 -0
- imbi_api/endpoints/client_credentials.py +396 -0
- imbi_api/endpoints/document_templates.py +590 -0
- imbi_api/endpoints/documents.py +678 -0
- imbi_api/endpoints/environments.py +537 -0
- imbi_api/endpoints/events.py +264 -0
- imbi_api/endpoints/identity_plugins.py +79 -0
- imbi_api/endpoints/link_definitions.py +488 -0
- imbi_api/endpoints/local_auth.py +77 -0
- imbi_api/endpoints/mfa.py +456 -0
- imbi_api/endpoints/operations_log.py +642 -0
- imbi_api/endpoints/organizations.py +523 -0
- imbi_api/endpoints/plugin_edges.py +369 -0
- imbi_api/endpoints/plugin_entities.py +345 -0
- imbi_api/endpoints/project_configuration.py +408 -0
- imbi_api/endpoints/project_deployments.py +1113 -0
- imbi_api/endpoints/project_logs.py +525 -0
- imbi_api/endpoints/project_plugins.py +201 -0
- imbi_api/endpoints/project_type_plugins.py +167 -0
- imbi_api/endpoints/project_types.py +458 -0
- imbi_api/endpoints/projects.py +1632 -0
- imbi_api/endpoints/releases.py +1070 -0
- imbi_api/endpoints/roles.py +567 -0
- imbi_api/endpoints/sa_api_keys.py +387 -0
- imbi_api/endpoints/scoring.py +574 -0
- imbi_api/endpoints/scoring_policies.py +345 -0
- imbi_api/endpoints/service_accounts.py +449 -0
- imbi_api/endpoints/service_plugins.py +869 -0
- imbi_api/endpoints/status.py +21 -0
- imbi_api/endpoints/tags.py +334 -0
- imbi_api/endpoints/teams.py +621 -0
- imbi_api/endpoints/third_party_services.py +1333 -0
- imbi_api/endpoints/uploads.py +363 -0
- imbi_api/endpoints/user_activity.py +1047 -0
- imbi_api/endpoints/users.py +918 -0
- imbi_api/endpoints/webhooks.py +891 -0
- imbi_api/entrypoint.py +228 -0
- imbi_api/graph_sql.py +26 -0
- imbi_api/identity/__init__.py +1 -0
- imbi_api/identity/endpoints.py +272 -0
- imbi_api/identity/errors.py +28 -0
- imbi_api/identity/flows.py +434 -0
- imbi_api/identity/host_integration.py +168 -0
- imbi_api/identity/models.py +79 -0
- imbi_api/identity/repository.py +428 -0
- imbi_api/identity/resolution.py +203 -0
- imbi_api/identity/state.py +127 -0
- imbi_api/identity/sweeper.py +133 -0
- imbi_api/lifespans.py +163 -0
- imbi_api/llm/__init__.py +0 -0
- imbi_api/llm/dependencies.py +20 -0
- imbi_api/middleware/__init__.py +5 -0
- imbi_api/middleware/rate_limit.py +53 -0
- imbi_api/models.py +104 -0
- imbi_api/openapi.py +437 -0
- imbi_api/patch.py +107 -0
- imbi_api/plugins/__init__.py +40 -0
- imbi_api/plugins/assignments.py +128 -0
- imbi_api/plugins/credentials.py +245 -0
- imbi_api/plugins/installer.py +87 -0
- imbi_api/plugins/lifecycle.py +141 -0
- imbi_api/plugins/reload.py +80 -0
- imbi_api/plugins/resolution.py +233 -0
- imbi_api/plugins/schemas.py +80 -0
- imbi_api/py.typed +0 -0
- imbi_api/relationships.py +23 -0
- imbi_api/scoring/__init__.py +44 -0
- imbi_api/scoring/queue.py +281 -0
- imbi_api/settings.py +302 -0
- imbi_api/storage/__init__.py +13 -0
- imbi_api/storage/client.py +187 -0
- imbi_api/storage/dependencies.py +21 -0
- imbi_api/storage/thumbnails.py +90 -0
- imbi_api/storage/validation.py +107 -0
- imbi_api-2.0.0.dist-info/METADATA +188 -0
- imbi_api-2.0.0.dist-info/RECORD +112 -0
- imbi_api-2.0.0.dist-info/WHEEL +4 -0
- imbi_api-2.0.0.dist-info/entry_points.txt +2 -0
- 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
|
imbi_api/auth/models.py
ADDED
|
@@ -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
|