msaas-integrations 0.1.0__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.
- msaas_integrations-0.1.0/.gitignore +23 -0
- msaas_integrations-0.1.0/PKG-INFO +19 -0
- msaas_integrations-0.1.0/pyproject.toml +41 -0
- msaas_integrations-0.1.0/src/integrations/__init__.py +42 -0
- msaas_integrations-0.1.0/src/integrations/config.py +78 -0
- msaas_integrations-0.1.0/src/integrations/crypto.py +39 -0
- msaas_integrations-0.1.0/src/integrations/manager.py +221 -0
- msaas_integrations-0.1.0/src/integrations/models.py +83 -0
- msaas_integrations-0.1.0/src/integrations/registry.py +118 -0
- msaas_integrations-0.1.0/src/integrations/router.py +166 -0
- msaas_integrations-0.1.0/src/integrations/store.py +63 -0
- msaas_integrations-0.1.0/tests/__init__.py +0 -0
- msaas_integrations-0.1.0/tests/conftest.py +58 -0
- msaas_integrations-0.1.0/tests/test_config.py +76 -0
- msaas_integrations-0.1.0/tests/test_crypto.py +49 -0
- msaas_integrations-0.1.0/tests/test_manager.py +153 -0
- msaas_integrations-0.1.0/tests/test_models.py +122 -0
- msaas_integrations-0.1.0/tests/test_registry.py +94 -0
- msaas_integrations-0.1.0/tests/test_router.py +188 -0
- msaas_integrations-0.1.0/tests/test_store.py +81 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
.turbo/
|
|
5
|
+
*.pyc
|
|
6
|
+
__pycache__/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
!.env.*.example
|
|
15
|
+
!.env.*.template
|
|
16
|
+
.DS_Store
|
|
17
|
+
coverage/
|
|
18
|
+
|
|
19
|
+
# Runtime artifacts
|
|
20
|
+
logs_llm/
|
|
21
|
+
vectors.db
|
|
22
|
+
vectors.db-shm
|
|
23
|
+
vectors.db-wal
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msaas-integrations
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Third-party integration framework for SaaS applications
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: cryptography>=44.0.0
|
|
8
|
+
Requires-Dist: msaas-api-core
|
|
9
|
+
Requires-Dist: msaas-errors
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: fastapi
|
|
17
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
|
|
18
|
+
Provides-Extra: oauth
|
|
19
|
+
Requires-Dist: httpx>=0.28.0; extra == 'oauth'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "msaas-integrations"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Third-party integration framework for SaaS applications"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
dependencies = [
|
|
8
|
+
"msaas-api-core",
|
|
9
|
+
"msaas-errors",
|
|
10
|
+
"pydantic>=2.0",
|
|
11
|
+
"cryptography>=44.0.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
fastapi = ["fastapi>=0.115.0"]
|
|
16
|
+
oauth = ["httpx>=0.28.0"]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
"pytest-asyncio>=0.24.0",
|
|
20
|
+
"httpx>=0.27.0",
|
|
21
|
+
"fastapi>=0.115.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/integrations"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
target-version = "py312"
|
|
37
|
+
line-length = 100
|
|
38
|
+
|
|
39
|
+
[tool.uv.sources]
|
|
40
|
+
msaas-api-core = { workspace = true }
|
|
41
|
+
msaas-errors = { workspace = true }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Willian Integrations -- Third-party integration framework for SaaS applications."""
|
|
2
|
+
|
|
3
|
+
from integrations.config import (
|
|
4
|
+
IntegrationsConfig,
|
|
5
|
+
get_integrations,
|
|
6
|
+
init_integrations,
|
|
7
|
+
reset,
|
|
8
|
+
)
|
|
9
|
+
from integrations.crypto import CredentialEncryptor, FernetEncryptor
|
|
10
|
+
from integrations.manager import IntegrationManager
|
|
11
|
+
from integrations.models import (
|
|
12
|
+
APIKeyCredentials,
|
|
13
|
+
AuthType,
|
|
14
|
+
ConnectionStatus,
|
|
15
|
+
Integration,
|
|
16
|
+
IntegrationProvider,
|
|
17
|
+
IntegrationStatus,
|
|
18
|
+
OAuthCredentials,
|
|
19
|
+
)
|
|
20
|
+
from integrations.registry import ProviderRegistry, default_registry
|
|
21
|
+
from integrations.store import InMemoryIntegrationStore, IntegrationStore
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"APIKeyCredentials",
|
|
25
|
+
"AuthType",
|
|
26
|
+
"ConnectionStatus",
|
|
27
|
+
"CredentialEncryptor",
|
|
28
|
+
"FernetEncryptor",
|
|
29
|
+
"InMemoryIntegrationStore",
|
|
30
|
+
"Integration",
|
|
31
|
+
"IntegrationManager",
|
|
32
|
+
"IntegrationProvider",
|
|
33
|
+
"IntegrationStatus",
|
|
34
|
+
"IntegrationsConfig",
|
|
35
|
+
"IntegrationStore",
|
|
36
|
+
"OAuthCredentials",
|
|
37
|
+
"ProviderRegistry",
|
|
38
|
+
"default_registry",
|
|
39
|
+
"get_integrations",
|
|
40
|
+
"init_integrations",
|
|
41
|
+
"reset",
|
|
42
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Module configuration and global singleton access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_config: IntegrationsConfig | None = None
|
|
9
|
+
_manager: object | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IntegrationsConfig(BaseModel):
|
|
13
|
+
"""Configuration for the integrations module.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
encryption_key: Fernet-compatible key used to encrypt credentials at rest.
|
|
17
|
+
oauth_redirect_base_url: Base URL appended with provider name for OAuth callbacks.
|
|
18
|
+
health_check_interval_seconds: Default interval between automatic health checks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
encryption_key: str
|
|
22
|
+
oauth_redirect_base_url: str = "http://localhost:8000/integrations/oauth/callback"
|
|
23
|
+
health_check_interval_seconds: int = 300
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def init_integrations(config: IntegrationsConfig) -> IntegrationsConfig:
|
|
27
|
+
"""Initialize the integrations module.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: Module configuration.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The active IntegrationsConfig.
|
|
34
|
+
"""
|
|
35
|
+
global _config, _manager
|
|
36
|
+
_config = config
|
|
37
|
+
_manager = None
|
|
38
|
+
return _config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_config() -> IntegrationsConfig:
|
|
42
|
+
"""Return the current module configuration.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
RuntimeError: If init_integrations() has not been called.
|
|
46
|
+
"""
|
|
47
|
+
if _config is None:
|
|
48
|
+
raise RuntimeError("Integrations module not initialized. Call init_integrations() first.")
|
|
49
|
+
return _config
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_integrations():
|
|
53
|
+
"""Return or create the singleton IntegrationManager.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If init_integrations() has not been called.
|
|
57
|
+
"""
|
|
58
|
+
global _manager
|
|
59
|
+
if _config is None:
|
|
60
|
+
raise RuntimeError("Integrations module not initialized. Call init_integrations() first.")
|
|
61
|
+
if _manager is None:
|
|
62
|
+
from integrations.manager import IntegrationManager
|
|
63
|
+
from integrations.registry import default_registry
|
|
64
|
+
from integrations.store import InMemoryIntegrationStore
|
|
65
|
+
|
|
66
|
+
_manager = IntegrationManager(
|
|
67
|
+
config=_config,
|
|
68
|
+
registry=default_registry(),
|
|
69
|
+
store=InMemoryIntegrationStore(),
|
|
70
|
+
)
|
|
71
|
+
return _manager
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def reset() -> None:
|
|
75
|
+
"""Reset module state (useful for testing)."""
|
|
76
|
+
global _config, _manager
|
|
77
|
+
_config = None
|
|
78
|
+
_manager = None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Credential encryption and decryption utilities.
|
|
2
|
+
|
|
3
|
+
Uses Fernet symmetric encryption from the `cryptography` library behind
|
|
4
|
+
a thin protocol so callers are not coupled to the concrete implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from cryptography.fernet import Fernet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class CredentialEncryptor(Protocol):
|
|
17
|
+
"""Protocol for encrypting / decrypting credential payloads."""
|
|
18
|
+
|
|
19
|
+
def encrypt(self, data: dict[str, Any]) -> bytes: ...
|
|
20
|
+
def decrypt(self, token: bytes) -> dict[str, Any]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FernetEncryptor:
|
|
24
|
+
"""Fernet-based credential encryptor.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
key: A URL-safe base64-encoded 32-byte key (``Fernet.generate_key()``).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, key: str) -> None:
|
|
31
|
+
self._fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
|
32
|
+
|
|
33
|
+
def encrypt(self, data: dict[str, Any]) -> bytes:
|
|
34
|
+
"""Serialize *data* to JSON and return the Fernet-encrypted token."""
|
|
35
|
+
return self._fernet.encrypt(json.dumps(data).encode())
|
|
36
|
+
|
|
37
|
+
def decrypt(self, token: bytes) -> dict[str, Any]:
|
|
38
|
+
"""Decrypt a Fernet token and deserialize the JSON payload."""
|
|
39
|
+
return json.loads(self._fernet.decrypt(token).decode())
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Integration connection manager -- orchestrates connect/disconnect/refresh flows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from integrations.config import IntegrationsConfig
|
|
11
|
+
from integrations.crypto import FernetEncryptor
|
|
12
|
+
from integrations.models import (
|
|
13
|
+
APIKeyCredentials,
|
|
14
|
+
AuthType,
|
|
15
|
+
ConnectionStatus,
|
|
16
|
+
Integration,
|
|
17
|
+
IntegrationStatus,
|
|
18
|
+
OAuthCredentials,
|
|
19
|
+
)
|
|
20
|
+
from integrations.registry import ProviderRegistry
|
|
21
|
+
from integrations.store import IntegrationStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IntegrationManager:
|
|
25
|
+
"""High-level manager for creating, refreshing, and checking integrations.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Module-level configuration (encryption key, redirect URL).
|
|
29
|
+
registry: Provider definitions catalogue.
|
|
30
|
+
store: Persistence backend for integration records.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: IntegrationsConfig,
|
|
36
|
+
registry: ProviderRegistry,
|
|
37
|
+
store: IntegrationStore,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._config = config
|
|
40
|
+
self._registry = registry
|
|
41
|
+
self._store = store
|
|
42
|
+
self._encryptor = FernetEncryptor(config.encryption_key)
|
|
43
|
+
|
|
44
|
+
# ── Properties ──────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def registry(self) -> ProviderRegistry:
|
|
48
|
+
return self._registry
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def store(self) -> IntegrationStore:
|
|
52
|
+
return self._store
|
|
53
|
+
|
|
54
|
+
# ── OAuth helpers ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def generate_auth_url(
|
|
57
|
+
self,
|
|
58
|
+
provider_name: str,
|
|
59
|
+
*,
|
|
60
|
+
client_id: str,
|
|
61
|
+
state: str | None = None,
|
|
62
|
+
extra_params: dict[str, str] | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Build the OAuth authorization redirect URL for a provider.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
provider_name: Registered provider name.
|
|
68
|
+
client_id: OAuth application client ID.
|
|
69
|
+
state: Opaque state for CSRF protection.
|
|
70
|
+
extra_params: Additional query parameters.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Full authorization URL ready for redirect.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
KeyError: If the provider is not registered.
|
|
77
|
+
ValueError: If the provider does not support OAuth.
|
|
78
|
+
"""
|
|
79
|
+
provider = self._registry.get(provider_name)
|
|
80
|
+
if provider.auth_type != AuthType.OAUTH or not provider.oauth_authorize_url:
|
|
81
|
+
raise ValueError(f"Provider '{provider_name}' does not support OAuth")
|
|
82
|
+
|
|
83
|
+
params: dict[str, str] = {
|
|
84
|
+
"client_id": client_id,
|
|
85
|
+
"redirect_uri": f"{self._config.oauth_redirect_base_url}/{provider_name}",
|
|
86
|
+
"response_type": "code",
|
|
87
|
+
}
|
|
88
|
+
if provider.required_scopes:
|
|
89
|
+
params["scope"] = " ".join(provider.required_scopes)
|
|
90
|
+
if state:
|
|
91
|
+
params["state"] = state
|
|
92
|
+
if extra_params:
|
|
93
|
+
params.update(extra_params)
|
|
94
|
+
|
|
95
|
+
return f"{provider.oauth_authorize_url}?{urlencode(params)}"
|
|
96
|
+
|
|
97
|
+
# ── Connect flows ───────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async def connect_oauth(
|
|
100
|
+
self,
|
|
101
|
+
provider_name: str,
|
|
102
|
+
credentials: OAuthCredentials,
|
|
103
|
+
*,
|
|
104
|
+
name: str | None = None,
|
|
105
|
+
config: dict[str, Any] | None = None,
|
|
106
|
+
) -> Integration:
|
|
107
|
+
"""Create an integration from an OAuth code exchange result.
|
|
108
|
+
|
|
109
|
+
The caller is responsible for exchanging the authorization code with
|
|
110
|
+
the provider's token endpoint; this method stores the resulting tokens.
|
|
111
|
+
"""
|
|
112
|
+
provider = self._registry.get(provider_name)
|
|
113
|
+
encrypted = self._encryptor.encrypt(credentials.model_dump(mode="json"))
|
|
114
|
+
|
|
115
|
+
integration = Integration(
|
|
116
|
+
id=str(uuid.uuid4()),
|
|
117
|
+
name=name or provider.name,
|
|
118
|
+
provider=provider.name,
|
|
119
|
+
auth_type=AuthType.OAUTH,
|
|
120
|
+
status=IntegrationStatus.ACTIVE,
|
|
121
|
+
config=config or {},
|
|
122
|
+
credentials_encrypted=encrypted,
|
|
123
|
+
)
|
|
124
|
+
return await self._store.save(integration)
|
|
125
|
+
|
|
126
|
+
async def connect_api_key(
|
|
127
|
+
self,
|
|
128
|
+
provider_name: str,
|
|
129
|
+
credentials: APIKeyCredentials,
|
|
130
|
+
*,
|
|
131
|
+
name: str | None = None,
|
|
132
|
+
config: dict[str, Any] | None = None,
|
|
133
|
+
) -> Integration:
|
|
134
|
+
"""Create an integration using an API key."""
|
|
135
|
+
provider = self._registry.get(provider_name)
|
|
136
|
+
encrypted = self._encryptor.encrypt(credentials.model_dump())
|
|
137
|
+
|
|
138
|
+
integration = Integration(
|
|
139
|
+
id=str(uuid.uuid4()),
|
|
140
|
+
name=name or provider.name,
|
|
141
|
+
provider=provider.name,
|
|
142
|
+
auth_type=AuthType.API_KEY,
|
|
143
|
+
status=IntegrationStatus.ACTIVE,
|
|
144
|
+
config=config or {},
|
|
145
|
+
credentials_encrypted=encrypted,
|
|
146
|
+
)
|
|
147
|
+
return await self._store.save(integration)
|
|
148
|
+
|
|
149
|
+
# ── Credential management ───────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def get_credentials(self, integration: Integration) -> dict[str, Any]:
|
|
152
|
+
"""Decrypt and return credentials for an integration.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If the integration has no stored credentials.
|
|
156
|
+
"""
|
|
157
|
+
if integration.credentials_encrypted is None:
|
|
158
|
+
raise ValueError("Integration has no stored credentials")
|
|
159
|
+
return self._encryptor.decrypt(integration.credentials_encrypted)
|
|
160
|
+
|
|
161
|
+
async def refresh_credentials(
|
|
162
|
+
self, integration_id: str, new_credentials: OAuthCredentials
|
|
163
|
+
) -> Integration:
|
|
164
|
+
"""Replace the stored credentials for an existing OAuth integration.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
KeyError: If the integration does not exist.
|
|
168
|
+
"""
|
|
169
|
+
integration = await self._store.get(integration_id)
|
|
170
|
+
if integration is None:
|
|
171
|
+
raise KeyError(f"Integration '{integration_id}' not found")
|
|
172
|
+
|
|
173
|
+
encrypted = self._encryptor.encrypt(new_credentials.model_dump(mode="json"))
|
|
174
|
+
integration.credentials_encrypted = encrypted
|
|
175
|
+
integration.status = IntegrationStatus.ACTIVE
|
|
176
|
+
integration.updated_at = datetime.now(UTC)
|
|
177
|
+
return await self._store.save(integration)
|
|
178
|
+
|
|
179
|
+
# ── Disconnect ──────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
async def disconnect(self, integration_id: str) -> bool:
|
|
182
|
+
"""Remove an integration.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
True if the integration was found and deleted.
|
|
186
|
+
"""
|
|
187
|
+
return await self._store.delete(integration_id)
|
|
188
|
+
|
|
189
|
+
# ── Health ──────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
async def check_health(self, integration_id: str) -> ConnectionStatus:
|
|
192
|
+
"""Perform a basic health check on an integration.
|
|
193
|
+
|
|
194
|
+
Currently validates that the integration exists and has credentials.
|
|
195
|
+
Real implementations would call the provider's API to verify access.
|
|
196
|
+
"""
|
|
197
|
+
integration = await self._store.get(integration_id)
|
|
198
|
+
now = datetime.now(UTC)
|
|
199
|
+
|
|
200
|
+
if integration is None:
|
|
201
|
+
return ConnectionStatus(
|
|
202
|
+
connected=False, last_checked=now, error_message="Integration not found"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if integration.credentials_encrypted is None:
|
|
206
|
+
return ConnectionStatus(
|
|
207
|
+
connected=False, last_checked=now, error_message="No credentials stored"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
self._encryptor.decrypt(integration.credentials_encrypted)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
integration.status = IntegrationStatus.ERROR
|
|
214
|
+
await self._store.save(integration)
|
|
215
|
+
return ConnectionStatus(
|
|
216
|
+
connected=False,
|
|
217
|
+
last_checked=now,
|
|
218
|
+
error_message=f"Credential decryption failed: {exc}",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return ConnectionStatus(connected=True, last_checked=now)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Domain models for third-party integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthType(StrEnum):
|
|
13
|
+
"""Supported authentication mechanisms."""
|
|
14
|
+
|
|
15
|
+
OAUTH = "oauth"
|
|
16
|
+
API_KEY = "api_key"
|
|
17
|
+
WEBHOOK = "webhook"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IntegrationStatus(StrEnum):
|
|
21
|
+
"""Lifecycle status of an integration connection."""
|
|
22
|
+
|
|
23
|
+
ACTIVE = "active"
|
|
24
|
+
INACTIVE = "inactive"
|
|
25
|
+
ERROR = "error"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OAuthCredentials(BaseModel):
|
|
29
|
+
"""OAuth 2.0 credential set."""
|
|
30
|
+
|
|
31
|
+
access_token: str
|
|
32
|
+
refresh_token: str | None = None
|
|
33
|
+
expires_at: datetime | None = None
|
|
34
|
+
scopes: list[str] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIKeyCredentials(BaseModel):
|
|
38
|
+
"""API key credential set."""
|
|
39
|
+
|
|
40
|
+
api_key: str
|
|
41
|
+
header_name: str = "Authorization"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConnectionStatus(BaseModel):
|
|
45
|
+
"""Health check result for an integration connection."""
|
|
46
|
+
|
|
47
|
+
connected: bool
|
|
48
|
+
last_checked: datetime
|
|
49
|
+
error_message: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class IntegrationProvider(BaseModel):
|
|
53
|
+
"""Definition of a third-party service provider.
|
|
54
|
+
|
|
55
|
+
Providers are registered in the ProviderRegistry and define
|
|
56
|
+
what is needed to connect to a service (auth type, scopes, URLs).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
description: str = ""
|
|
61
|
+
auth_type: AuthType
|
|
62
|
+
oauth_authorize_url: str | None = None
|
|
63
|
+
oauth_token_url: str | None = None
|
|
64
|
+
required_scopes: list[str] = Field(default_factory=list)
|
|
65
|
+
config_schema: dict[str, Any] = Field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Integration(BaseModel):
|
|
69
|
+
"""A user's connection to a third-party provider.
|
|
70
|
+
|
|
71
|
+
Represents a configured and (optionally) authenticated link
|
|
72
|
+
between the application and an external service.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
name: str
|
|
77
|
+
provider: str
|
|
78
|
+
auth_type: AuthType
|
|
79
|
+
status: IntegrationStatus = IntegrationStatus.INACTIVE
|
|
80
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
81
|
+
credentials_encrypted: bytes | None = None
|
|
82
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
83
|
+
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Provider registry -- catalogue of available third-party integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from integrations.models import AuthType, IntegrationProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderRegistry:
|
|
9
|
+
"""Thread-safe registry of IntegrationProvider definitions.
|
|
10
|
+
|
|
11
|
+
Providers are looked up by name (case-insensitive).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._providers: dict[str, IntegrationProvider] = {}
|
|
16
|
+
|
|
17
|
+
def register(self, provider: IntegrationProvider) -> None:
|
|
18
|
+
"""Register a provider definition.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
provider: The provider to register.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If a provider with the same name already exists.
|
|
25
|
+
"""
|
|
26
|
+
key = provider.name.lower()
|
|
27
|
+
if key in self._providers:
|
|
28
|
+
raise ValueError(f"Provider '{provider.name}' is already registered")
|
|
29
|
+
self._providers[key] = provider
|
|
30
|
+
|
|
31
|
+
def get(self, name: str) -> IntegrationProvider:
|
|
32
|
+
"""Look up a provider by name (case-insensitive).
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
KeyError: If the provider is not registered.
|
|
36
|
+
"""
|
|
37
|
+
key = name.lower()
|
|
38
|
+
if key not in self._providers:
|
|
39
|
+
raise KeyError(f"Provider '{name}' not found")
|
|
40
|
+
return self._providers[key]
|
|
41
|
+
|
|
42
|
+
def list_providers(self) -> list[IntegrationProvider]:
|
|
43
|
+
"""Return all registered providers sorted by name."""
|
|
44
|
+
return sorted(self._providers.values(), key=lambda p: p.name)
|
|
45
|
+
|
|
46
|
+
def __contains__(self, name: str) -> bool:
|
|
47
|
+
return name.lower() in self._providers
|
|
48
|
+
|
|
49
|
+
def __len__(self) -> int:
|
|
50
|
+
return len(self._providers)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Built-in provider definitions ───────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
SLACK_PROVIDER = IntegrationProvider(
|
|
56
|
+
name="slack",
|
|
57
|
+
description="Slack workspace messaging",
|
|
58
|
+
auth_type=AuthType.OAUTH,
|
|
59
|
+
oauth_authorize_url="https://slack.com/oauth/v2/authorize",
|
|
60
|
+
oauth_token_url="https://slack.com/api/oauth.v2.access",
|
|
61
|
+
required_scopes=["chat:write", "channels:read"],
|
|
62
|
+
config_schema={
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {"team_id": {"type": "string"}},
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
GITHUB_PROVIDER = IntegrationProvider(
|
|
69
|
+
name="github",
|
|
70
|
+
description="GitHub repositories and issues",
|
|
71
|
+
auth_type=AuthType.OAUTH,
|
|
72
|
+
oauth_authorize_url="https://github.com/login/oauth/authorize",
|
|
73
|
+
oauth_token_url="https://github.com/login/oauth/access_token",
|
|
74
|
+
required_scopes=["repo", "read:user"],
|
|
75
|
+
config_schema={
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {"org": {"type": "string"}},
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
GOOGLE_PROVIDER = IntegrationProvider(
|
|
82
|
+
name="google",
|
|
83
|
+
description="Google Workspace (Drive, Calendar, Gmail)",
|
|
84
|
+
auth_type=AuthType.OAUTH,
|
|
85
|
+
oauth_authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
86
|
+
oauth_token_url="https://oauth2.googleapis.com/token",
|
|
87
|
+
required_scopes=["openid", "email", "profile"],
|
|
88
|
+
config_schema={
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {"domain": {"type": "string"}},
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
STRIPE_PROVIDER = IntegrationProvider(
|
|
95
|
+
name="stripe",
|
|
96
|
+
description="Stripe payment processing",
|
|
97
|
+
auth_type=AuthType.API_KEY,
|
|
98
|
+
required_scopes=[],
|
|
99
|
+
config_schema={
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {"webhook_secret": {"type": "string"}},
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
BUILTIN_PROVIDERS: list[IntegrationProvider] = [
|
|
106
|
+
SLACK_PROVIDER,
|
|
107
|
+
GITHUB_PROVIDER,
|
|
108
|
+
GOOGLE_PROVIDER,
|
|
109
|
+
STRIPE_PROVIDER,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def default_registry() -> ProviderRegistry:
|
|
114
|
+
"""Create a registry pre-loaded with all built-in providers."""
|
|
115
|
+
registry = ProviderRegistry()
|
|
116
|
+
for provider in BUILTIN_PROVIDERS:
|
|
117
|
+
registry.register(provider)
|
|
118
|
+
return registry
|