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.
@@ -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